diff --git a/docs/compiler/executables.mdx b/docs/compiler/executables.mdx index 1abdf1bc9b47..08d58095ea87 100644 --- a/docs/compiler/executables.mdx +++ b/docs/compiler/executables.mdx @@ -834,12 +834,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 }); @@ -1036,12 +1034,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..a4f980029b0b 100644 --- a/docs/guides/http/stream-file.mdx +++ b/docs/guides/http/stream-file.mdx @@ -34,17 +34,30 @@ 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); }, }); ``` +The check compares paths, so a symbolic link inside `public` is still followed to wherever it points. Don't keep links to private files there. + +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 52147eac8dec..6efcfc29fdf3 100644 --- a/docs/runtime/environment-variables.mdx +++ b/docs/runtime/environment-variables.mdx @@ -225,6 +225,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 38b353a437bd..5642421a43aa 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,7 @@ 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. +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. A symlink never leads out of `dir`. Bun resolves symlinks as if `dir` were the root directory: @@ -216,6 +223,8 @@ A symlink never leads out of `dir`. Bun resolves symlinks as if `dir` were the r These are the rules of `openat2(RESOLVE_IN_ROOT)` on Linux. Bun applies them itself on macOS, on FreeBSD, and on Linux when `openat2` is not available. On Windows, Bun follows symlinks wherever they lead. +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 case-varied URL (`/static/Admin/secret.txt`) routes to the directory wildcard rather than a sibling `/static/admin/*` @@ -259,15 +268,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..402dc48e91ed 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`. @@ -252,7 +294,7 @@ Set `http1: false` to refuse HTTP/1.x clients. Over TLS, clients that offer ALPN By default, `Bun.serve` closes connections after **10 seconds** of inactivity. A connection is idle when no data is being sent or received. That includes in-flight requests where your handler is still running but hasn't written any bytes to the response yet. Browsers and `fetch()` clients see this as a connection reset. -To configure this, set the `idleTimeout` field (in seconds). The maximum value is `255`, and `0` disables the timeout entirely. +To configure this, set the `idleTimeout` field (in seconds). The maximum value is `255`, and `0` disables the timeout entirely. Bun checks for idle connections every 4 seconds, so a connection is closed up to 4 seconds after its `idleTimeout` has passed, and never before. ```ts Bun.serve({ @@ -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/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/module-resolution.mdx b/docs/runtime/module-resolution.mdx index 206f5e269688..11894ac113c1 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. `ProxyAgent` ignores `proxyTls`. It applies `requestTls` when the proxy URL is `http:`, and its constructor throws if `requestTls` is given with an `https:` proxy URL. `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..3eb077d060b1 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 about 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, or halfway through it when `N` is less than 4. `timeout=0` means the connection isn't pooled. 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. Bun checks socket timers every 4 seconds, so the connection itself can stay open up to 4 seconds longer. If you don't set `idleTimeout`, only that timer enforces the 5 minute default. 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/networking/tcp.mdx b/docs/runtime/networking/tcp.mdx index b23eef720176..dafee5d00a09 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. If the queued data can't be sent, `close(socket, error)` gets the error: `EPIPE` or `ECONNRESET` when the peer is gone, `ETIMEDOUT` when the peer reads nothing for the socket's timeout (`socket.timeout(seconds)`, 120 seconds by default on a socket that `Bun.listen` accepted). + +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 bf1da9fd7330..0b4e5856266c 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. On the main thread, `setDefaultResultOrder()` also sets which address family `fetch()` and `Bun.connect()` dial first. That part applies to the whole process, including worker threads, because they share one DNS cache. In a worker thread, it only changes that thread's `dns.lookup()` default. ### [`node:events`](https://nodejs.org/api/events.html) @@ -45,11 +45,11 @@ 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) -🟡 `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 `tls.Server`, runs on Bun's HTTP server rather than on a `net.Server` handle, and has `setSecureContext()`, `addContext()`, `getTicketKeys()`, `setTicketKeys()` and `SNICallback`. Client sockets and request sockets (`req.socket`) are `tls.TLSSocket`s. The server negotiates ALPN (`ALPNProtocols`, `ALPNCallback`) and emits `'secureConnection'`, `'tlsClientError'` and `'keylog'` like a `tls.Server`. What `node:tls` is missing is missing here too. ### [`node:os`](https://nodejs.org/api/os.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) @@ -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/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/hawk.toml b/hawk.toml index a70d0ab48b5e..ac14ea9fb4c4 100644 --- a/hawk.toml +++ b/hawk.toml @@ -44,22 +44,6 @@ comment = "HOST_EXPORT(" reason = "codegen-scraped host export" # Group A: enum variants whose discriminants form external code tables (kept on purpose). -[[override]] -lint = "hawk::dead_public" -crate = "bun_http_types" -item = "h2::FrameType::HTTP_FRAME_ALTSVC" -kind = "enum_variant" -level = "expect" -reason = "external code table: HTTP/2 frame type numbering" - -[[override]] -lint = "hawk::dead_public" -crate = "bun_http_types" -item = "h2::FrameType::HTTP_FRAME_ORIGIN" -kind = "enum_variant" -level = "expect" -reason = "external code table: HTTP/2 frame type numbering" - [[override]] lint = "hawk::dead_public" crate = "bun_jsc" diff --git a/mordant-baseline.toml b/mordant-baseline.toml index ccd1f7a198d8..a368c250907a 100644 --- a/mordant-baseline.toml +++ b/mordant-baseline.toml @@ -228,7 +228,7 @@ [bun_sys] "error_collapsed_to_bool:src/sys/lib.rs" = 4 -"unused_pub:src/sys/lib.rs" = 55 +"unused_pub:src/sys/lib.rs" = 34 "unused_pub:src/sys/windows/mod.rs" = 5 [bun_text_diff] diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 77a8fe93f0d1..0eff7958f2b0 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -5138,6 +5138,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; @@ -7139,10 +7154,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 @@ -7167,11 +7185,14 @@ 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. * - * To reset the timeout, call this function again. + * The socket stays open. Call {@link end `end()`} or {@link terminate `terminate()`} in the handler to close it. + * Both drop the data an earlier `end(data)` queued and the peer hasn't taken yet. * - * When a timeout happens, the `timeout` callback is called and the socket is closed. + * Reads and writes don't restart the timer. To reset the timeout, call this function again. `0` cancels it. + * + * Bun checks socket timeouts every 4 seconds, so the handler can run up to 4 seconds late. It never runs early. */ timeout(seconds: number): void; @@ -7585,6 +7606,11 @@ declare module "bun" { interface TLSUpgradeOptions { data?: Data; + /** + * TLS configuration for the upgraded socket. + * + * On a client socket, an object that sets no option, such as `{}`, is the same as `true`. + */ tls: TLSOptions | boolean; socket: SocketHandler; } @@ -7622,11 +7648,16 @@ declare module "bun" { * handler, this is called only after the handshake completes. */ open?(socket: Socket): void | Promise; + /** + * Called once the socket has closed. `error` is set when the connection + * failed: a read reported a reset (`syscall: "read"`), or the peer was gone + * before the data queued by `end(data)` was sent (`syscall: "write"`). + */ 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. + * (a record that does not decrypt, a fatal alert from the peer). After a TLS + * failure the socket closes, with or without this handler. */ error?(socket: Socket, error: Error): void | Promise; data?(socket: Socket, data: BinaryTypeList[DataBinaryType]): void | Promise; @@ -7661,7 +7692,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; /** @@ -7728,6 +7760,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. * @@ -7756,22 +7802,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; } @@ -7782,14 +7830,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; /** @@ -9419,12 +9473,10 @@ 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. Bun checks socket timers every 4 seconds, so the + * connection itself can stay open up to 4 seconds longer. * * @default 300 */ diff --git a/packages/bun-types/fetch.d.ts b/packages/bun-types/fetch.d.ts index 19c5a63a6faf..a52a8024a594 100644 --- a/packages/bun-types/fetch.d.ts +++ b/packages/bun-types/fetch.d.ts @@ -106,6 +106,7 @@ declare module "bun" { interface BunRequestOverride extends LibOrFallbackRequest { headers: BunHeadersOverride; + clone(): Request; /** * Returns a {@link ReadableStream} of the body decoded as UTF-8 text. * @@ -118,6 +119,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 c02e1080e7a1..11d2e05d17a0 100644 --- a/packages/bun-types/globals.d.ts +++ b/packages/bun-types/globals.d.ts @@ -2019,6 +2019,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; @@ -2174,6 +2175,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 5cad6313351f..04e35f8e3919 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,15 +675,18 @@ 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. A symlink never leads out of `dir`: symlinks resolve - * as if `dir` were the root directory, so a `..` stops at `dir` and an - * absolute target starts from it. Windows follows symlinks wherever they - * lead. 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. + * 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. A symlink never leads out of + * `dir`: symlinks resolve as if `dir` were the root directory, so a `..` + * stops at `dir` and an absolute target starts from it. Windows follows + * symlinks wherever they lead. 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. * * Responses carry `Content-Type` (from the file extension), * `Last-Modified`, a weak `ETag`, and support single-range `Range` @@ -714,13 +715,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 = { @@ -728,7 +740,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 + > >; }; @@ -841,10 +857,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' @@ -949,14 +983,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 { @@ -1063,13 +1089,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} @@ -1222,6 +1251,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..58e192d97d4c 100644 --- a/packages/bun-usockets/src/bsd.c +++ b/packages/bun-usockets/src/bsd.c @@ -24,6 +24,7 @@ #include "internal/fault_inject.h" #include +#include #include #include @@ -59,6 +60,7 @@ static void init_debug_logging() { #include #else /* _WIN32 */ #include +#include #endif #if defined(__APPLE__) @@ -83,6 +85,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 +158,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 +695,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 @@ -695,6 +735,112 @@ void bsd_socket_flush(LIBUS_SOCKET_DESCRIPTOR fd) { #endif } +/* One bit per descriptor a socket in this process owns, on any thread's loop. */ +#define BSD_OWNED_PAGE_BITS (1u << 15) +#ifdef _WIN32 +/* The key of a handle is its index in the process's handle table, which holds 2^24. A SOCKET past that isn't tracked. */ +#define BSD_OWNED_KEYS (1u << 24) +#define BSD_OWNED_KEY(fd) ((uintptr_t) (fd) >> 2) +#else +#define BSD_OWNED_KEYS (1u << 31) +#endif +/* Not an array: half a megabyte more of zero-filled data makes room there for a transparent huge page, which nothing can opt out of. */ +static _Atomic(void *) bsd_owned_pages; + +static void *bsd_owned_level(_Atomic(void *) *slot, size_t size) { + void *level = atomic_load(slot); + if (UNLIKELY(!level)) { + void *fresh = us_calloc(1, size); + if (UNLIKELY(!fresh)) { + Bun__outOfMemory(); + } + if (atomic_compare_exchange_strong(slot, &level, fresh)) { + level = fresh; + } else { + us_free(fresh); + } + } + return level; +} + +static _Atomic(uint64_t) *bsd_owned_word(unsigned int key) { + _Atomic(void *) *pages = bsd_owned_level(&bsd_owned_pages, BSD_OWNED_KEYS / BSD_OWNED_PAGE_BITS * sizeof(void *)); + _Atomic(uint64_t) *page = bsd_owned_level(&pages[key / BSD_OWNED_PAGE_BITS], BSD_OWNED_PAGE_BITS / 8); + return &page[key % BSD_OWNED_PAGE_BITS / 64]; +} + +#ifdef _WIN32 +/* Only a socket this process made: another process may hold one that came from elsewhere, and a shutdown() reaches every holder. */ +static void bsd_own_new_socket(SOCKET fd) { + uintptr_t key = BSD_OWNED_KEY(fd); + if (key < BSD_OWNED_KEYS) { + atomic_fetch_or(bsd_owned_word((unsigned int) key), 1ull << (key % 64)); + } +} +#else +#define bsd_own_new_socket(fd) bsd_adopt_socket(fd, 0) +#endif + +int bsd_adopt_socket(LIBUS_SOCKET_DESCRIPTOR fd, int type) { + if (type) { + int actual = 0; + socklen_t length = sizeof(actual); + if (getsockopt(fd, SOL_SOCKET, SO_TYPE, (char *) &actual, &length)) { + return -1; + } + if (actual != type) { +#ifdef _WIN32 + WSASetLastError(WSAEINVAL); +#endif + errno = EINVAL; + return -1; + } + } +#ifndef _WIN32 + if (fd < 0) { + errno = EBADF; + return -1; + } + uint64_t bit = 1ull << (fd % 64); + if (atomic_fetch_or(bsd_owned_word(fd), bit) & bit) { + errno = EEXIST; + return -1; + } +#endif + return 0; +} + +void bsd_disown_socket(LIBUS_SOCKET_DESCRIPTOR fd) { +#ifdef _WIN32 + uintptr_t key = BSD_OWNED_KEY(fd); + if (key < BSD_OWNED_KEYS) { + atomic_fetch_and(bsd_owned_word((unsigned int) key), ~(1ull << (key % 64))); + } +#else + if (fd >= 0) { + atomic_fetch_and(bsd_owned_word(fd), ~(1ull << (fd % 64))); + } +#endif +} + +#ifdef _WIN32 +void bsd_shutdown_owned_sockets(void) { + _Atomic(void *) *pages = atomic_load(&bsd_owned_pages); + for (unsigned int i = 0; pages && i < BSD_OWNED_KEYS / BSD_OWNED_PAGE_BITS; i++) { + _Atomic(uint64_t) *page = atomic_load(&pages[i]); + for (unsigned int word = 0; page && word < BSD_OWNED_PAGE_BITS / 64; word++) { + uint64_t rest; + /* Read again for each socket: its thread is running and may have closed it by now. */ + for (unsigned int bit = 0; bit < 64 && (rest = atomic_load(&page[word]) >> bit); bit++) { + if (rest & 1) { + shutdown((SOCKET) ((uintptr_t) (i * BSD_OWNED_PAGE_BITS + word * 64 + bit) << 2), SD_SEND); + } + } + } + } +} +#endif + LIBUS_SOCKET_DESCRIPTOR bsd_create_socket(int domain, int type, int protocol, int *err) { bsd_winsock_ensure(); if (err != NULL) { @@ -715,6 +861,7 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_socket(int domain, int type, int protocol, in return LIBUS_SOCKET_ERROR; } + bsd_own_new_socket(created_fd); return apple_no_sigpipe(created_fd); #elif defined(_WIN32) /* Plain socket() returns an inheritable handle, so any child spawned with @@ -730,6 +877,7 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_socket(int domain, int type, int protocol, in return LIBUS_SOCKET_ERROR; } + bsd_own_new_socket(created_fd); return bsd_set_nonblocking(created_fd); #else do { @@ -743,11 +891,14 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_socket(int domain, int type, int protocol, in return LIBUS_SOCKET_ERROR; } + bsd_own_new_socket(created_fd); return bsd_set_nonblocking(apple_no_sigpipe(created_fd)); #endif } void bsd_close_socket(LIBUS_SOCKET_DESCRIPTOR fd) { + /* Before close(): the kernel can hand the number to another thread right after it. */ + bsd_disown_socket(fd); #ifdef _WIN32 closesocket(fd); #else @@ -817,13 +968,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 +1071,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 @@ -874,6 +1116,7 @@ LIBUS_SOCKET_DESCRIPTOR bsd_accept_socket(LIBUS_SOCKET_DESCRIPTOR fd, struct bsd #endif internal_finalize_bsd_addr(addr); + bsd_own_new_socket(accepted_fd); #if defined(SOCK_CLOEXEC) && defined(SOCK_NONBLOCK) // skip the extra fcntl calls. @@ -1084,6 +1327,7 @@ int bsd_send_is_transient_error() { #endif } +/* A `backlog` of 0 only binds. */ static int us_internal_bind_and_listen(LIBUS_SOCKET_DESCRIPTOR listenFd, struct sockaddr *listenAddr, socklen_t listenAddrLength, int backlog, int* error) { int result; do @@ -1094,6 +1338,9 @@ static int us_internal_bind_and_listen(LIBUS_SOCKET_DESCRIPTOR listenFd, struct *error = LIBUS_ERR; return -1; } + if (backlog == 0) { + return 0; + } do result = listen(listenFd, backlog); @@ -1167,12 +1414,12 @@ static int bsd_set_reuse(LIBUS_SOCKET_DESCRIPTOR listenFd, int options) { inline __attribute__((always_inline)) LIBUS_SOCKET_DESCRIPTOR bsd_bind_listen_fd( LIBUS_SOCKET_DESCRIPTOR listenFd, struct addrinfo *listenAddr, - int port, int options, int* error ) { if (bsd_set_reuse(listenFd, options) != 0) { + *error = LIBUS_ERR; return LIBUS_SOCKET_ERROR; } @@ -1189,6 +1436,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; } } @@ -1234,6 +1482,8 @@ int bsd_socket_export(LIBUS_SOCKET_DESCRIPTOR fd, unsigned int target_pid, void if (WSADuplicateSocketW(fd, (DWORD) target_pid, (WSAPROTOCOL_INFOW *) info_out) != 0) { return WSAGetLastError(); } + /* The other process holds it too from here on, so the exit of this one leaves it alone. */ + bsd_disown_socket(fd); return 0; #else (void) fd; (void) target_pid; (void) info_out; @@ -1357,7 +1607,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,43 +1619,55 @@ 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; } - 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); - if (listenFd == 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. */ + static const int families[] = { AF_INET6, AF_INET }; + for (int i = 0; i < 2; i++) { + for (struct addrinfo *a = result; a != NULL; a = a->ai_next) { + if (a->ai_family != families[i]) { continue; } - - listenAddr = a; - if (bsd_bind_listen_fd(listenFd, listenAddr, port, options, error) != LIBUS_SOCKET_ERROR) { - freeaddrinfo(result); - return listenFd; - } - - bsd_close_socket(listenFd); - } - } - - 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); + LIBUS_SOCKET_DESCRIPTOR listenFd = bsd_create_socket(a->ai_family, a->ai_socktype, a->ai_protocol, error); if (listenFd == LIBUS_SOCKET_ERROR) { continue; } - listenAddr = a; - if (bsd_bind_listen_fd(listenFd, listenAddr, port, options, error) != LIBUS_SOCKET_ERROR) { + *error = 0; + if (bsd_bind_listen_fd(listenFd, a, 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 +1704,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 +1747,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; @@ -1551,23 +1829,19 @@ static LIBUS_SOCKET_DESCRIPTOR bsd_create_unix_socket_address(const char *path, return 0; } -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); +static LIBUS_SOCKET_DESCRIPTOR internal_bsd_create_listen_socket_unix(struct sockaddr_un* server_address, size_t addrlen, int backlog, int* error) { + LIBUS_SOCKET_DESCRIPTOR 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 + if (us_internal_bind_and_listen(listenFd, (struct sockaddr *) server_address, (socklen_t) addrlen, backlog, error)) { 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; @@ -1576,15 +1850,19 @@ static LIBUS_SOCKET_DESCRIPTOR internal_bsd_create_listen_socket_unix(const char return listenFd; } -LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket_unix(const char *path, size_t len, int options, int* error) { +static LIBUS_SOCKET_DESCRIPTOR bsd_bind_socket_unix(const char *path, size_t len, int backlog, int* error) { int dirfd_workaround_for_unix_path_len = -1; struct sockaddr_un server_address; 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,20 +1870,18 @@ 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; } } #endif - LIBUS_SOCKET_DESCRIPTOR listenFd = internal_bsd_create_listen_socket_unix(path, options, &server_address, addrlen, error); + LIBUS_SOCKET_DESCRIPTOR listenFd = internal_bsd_create_listen_socket_unix(&server_address, addrlen, backlog, error); #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) { @@ -1616,6 +1892,14 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket_unix(const char *path, size_t l return listenFd; } +LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket_unix(const char *path, size_t len, int* error) { + return bsd_bind_socket_unix(path, len, 512, error); +} + +LIBUS_SOCKET_DESCRIPTOR bsd_create_bound_socket_unix(const char *path, size_t len, int* error) { + return bsd_bind_socket_unix(path, len, 0, error); +} + /* Receive-path options every UDP socket needs, whether freshly created or * adopted from an existing fd: destination-address and TOS reporting for @@ -1685,19 +1969,6 @@ static void bsd_apply_udp_recv_options(LIBUS_SOCKET_DESCRIPTOR fd, int family, i * nonblock + SO_REUSEADDR only. Recv-path options (IP_RECVERR, PKTINFO, * SIO_UDP_CONNRESET) are NOT applied — mutating the caller's fd is observable. */ int bsd_prepare_adopted_udp_socket(LIBUS_SOCKET_DESCRIPTOR fd) { - /* Refuse to adopt anything that isn't a datagram socket. */ - int sock_type = 0; - socklen_t type_len = sizeof(sock_type); - if (getsockopt(fd, SOL_SOCKET, SO_TYPE, (char *) &sock_type, (socklen_t *) &type_len)) { - return -1; - } - if (sock_type != SOCK_DGRAM) { -#ifdef _WIN32 - WSASetLastError(WSAEINVAL); -#endif - errno = EINVAL; - return -1; - } struct sockaddr_storage ss; socklen_t len = sizeof(ss); if (getsockname(fd, (struct sockaddr *) &ss, (socklen_t *) &len)) { @@ -1891,14 +2162,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 +2210,7 @@ static int bsd_do_connect_raw(LIBUS_SOCKET_DESCRIPTOR fd, struct sockaddr *addr, return errno; } + *connected = r == 0; return 0; #endif } @@ -1975,7 +2250,7 @@ static int is_loopback(struct sockaddr_storage *sockaddr) { } #endif -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(struct sockaddr_storage *addr, struct sockaddr_storage *local_addr) { LIBUS_SOCKET_DESCRIPTOR fd = bsd_create_socket(addr->ss_family, SOCK_STREAM, 0, NULL); if (fd == LIBUS_SOCKET_ERROR) { return LIBUS_SOCKET_ERROR; @@ -2040,7 +2315,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 +2331,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(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 +2340,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 +2352,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 *connected) { struct sockaddr_un server_address; size_t addrlen = 0; int dirfd_workaround_for_unix_path_len = -1; @@ -2094,7 +2370,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_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 8e28db28b3e7..61dd26a277ca 100644 --- a/packages/bun-usockets/src/context.c +++ b/packages/bun-usockets/src/context.c @@ -18,6 +18,7 @@ #include "internal/internal.h" #include "libusockets.h" #include +#include #include #include #ifndef _WIN32 @@ -73,7 +74,7 @@ void us_socket_group_deinit(struct us_socket_group_t *group) { * is 1:1 owned by a Zig Listener / uWS App that holds a raw pointer to it and * closes it in finalize(), so closing+freeing it here turns that into a UAF * (the original LSAN was only the *accepted* sockets, not the listener). */ -void us_socket_group_close_all_ex(struct us_socket_group_t *group, int also_listeners) { +void us_socket_group_close_all_ex(struct us_socket_group_t *group, int also_listeners, int thread_ends) { struct us_loop_t *loop = group->loop; if (!loop) { /* A Windows named-pipe Listener never initializes its group. */ @@ -113,13 +114,19 @@ void us_socket_group_close_all_ex(struct us_socket_group_t *group, int also_list while (group->iterator) { struct us_socket_t *s = group->iterator; group->iterator = s->next; - us_socket_close(s, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, 0); + /* Its owner ended this stream, so nothing of it is held back: every wait is over, and the kernel goes on delivering what it took. */ + if (!thread_ends && s->ssl && us_internal_ssl_sealed_close_notify(s)) { + us_internal_socket_close_raw(s, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, 0); + continue; + } + /* No close_notify from a thread that ends, whose answer nothing would read, nor after a write that fell short: the owner may hold the rest. */ + us_socket_close(s, thread_ends ? LIBUS_SOCKET_CLOSE_CODE_FAST_SHUTDOWN : s->ssl && s->flags.last_write_failed ? LIBUS_SOCKET_CLOSE_CODE_CONNECTION_RESET : LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, 0); /* A TLS socket may have *deferred* that: us_internal_ssl_close with * code==0 sends close_notify and, on WANT_READ, waits for the peer's * reply. Callers (e.g. Listener.deinit) free the embedding storage * next, which would leave s->group dangling. */ if (!us_socket_is_closed(s)) { - us_internal_socket_close_raw(s, LIBUS_SOCKET_CLOSE_CODE_CONNECTION_RESET, 0); + us_internal_socket_close_raw(s, thread_ends ? LIBUS_SOCKET_CLOSE_CODE_FAST_SHUTDOWN : LIBUS_SOCKET_CLOSE_CODE_CONNECTION_RESET, 0); } } group->iterator = 0; @@ -148,7 +155,7 @@ void us_socket_group_close_all_ex(struct us_socket_group_t *group, int also_list if (!q) { break; } - us_socket_close(q, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, 0); + us_socket_close(q, thread_ends ? LIBUS_SOCKET_CLOSE_CODE_FAST_SHUTDOWN : LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, 0); } US_ASSERT(group->low_prio_count == 0); } @@ -157,11 +164,7 @@ void us_socket_group_close_all_ex(struct us_socket_group_t *group, int also_list } void us_socket_group_close_all(struct us_socket_group_t *group) { - us_socket_group_close_all_ex(group, 1); -} - -unsigned short us_socket_group_timestamp(struct us_socket_group_t *group) { - return group->timestamp; + us_socket_group_close_all_ex(group, 1, 0); } __attribute__((always_inline)) struct us_loop_t *us_socket_group_loop(struct us_socket_group_t *group) { @@ -172,10 +175,6 @@ __attribute__((always_inline)) void *us_socket_group_ext(struct us_socket_group_ return group->ext; } -struct us_socket_group_t *us_socket_group_next(struct us_socket_group_t *group) { - return group->next; -} - /* ── Link / unlink ──────────────────────────────────────────────────────── */ static inline int us_internal_group_is_empty(struct us_socket_group_t *group) { @@ -270,6 +269,16 @@ void us_internal_socket_group_unlink_connecting_socket(struct us_socket_group_t us_internal_group_maybe_unlink(group); } +/* ── Init ───────────────────────────────────────────────────────────────── */ + +void us_internal_socket_init(struct us_socket_t *s, struct us_socket_group_t *group, unsigned char kind, int allow_half_open) { + memset((char *) s + offsetof(struct us_socket_t, timeout), 0, sizeof(struct us_socket_t) - offsetof(struct us_socket_t, timeout)); + s->timeout = LIBUS_NO_TIMEOUT; + s->flags.allow_half_open = allow_half_open != 0; + s->kind = kind; + s->group = group; +} + /* ── Adopt ──────────────────────────────────────────────────────────────── */ struct us_socket_t *us_socket_adopt(struct us_socket_t *s, struct us_socket_group_t *group, @@ -317,14 +326,14 @@ struct us_socket_t *us_socket_adopt(struct us_socket_t *s, struct us_socket_grou c->connecting_head = new_s; c->group = group; c->kind = kind; + c->timeout = LIBUS_NO_TIMEOUT; us_internal_socket_group_unlink_connecting_socket(old_group, c); us_internal_socket_group_link_connecting_socket(group, c); } } new_s->group = group; new_s->kind = kind; - new_s->timeout = 255; - new_s->long_timeout = 255; + new_s->timeout = LIBUS_NO_TIMEOUT; if (new_s->flags.low_prio_state == 1) { /* update pointers in low-priority queue */ @@ -345,26 +354,8 @@ static void us_internal_init_listen_socket(struct us_listen_socket_t *ls, struct us_socket_group_t *group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, int options, int socket_ext_size) { - struct us_socket_t *s = &ls->s; - s->group = group; - s->kind = 0; /* listener itself never dispatches */ - s->ssl = NULL; - s->timeout = 255; - s->long_timeout = 255; - s->flags.low_prio_state = 0; - s->flags.is_paused = 0; - s->flags.is_ipc = 0; - s->flags.is_closed = 0; - s->flags.adopted = 0; - s->flags.allow_half_open = (options & LIBUS_SOCKET_ALLOW_HALF_OPEN); - s->unclassified_send_failures = 0; - s->read_eof = 0; - s->hangup_closes_unsent = 0; - s->send_rejected = 0; - s->next = 0; - s->prev = 0; - s->connect_state = NULL; - s->connect_next = NULL; + /* The listener itself never dispatches, so it has no kind. */ + us_internal_socket_init(&ls->s, group, 0, options & LIBUS_SOCKET_ALLOW_HALF_OPEN); ls->accept_group = group; ls->accept_kind = kind; @@ -374,6 +365,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; @@ -383,8 +375,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 +384,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; } @@ -416,11 +404,16 @@ struct us_listen_socket_t *us_socket_group_listen(struct us_socket_group_t *grou struct us_listen_socket_t *us_socket_group_listen_fd(struct us_socket_group_t *group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, LIBUS_SOCKET_DESCRIPTOR fd, int backlog, int options, int socket_ext_size, int *error) { + if (bsd_adopt_socket(fd, SOCK_STREAM)) { + *error = LIBUS_ERR; + return 0; + } /* Validate with listen(2) before touching the descriptor's flags: on failure the caller keeps * the fd (it may be its stdio), and a non-socket must come back untouched. */ if (listen(fd, backlog > 0 ? backlog : 512)) { int listen_err = LIBUS_ERR; if (!bsd_socket_listen_error_is_benign(fd)) { + bsd_disown_socket(fd); *error = listen_err; return 0; } @@ -432,6 +425,7 @@ struct us_listen_socket_t *us_socket_group_listen_fd(struct us_socket_group_t *g us_poll_init(p, fd, POLL_TYPE_SEMI_SOCKET); if (us_poll_start_rc(p, group->loop, LIBUS_SOCKET_READABLE) != 0) { int saved_errno = LIBUS_ERR; + bsd_disown_socket(fd); us_poll_free(p, group->loop); *error = saved_errno; return 0; @@ -439,6 +433,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); @@ -450,7 +446,7 @@ struct us_listen_socket_t *us_socket_group_listen_fd(struct us_socket_group_t *g struct us_listen_socket_t *us_socket_group_listen_unix(struct us_socket_group_t *group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, const char *path, size_t pathlen, int options, int socket_ext_size, int *error) { - LIBUS_SOCKET_DESCRIPTOR listen_socket_fd = bsd_create_listen_socket_unix(path, pathlen, options, error); + LIBUS_SOCKET_DESCRIPTOR listen_socket_fd = bsd_create_listen_socket_unix(path, pathlen, error); if (listen_socket_fd == LIBUS_SOCKET_ERROR) { 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; } @@ -497,10 +491,6 @@ void us_listen_socket_close(struct us_listen_socket_t *ls) { /* We cannot immediately free a listen socket as we can be inside an accept loop */ } -__attribute__((always_inline)) void *us_listen_socket_ext(struct us_listen_socket_t *ls) { - return ls + 1; -} - struct us_listen_socket_t *us_socket_group_head_listen_socket(struct us_socket_group_t *group) { return group->head_listen_sockets; } @@ -509,53 +499,20 @@ struct us_listen_socket_t *us_listen_socket_next(struct us_listen_socket_t *ls) return ls->next; } -LIBUS_SOCKET_DESCRIPTOR us_listen_socket_get_fd(struct us_listen_socket_t *ls) { - return us_poll_fd(&ls->s.p); -} - -int us_listen_socket_port(struct us_listen_socket_t *ls) { - return us_socket_local_port(&ls->s); -} - -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, - struct us_socket_group_t *group, - unsigned char kind, int options) { - s->group = group; - s->kind = kind; - s->ssl = NULL; - s->timeout = 255; - s->long_timeout = 255; - s->flags.low_prio_state = 0; - s->flags.allow_half_open = (options & LIBUS_SOCKET_ALLOW_HALF_OPEN); - s->flags.is_paused = 0; - s->flags.is_ipc = 0; - s->flags.is_closed = 0; - s->flags.adopted = 0; - s->flags.last_write_failed = 0; - s->unclassified_send_failures = 0; - s->read_eof = 0; - s->hangup_closes_unsent = 0; - s->send_rejected = 0; - s->connect_state = NULL; - s->connect_next = NULL; -} - struct us_socket_t *us_socket_group_connect_resolved_dns(struct us_socket_group_t *group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, struct sockaddr_storage *addr, struct sockaddr_storage *local_addr, int options, int socket_ext_size) { - LIBUS_SOCKET_DESCRIPTOR connect_socket_fd = bsd_create_connect_socket(addr, local_addr, options); + LIBUS_SOCKET_DESCRIPTOR connect_socket_fd = bsd_create_connect_socket(addr, local_addr); if (connect_socket_fd == LIBUS_SOCKET_ERROR) { return NULL; } - bsd_socket_nodelay(connect_socket_fd, 1); - struct us_poll_t *p = us_create_poll(group->loop, 0, sizeof(struct us_socket_t) + socket_ext_size); us_poll_init(p, connect_socket_fd, POLL_TYPE_SEMI_SOCKET); if (us_poll_start_rc(p, group->loop, LIBUS_SOCKET_WRITABLE) != 0) { @@ -567,7 +524,7 @@ struct us_socket_t *us_socket_group_connect_resolved_dns(struct us_socket_group_ } struct us_socket_t *socket = (struct us_socket_t *) p; - us_internal_init_connect_socket(socket, group, kind, options); + us_internal_socket_init(socket, group, kind, options & LIBUS_SOCKET_ALLOW_HALF_OPEN); /* Fast path has no us_connecting_socket_t to stash ssl_ctx on, so attach * the SSL state now; on_open will see s->ssl != NULL and route through the @@ -630,7 +587,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; } } @@ -643,8 +605,7 @@ void *us_socket_group_connect(struct us_socket_group_t *group, unsigned char kin c->loop = loop; c->ssl_ctx = ssl_ctx; if (ssl_ctx) us_internal_ssl_ctx_up_ref(ssl_ctx); - c->timeout = 255; - c->long_timeout = 255; + c->timeout = LIBUS_NO_TIMEOUT; c->pending_resolve_callback = 1; c->addrinfo_req = ai_req; c->port = port; @@ -664,7 +625,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, &connected); if (connect_socket_fd == LIBUS_SOCKET_ERROR) { return 0; } @@ -680,7 +642,8 @@ 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); + us_internal_socket_init(connect_socket, group, kind, options & LIBUS_SOCKET_ALLOW_HALF_OPEN); + connect_socket->connect_returned_zero = connected; if (ssl_ctx) { us_internal_ssl_attach(connect_socket, ssl_ctx, 1, NULL, NULL); @@ -698,23 +661,23 @@ int start_connections(struct us_connecting_socket_t *c, int count) { struct sockaddr_storage addr; init_addr_with_port(c->addrinfo_head, c->port, &addr); /* The deferred-DNS path does not carry a local binding. */ - LIBUS_SOCKET_DESCRIPTOR connect_socket_fd = bsd_create_connect_socket(&addr, NULL, c->options); + LIBUS_SOCKET_DESCRIPTOR connect_socket_fd = bsd_create_connect_socket(&addr, NULL); if (connect_socket_fd == LIBUS_SOCKET_ERROR) { + c->last_candidate_error = LIBUS_ERR; continue; } - bsd_socket_nodelay(connect_socket_fd, 1); struct us_socket_t *s = (struct us_socket_t *)us_create_poll(loop, 0, sizeof(struct us_socket_t) + c->socket_ext_size); 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; } ++opened; - us_internal_init_connect_socket(s, group, c->kind, c->options); + us_internal_socket_init(s, group, c->kind, c->options & LIBUS_SOCKET_ALLOW_HALF_OPEN); s->timeout = c->timeout; - s->long_timeout = c->long_timeout; us_internal_socket_group_link_socket(group, s); @@ -727,6 +690,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 : LIBUS_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 @@ -765,10 +735,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); } } @@ -790,6 +757,8 @@ void us_internal_socket_after_open(struct us_socket_t *s, int error) { int so_error = us_socket_get_error(s); if (so_error > 0) { error = so_error; + } else if (error == WSAENOTCONN) { + error = LIBUS_ECONNREFUSED; } break; } @@ -799,6 +768,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; @@ -807,16 +777,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); @@ -840,6 +804,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) { @@ -849,19 +816,3 @@ void us_internal_socket_after_open(struct us_socket_t *s, int error) { } } } - -/* ── Misc ───────────────────────────────────────────────────────────────── */ - -struct us_bun_verify_error_t us_socket_verify_error(struct us_socket_t *s) { - if (s->ssl) { - return us_internal_ssl_verify_error(s); - } - return (struct us_bun_verify_error_t) { .error = 0, .code = NULL, .reason = NULL }; -} - -const char *us_socket_sni_servername(struct us_socket_t *s) { - if (s->ssl) { - return us_internal_ssl_sni_servername(s); - } - return NULL; -} diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 416eec057b29..57b6af3242f6 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 @@ -106,12 +107,6 @@ struct loop_ssl_data { * handshake flight is held whatever this says, with the first record written behind it, and * counts here if the kernel refuses it. */ unsigned int ssl_batch_spills; - - /* Why ssl_raw_write ended a write side inside the running us_internal_ssl_writev or spill drain. */ - int ssl_send_error; - - /* us_socket_sni_resolve's answer; us_select_cert_cb takes it in the same re-drive. */ - SSL_CTX *ssl_sni_resolved_ctx; }; enum { @@ -127,6 +122,8 @@ enum { /* SNI tree leaf — stored as the void* user in sni_tree.cpp. */ struct sni_node_t { SSL_CTX *ctx; + /* See us_listen_socket_add_server_name. */ + int own_client_auth; }; static _Atomic long ssl_ctx_live = 0; @@ -140,7 +137,6 @@ long us_ssl_ctx_live_count(void) { * free_func also decrements ssl_ctx_live so the counter tracks ACTUAL * destruction (refcount→0), not every SSL_CTX_free. * - us_ssl_rare_ex_idx (SSL): us_ssl_rare_t. - * - us_ssl_wrapper_ex_idx (SSL): the owner of an SSL that no us_socket_t drives. * * SSL_CTX creation runs from both the JS thread (SecureContext, Bun.connect/ * listen) and the HTTP-client thread (HTTPContext.initWithOpts). A racy `<0` @@ -155,14 +151,8 @@ static int us_ctx_cache_ex_idx = -1; * not replace such a store with the process-shared default roots. */ static int us_ctx_user_ca_ex_idx = -1; static int us_ssl_rare_ex_idx = -1; -/* (SSL) The SSLWrapper (src/uws/lib.rs) that owns this SSL. Not set on the SSL of a us_socket_t. */ -static int us_ssl_wrapper_ex_idx = -1; /* BIO type of the loop's shared BIO: tells a us_socket_t's SSL from an SSLWrapper's. */ static int us_ssl_bio_type = 0; -/* (SSL_CTX) packed client-certificate policy of a Bun.serve per-serverName - * entry — see us_ssl_ctx_set_sni_policy. Absent on node:tls SecureContexts, - * whose policy is server-level. */ -static int us_ctx_sni_policy_ex_idx = -1; /* Defined in Rust (src/uws_sys/SocketKind.rs) so the ordinal tracks the enum. */ extern const unsigned char BUN_SOCKET_KIND_BUN_SOCKET_TLS; extern const unsigned char BUN_SOCKET_KIND_UWS_HTTP_TLS; @@ -177,12 +167,14 @@ static pthread_once_t us_ex_idx_once = PTHREAD_ONCE_INIT; #define US_RENEG_LIMIT(p) ((uint32_t)((uint64_t)(uintptr_t)(p) >> 32)) #define US_RENEG_WINDOW(p) ((uint32_t)((uint64_t)(uintptr_t)(p))) -/* us_socket_t::ssl_sni_pending: an async SNICallback has the handshake suspended. */ +/* us_ssl_rare_t::sni_pending: where the answer of an SNICallback stands. */ enum { US_SNI_NONE = 0, + /* An asynchronous SNICallback has the handshake suspended. */ US_SNI_WAITING = 1, US_SNI_RESOLVED = 2, - US_SNI_ERROR = 3, + /* For good: the connection is dropped. */ + US_SNI_REFUSED = 3, }; struct us_ssl_pending_event_t { @@ -201,18 +193,57 @@ struct us_ssl_spill_t { char data[]; }; -/* Allocated for an accepted socket, a node:tls socket's events, a client's first renegotiation, fetch's session sink, and a first spill. */ +/* Ciphertext a client sent after 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) + +/* What BoringSSL's callbacks read and write, for an SSL of either engine (a us_socket_t here, an SSLWrapper in src/uws/lib.rs). + * Allocated for an accepted socket, a Bun socket, a verify step, a client's first renegotiation, fetch's session sink, and a first spill. */ struct us_ssl_rare_t { + /* us_ssl_set_bun_socket: the Bun.connect / Bun.listen socket this SSL belongs to. NULL for every other owner. */ + void *bun_socket; + /* us_ssl_set_identity_check: the name check of an SSLWrapper's owner. */ + int (*identity_check)(void *owner, SSL *ssl); + void *identity_owner; /* 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; + /* us_ssl_sni_resolve's answer, one reference. us_select_cert_sni takes it when the handshake is driven again. */ + SSL_CTX *sni_resolved_ctx; /* Sessions and keylog lines parked inside SSL_read/SSL_do_handshake, in arrival order. */ struct us_ssl_pending_event_t *pending_head, *pending_tail; + /* One reference to the session the new-session callback delivered last: TLS 1.3 never stores it on the SSL. */ + SSL_SESSION *latest_session; void *session_sink; + void (*session_sink_on_session)(void *sink, SSL_SESSION *session); 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; + /* us_ssl_set_inline_reject: a client that refuses a bad chain or a wrong name inside the handshake. */ + unsigned char inline_reject : 1; + /* The verify step found an error in the peer's chain, or the owner refused the name on it. */ + unsigned char verify_failed : 1; + /* The owner gave a verdict on the server's name in the verify step. */ + unsigned char identity_checked : 1; + /* The verify step ran, so SSL_get_verify_result is about a chain the peer sent. */ + unsigned char peer_chain_checked : 1; + /* US_SNI_* */ + unsigned char sni_pending : 2; + /* us_ssl_on_server_name: `bun_socket` has a `serverName` handler. */ + unsigned char sni_resolver : 1; }; static void us_ctx_ex_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, @@ -222,18 +253,32 @@ static void us_ctx_ex_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, } static struct us_server_names_t *us_server_names_ref(struct us_listen_socket_t *ls); static void us_server_names_unref(struct us_server_names_t *names); +static int us_alpn_select_cb(SSL *ssl, const unsigned char **out, unsigned char *outlen, + const unsigned char *in, unsigned int inlen, void *arg); + +/* Events parked by this thread's SSLs and not freed yet. Nearly always 0, and then a read event doesn't walk from its SSL to its us_ssl_rare_t. */ +static _Thread_local unsigned int ssl_parked_events; + +static void ssl_free_events(struct us_ssl_pending_event_t *events) { + while (events) { + struct us_ssl_pending_event_t *next = events->next; + us_free(events); + ssl_parked_events--; + events = next; + } +} + static void us_ssl_rare_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, int index, long argl, void *argp) { (void)parent; (void)ad; (void)index; (void)argl; (void)argp; struct us_ssl_rare_t *rare = ptr; if (!rare) return; - while (rare->pending_head) { - struct us_ssl_pending_event_t *next = rare->pending_head->next; - us_free(rare->pending_head); - rare->pending_head = next; - } + ssl_free_events(rare->pending_head); + SSL_SESSION_free(rare->latest_session); 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); + SSL_CTX_free(rare->sni_resolved_ctx); us_free(rare); } @@ -242,16 +287,6 @@ static void us_ssl_rare_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, #define US_SSL_PENDING_SESSION_MAX 65536 #define US_SSL_PENDING_KEYLOG_LINE_MAX 4096 -/* 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_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); - -static inline void *us_ssl_wrapper(const SSL *ssl) { - return us_ssl_wrapper_ex_idx >= 0 ? SSL_get_ex_data(ssl, us_ssl_wrapper_ex_idx) : NULL; -} - static int us_ssl_is_socket(const SSL *ssl) { BIO *bio = SSL_get_wbio(ssl); return bio && BIO_method_type(bio) == us_ssl_bio_type; @@ -267,21 +302,20 @@ static struct us_socket_t *us_ssl_socket(const SSL *ssl) { static inline struct us_ssl_rare_t *us_ssl_rare(const SSL *ssl); static struct us_ssl_rare_t *us_ssl_rare_ensure(SSL *ssl); -static struct us_ssl_pending_event_t *ssl_park_event(struct us_socket_t *s, int is_keylog, +static struct us_ssl_pending_event_t *ssl_park_event(struct us_ssl_rare_t *rare, int is_keylog, size_t length) { struct us_ssl_pending_event_t *event = us_malloc(sizeof(*event) + length); if (!event) return NULL; event->next = NULL; event->length = (uint32_t)length; event->is_keylog = is_keylog ? 1 : 0; - struct us_ssl_rare_t *rare = us_ssl_rare_ensure(s_ssl(s)); if (rare->pending_tail) { rare->pending_tail->next = event; } else { rare->pending_head = event; } rare->pending_tail = event; - s->ssl_has_pending_events = 1; + ssl_parked_events++; return event; } @@ -290,16 +324,11 @@ static void us_ssl_keylog_cb(const SSL *ssl, const char *line) { if (line_len == 0 || line_len > US_SSL_PENDING_KEYLOG_LINE_MAX) { return; } - void *wrapper = us_ssl_wrapper(ssl); - if (wrapper) { - us_ssl_wrapper_keylog(wrapper, line, line_len); - return; - } - struct us_socket_t *s = us_ssl_socket(ssl); - if (!s || us_socket_kind(s) != BUN_SOCKET_KIND_BUN_SOCKET_TLS) { + struct us_ssl_rare_t *rare = us_ssl_rare(ssl); + if (!rare || !rare->bun_socket) { return; } - struct us_ssl_pending_event_t *event = ssl_park_event(s, 1, line_len + 1); + struct us_ssl_pending_event_t *event = ssl_park_event(rare, 1, line_len + 1); if (!event) { return; } @@ -309,21 +338,23 @@ static void us_ssl_keylog_cb(const SSL *ssl, const char *line) { } static int us_ssl_new_session_cb(SSL *ssl, SSL_SESSION *session) { - void *wrapper = us_ssl_wrapper(ssl); - if (wrapper) { - us_ssl_wrapper_new_session(wrapper, session); + struct us_ssl_rare_t *rare = us_ssl_rare(ssl); + if (!rare) { return 0; } - struct us_socket_t *s = us_ssl_socket(ssl); - /* The owner takes its own reference if it keeps the session; 1 asks for a 'session' event. */ - if (!s || !us_dispatch_new_session(s, session)) { + /* The sink takes its own reference if it keeps the session. */ + if (rare->session_sink) rare->session_sink_on_session(rare->session_sink, session); + if (!rare->bun_socket) { return 0; } + SSL_SESSION_up_ref(session); + SSL_SESSION_free(rare->latest_session); + rare->latest_session = session; int length = i2d_SSL_SESSION(session, NULL); if (length <= 0 || length > US_SSL_PENDING_SESSION_MAX) { return 0; } - struct us_ssl_pending_event_t *event = ssl_park_event(s, 0, (size_t)length); + struct us_ssl_pending_event_t *event = ssl_park_event(rare, 0, (size_t)length); if (!event) { return 0; } @@ -332,34 +363,32 @@ static int us_ssl_new_session_cb(SSL *ssl, SSL_SESSION *session) { return 0; } -/* Runs JS, which may close the socket: call after the SSL_* call returned, then check ssl_gone(s). */ -static void ssl_flush_pending_events(struct us_socket_t *s) { - if (!s->ssl_has_pending_events) { - return; - } - s->ssl_has_pending_events = 0; - struct us_ssl_rare_t *rare = s->ssl ? us_ssl_rare(s_ssl(s)) : NULL; - if (!rare) { +static void ssl_hand_over_events(SSL *ssl) { + struct us_ssl_rare_t *rare = us_ssl_rare(ssl); + if (!rare || !rare->pending_head) { return; } + void *bun_socket = rare->bun_socket; struct us_ssl_pending_event_t *events = rare->pending_head; rare->pending_head = rare->pending_tail = NULL; + int is_open = bun_socket != NULL; /* Sessions first, then keylog lines. */ for (int is_keylog = 0; is_keylog <= 1; is_keylog++) { - for (struct us_ssl_pending_event_t *event = events; event; event = event->next) { - if (event->is_keylog != is_keylog || us_socket_is_closed(s) || !s->ssl) continue; - if (is_keylog) { - us_dispatch_keylog(s, event->data, (int)event->length); - } else { - us_dispatch_session(s, event->data, (int)event->length); - } + for (struct us_ssl_pending_event_t *event = events; event && is_open; event = event->next) { + if (event->is_keylog != is_keylog) continue; + is_open = us_dispatch_tls_event(bun_socket, is_keylog, event->data, event->length); } } - while (events) { - struct us_ssl_pending_event_t *next = events->next; - us_free(events); - events = next; - } + ssl_free_events(events); +} + +/* Runs JS, which may close the connection and free `ssl`: call it after the SSL_* call returned, then check that the SSL is still there. */ +void us_ssl_flush_pending_events(SSL *ssl) { + if (ssl_parked_events) ssl_hand_over_events(ssl); +} + +static void ssl_flush_pending_events(struct us_socket_t *s) { + if (ssl_parked_events && s->ssl) ssl_hand_over_events(s_ssl(s)); } /* Defined in `src/runtime/api/bun/SSLContextCache.rs`: tombstones the cache entry on @@ -372,9 +401,7 @@ static void us_ex_idx_init(void) { us_ctx_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, us_ctx_ex_free); us_ctx_cache_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, bun_ssl_ctx_cache_on_free); us_ctx_user_ca_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); - us_ctx_sni_policy_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ssl_rare_ex_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_rare_free); - us_ssl_wrapper_ex_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ssl_bio_type = BIO_get_new_index() | BIO_TYPE_SOURCE_SINK; } @@ -424,18 +451,28 @@ static struct us_ssl_rare_t *us_ssl_rare_ensure(SSL *ssl) { return rare; } -void us_ssl_set_wrapper(SSL *ssl, void *wrapper) { - us_ex_idx_ensure(); - if (!SSL_set_ex_data(ssl, us_ssl_wrapper_ex_idx, wrapper)) Bun__outOfMemory(); +void us_ssl_set_identity_check(SSL *ssl, int (*check)(void *owner, SSL *ssl), void *owner) { + struct us_ssl_rare_t *rare = us_ssl_rare_ensure(ssl); + rare->identity_check = check; + rare->identity_owner = owner; } -void *us_ssl_wrapper_from_verify(void *ctx) { - SSL *ssl = X509_STORE_CTX_get_ex_data(ctx, SSL_get_ex_data_X509_STORE_CTX_idx()); - return ssl ? us_ssl_wrapper(ssl) : NULL; +void us_ssl_set_bun_socket(SSL *ssl, void *bun_socket) { + us_ssl_rare_ensure(ssl)->bun_socket = bun_socket; +} + +void *us_ssl_bun_socket(const SSL *ssl) { + struct us_ssl_rare_t *rare = us_ssl_rare(ssl); + return rare ? rare->bun_socket : NULL; +} + +SSL_SESSION *us_ssl_latest_session(const SSL *ssl) { + struct us_ssl_rare_t *rare = us_ssl_rare(ssl); + return rare ? rare->latest_session : NULL; } /* `sink` lives until SSL_free, then `on_free(sink)` runs. Replaces the sink set before. */ -void us_socket_set_session_sink(struct us_socket_t *s, void *sink, void (*on_free)(void *)) { +void us_socket_set_session_sink(struct us_socket_t *s, void *sink, void (*on_session)(void *, SSL_SESSION *), void (*on_free)(void *)) { if (!s->ssl) { if (on_free) on_free(sink); return; @@ -443,6 +480,7 @@ void us_socket_set_session_sink(struct us_socket_t *s, void *sink, void (*on_fre struct us_ssl_rare_t *rare = us_ssl_rare_ensure(s_ssl(s)); if (rare->session_sink_free) rare->session_sink_free(rare->session_sink); rare->session_sink = sink; + rare->session_sink_on_session = on_session; rare->session_sink_free = on_free; } @@ -457,7 +495,7 @@ extern void us_internal_socket_raw_shutdown(struct us_socket_t *s); static void ssl_update_handshake(struct us_socket_t *s, int fin_ends_handshake); static int ssl_flush_write_batch(struct loop_ssl_data *loop_ssl_data, struct us_socket_t *s); static void ssl_emit(struct loop_ssl_data *loop_ssl_data, struct us_socket_t *s, const char *data, unsigned int length, int is_batch); -static int us_ssl_inline_reject_tripped(struct us_socket_t *s); +static int ssl_inline_reject_tripped(struct us_socket_t *s); static inline int ssl_gone(struct us_socket_t *s); /* ── BIO plumbing ───────────────────────────────────────────────────────── @@ -503,7 +541,7 @@ int us_internal_ssl_loop_state_slots(void) { return US_SSL_LOOP_STATE_SLOTS; } void us_internal_ssl_loop_state_save(void *ssl_ptr, void **out) { SSL *ssl = (SSL *)ssl_ptr; - struct loop_ssl_data *d = (struct loop_ssl_data *)BIO_get_data(SSL_get_wbio(ssl)); + struct loop_ssl_data *d = us_ssl_is_socket(ssl) ? (struct loop_ssl_data *)BIO_get_data(SSL_get_wbio(ssl)) : NULL; out[0] = d; out[1] = d ? (void *)d->ssl_socket : NULL; out[2] = d ? (void *)d->ssl_read_input : NULL; @@ -514,6 +552,8 @@ void us_internal_ssl_loop_state_save(void *ssl_ptr, void **out) { } void us_internal_ssl_loop_state_restore(void **saved) { + /* SSL_get_error reads the thread's queue first, and whatever the JS queued isn't about the SSL_* call it ran inside. */ + ERR_clear_error(); struct loop_ssl_data *d = (struct loop_ssl_data *)saved[0]; if (!d) return; d->ssl_socket = (struct us_socket_t *)saved[1]; @@ -524,16 +564,10 @@ void us_internal_ssl_loop_state_restore(void **saved) { } /* A send() that no retry can save loses sealed records: nothing may follow them, but what the peer sent is still read. */ -static int ssl_raw_write(struct loop_ssl_data *loop_ssl_data, struct us_socket_t *s, const char *data, int length) { +static int ssl_raw_write(struct us_socket_t *s, const char *data, int length) { int send_error = 0; int written = us_internal_socket_raw_write(s, data, length, &send_error); - if (send_error) { - loop_ssl_data->ssl_send_error = send_error; - s->ssl_send_rejected = 1; - us_internal_socket_raw_shutdown(s); - /* No write may close under its caller: us_internal_ssl_on_writable does. */ - us_internal_rearm_writable(s); - } + if (send_error) us_internal_socket_reject_send(s, send_error); return written; } @@ -555,7 +589,7 @@ static int BIO_s_custom_write(BIO *bio, const char *data, int length) { * post-verify flight so the peer never sees the handshake complete * (node's post-verify destroy cancels its queued Finished the same way). */ if (loop_ssl_data->ssl_socket && - us_ssl_inline_reject_tripped(loop_ssl_data->ssl_socket)) { + ssl_inline_reject_tripped(loop_ssl_data->ssl_socket)) { BIO_clear_retry_flags(bio); return length; } @@ -612,7 +646,7 @@ static void ssl_discard_spill(struct loop_ssl_data *loop_ssl_data, struct us_soc static void ssl_emit(struct loop_ssl_data *loop_ssl_data, struct us_socket_t *s, const char *data, unsigned int length, int is_batch) { if (!s->ssl_spilled) { - unsigned int written = (unsigned int)ssl_raw_write(loop_ssl_data, s, data, (int)length); + unsigned int written = (unsigned int)ssl_raw_write(s, data, (int)length); /* Sealed after our FIN, it can never leave. */ if (written == length || !us_internal_socket_can_raw_write(s)) return; data += written; @@ -675,8 +709,7 @@ static void ssl_release_batch(struct us_loop_t *loop, struct us_socket_t *s) { static int ssl_drain_spill(struct loop_ssl_data *loop_ssl_data, struct us_socket_t *s) { if (!s->ssl_spilled) return 1; struct us_ssl_spill_t *spill = us_ssl_rare(s_ssl(s))->spill; - spill->offset += (unsigned int)ssl_raw_write(loop_ssl_data, s, spill->data + spill->offset, - (int)(spill->length - spill->offset)); + spill->offset += (unsigned int)ssl_raw_write(s, spill->data + spill->offset, (int)(spill->length - spill->offset)); if (spill->offset != spill->length && us_internal_socket_can_raw_write(s)) return 0; ssl_discard_spill(loop_ssl_data, s); return 1; @@ -911,8 +944,8 @@ static int us_crypto_buffer_cmp(const CRYPTO_BUFFER *const *a, const CRYPTO_BUFF /* Trusts the DER certificates in `certs` (duplicates dropped) through one lazily-parsed set on `store`, and makes their * subjects the context's client-CA list, as X509_STORE_add_cert + SSL_CTX_add_client_CA per parsed certificate did. - * Consumes `certs`. */ -static int us_install_ca_buffers(SSL_CTX *ctx, X509_STORE *store, STACK_OF(CRYPTO_BUFFER) *certs) { + * Consumes `certs`. Without `announce` it leaves the client-CA list alone. */ +static int us_install_ca_buffers(SSL_CTX *ctx, X509_STORE *store, STACK_OF(CRYPTO_BUFFER) *certs, int announce) { int ok = 1; size_t n = sk_CRYPTO_BUFFER_num(certs); if (n > 1) { @@ -936,9 +969,9 @@ static int us_install_ca_buffers(SSL_CTX *ctx, X509_STORE *store, STACK_OF(CRYPT for (size_t i = 0; bufs != NULL && i < n; i++) bufs[i] = sk_CRYPTO_BUFFER_value(certs, i); X509_LAZY_CERT_SET *set = bufs != NULL ? X509_LAZY_CERT_SET_new(bufs, n) : NULL; free(bufs); - STACK_OF(CRYPTO_BUFFER) *names = sk_CRYPTO_BUFFER_new_null(); - ok = set != NULL && names != NULL && X509_STORE_add_lazy_cert_set(store, set); - for (size_t i = 0; ok && i < n; i++) { + STACK_OF(CRYPTO_BUFFER) *names = announce ? sk_CRYPTO_BUFFER_new_null() : NULL; + ok = set != NULL && (names != NULL || !announce) && X509_STORE_add_lazy_cert_set(store, set); + for (size_t i = 0; ok && announce && i < n; i++) { const uint8_t *subject; size_t subject_len; CRYPTO_BUFFER *name = NULL; @@ -946,7 +979,7 @@ static int us_install_ca_buffers(SSL_CTX *ctx, X509_STORE *store, STACK_OF(CRYPT (name = CRYPTO_BUFFER_new(subject, subject_len, NULL)) != NULL && sk_CRYPTO_BUFFER_push(names, name); if (!ok) CRYPTO_BUFFER_free(name); } - if (ok) { + if (ok && announce) { SSL_CTX_set0_client_CAs(ctx, names); } else { sk_CRYPTO_BUFFER_pop_free(names, CRYPTO_BUFFER_free); @@ -1133,8 +1166,7 @@ static int us_ssl_ctx_use_identities(SSL_CTX *ctx, const char *const *cert, unsi } static int us_verify_callback(int preverify_ok, X509_STORE_CTX *ctx) { - /* Always continue; the user inspects via us_socket_verify_error after - * on_handshake. Returning 1 defers the decision to JS without aborting + /* Always continue; on_handshake hands the owner the verdict. Returning 1 defers the decision to JS without aborting * mid-handshake - the same model as Node (crypto_tls.cc VerifyCallback). */ return 1; } @@ -1145,20 +1177,21 @@ static int us_verify_callback(int preverify_ok, X509_STORE_CTX *ctx) { static int us_inline_reject_verify_callback(int preverify_ok, X509_STORE_CTX *ctx) { if (!preverify_ok) { SSL *ssl = X509_STORE_CTX_get_ex_data(ctx, SSL_get_ex_data_X509_STORE_CTX_idx()); - struct us_socket_t *s = ssl ? us_ssl_socket(ssl) : NULL; - if (s) s->ssl_verify_failed = 1; + if (ssl) us_ssl_rare_ensure(ssl)->verify_failed = 1; } return 1; } -/* Whether this socket is a rejecting client whose chain verification failed: - * from that point on its handshake output is suppressed and the handshake is - * reported as failed. */ -static int us_ssl_inline_reject_tripped(struct us_socket_t *s) { - if (!s->ssl || s->ssl_handshake_state == HANDSHAKE_COMPLETED) return 0; - if (!s->ssl_inline_reject || !s->ssl_verify_failed) return 0; +/* Returns 1 for a rejecting client whose verify step failed: its handshake output is dropped and the handshake is reported as failed. */ +int us_ssl_inline_reject_tripped(SSL *ssl) { + struct us_ssl_rare_t *rare = us_ssl_rare(ssl); + if (!rare || !rare->inline_reject || !rare->verify_failed) return 0; /* Only the final verdict rejects: an alternate chain can recover a per-depth failure. */ - return SSL_get_verify_result(s_ssl(s)) != X509_V_OK; + return SSL_get_verify_result(ssl) != X509_V_OK; +} + +static int ssl_inline_reject_tripped(struct us_socket_t *s) { + return s->ssl && s->ssl_handshake_state != HANDSHAKE_COMPLETED && us_ssl_inline_reject_tripped(s_ssl(s)); } /* What the owner of a client says about the name on the server's certificate. */ @@ -1271,16 +1304,6 @@ static int us_x509_verify_chain(X509_STORE_CTX *ctx) { return verify_callback ? verify_callback(0, ctx) : 0; } -static int us_x509_verify_chain_cb(X509_STORE_CTX *ctx, void *arg) { - (void)arg; - return us_x509_verify_chain(ctx); -} - -/* For node:quic, which builds its own SSL_CTX. */ -void us_ssl_ctx_reject_blocked_keys(SSL_CTX *ctx) { - SSL_CTX_set_cert_verify_callback(ctx, us_x509_verify_chain_cb, NULL); -} - /* A self-issued leaf that may not sign certificates is its own anchor when the store holds that very certificate (#44365). */ int us_x509_verify_cert(X509_STORE_CTX *ctx, int purpose) { X509 *leaf = X509_STORE_CTX_get0_cert(ctx); @@ -1304,34 +1327,41 @@ static int us_cert_verify_cb(X509_STORE_CTX *ctx, void *arg) { SSL *ssl = X509_STORE_CTX_get_ex_data(ctx, SSL_get_ex_data_X509_STORE_CTX_idx()); int ok = us_x509_verify_cert(ctx, ssl && SSL_is_server(ssl) ? X509_PURPOSE_SSL_CLIENT : X509_PURPOSE_SSL_SERVER); if (!ssl) return ok; - void *wrapper = us_ssl_wrapper(ssl); - struct us_socket_t *s = wrapper ? NULL : us_ssl_socket(ssl); - if (s) s->ssl_peer_chain_checked = 1; + struct us_ssl_rare_t *rare = us_ssl_rare_ensure(ssl); + rare->peer_chain_checked = 1; if (SSL_is_server(ssl) || X509_STORE_CTX_get_error(ctx) != X509_V_OK) return ok; - int verdict = wrapper ? us_ssl_wrapper_server_identity(wrapper, ssl) - : s ? us_dispatch_server_identity(s, ssl) - : US_IDENTITY_UNCHECKED; + struct us_socket_t *s; + int verdict = rare->bun_socket ? us_dispatch_bun_socket_identity(rare->bun_socket, ssl) + : rare->identity_check ? rare->identity_check(rare->identity_owner, ssl) + : (s = us_ssl_socket(ssl)) ? us_dispatch_server_identity(s, ssl) + : US_IDENTITY_UNCHECKED; if (verdict == US_IDENTITY_UNCHECKED) return ok; - if (s) s->ssl_identity_checked = 1; + rare->identity_checked = 1; if (verdict == US_IDENTITY_REJECTED) { - if (s) s->ssl_verify_failed = 1; + rare->verify_failed = 1; X509_STORE_CTX_set_error(ctx, X509_V_ERR_HOSTNAME_MISMATCH); } return ok; } int us_ssl_identity_checked(SSL *ssl) { - void *wrapper = us_ssl_wrapper(ssl); - if (wrapper) return us_ssl_wrapper_identity_checked(wrapper); - struct us_socket_t *s = us_ssl_socket(ssl); - return s && s->ssl_identity_checked; + struct us_ssl_rare_t *rare = us_ssl_rare(ssl); + return rare && rare->identity_checked; } /* A rejecting client refuses a bad chain inside the handshake, before its Certificate flight. */ -void us_socket_set_inline_reject(struct us_socket_t *s) { - if (!s->ssl || s->ssl_is_server || s->ssl_handshake_state == HANDSHAKE_COMPLETED) return; - s->ssl_inline_reject = 1; - SSL_set_verify(s_ssl(s), SSL_VERIFY_PEER, us_inline_reject_verify_callback); +void us_ssl_set_inline_reject(SSL *ssl) { + if (SSL_is_server(ssl) || SSL_is_init_finished(ssl)) return; + us_ssl_rare_ensure(ssl)->inline_reject = 1; + SSL_set_verify(ssl, SSL_VERIFY_PEER, us_inline_reject_verify_callback); +} + +/* As in Node's TLSWrap::SetVerifyMode, the policy is the socket's, not the one its shared SSL_CTX was built with. */ +void us_ssl_set_verify_mode(SSL *ssl, int request_cert, int reject_unauthorized) { + int mode = !request_cert ? SSL_VERIFY_NONE + : reject_unauthorized ? SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT + : SSL_VERIFY_PEER; + SSL_set_verify(ssl, mode, us_verify_callback); } /* node drops its pending output in destroy(): https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1310-L1331 */ @@ -1394,6 +1424,8 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, * build_fail) balances. The packed reneg policy reuses the same slot. */ SSL_CTX_set_ex_data(ssl_context, us_ssl_ctx_ex_idx(), NULL); + SSL_CTX_set_alpn_select_cb(ssl_context, us_alpn_select_cb, NULL); + /* Default options we rely on — changing these breaks the BIO logic. */ SSL_CTX_set_read_ahead(ssl_context, 1); SSL_CTX_set_mode(ssl_context, SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); @@ -1470,7 +1502,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 — @@ -1484,7 +1516,7 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, ca_certs = NULL; } } - if (ca_certs == NULL || !us_install_ca_buffers(ssl_context, cert_store, ca_certs)) { + if (ca_certs == NULL || !us_install_ca_buffers(ssl_context, cert_store, ca_certs, !options.ca_is_default)) { *err = CREATE_BUN_SOCKET_ERROR_INVALID_CA; ssl_ctx_build_fail(ssl_context); return NULL; @@ -1804,41 +1836,6 @@ void us_internal_ssl_ctx_unref(SSL_CTX *p) { if (p) SSL_CTX_free(p); } -/* ── HTTP/2 ALPN ─────────────────────────────────────────────────────────── */ - -static int us_alpn_select_h2(SSL *ssl, const unsigned char **out, unsigned char *outlen, - const unsigned char *in, unsigned int inlen, void *arg) { - (void) ssl; - int allow_http1 = arg != NULL; - const unsigned char *http1 = NULL; - for (unsigned int i = 0; i + 1 <= inlen;) { - unsigned int len = in[i]; - if (i + 1 + len > inlen) break; - const unsigned char *proto = &in[i + 1]; - if (len == 2 && proto[0] == 'h' && proto[1] == '2') { - *out = proto; - *outlen = 2; - return SSL_TLSEXT_ERR_OK; - } - if (len == 8 && memcmp(proto, "http/1.1", 8) == 0) http1 = proto; - i += 1 + len; - } - if (http1 && allow_http1) { - *out = http1; - *outlen = 8; - return SSL_TLSEXT_ERR_OK; - } - /* The client offered ALPN but not h2 (this callback doesn't run at all for - * a client that sends no ALPN extension; that one completes the handshake - * and is answered at the HTTP layer). Proceed without ALPN when HTTP/1 is - * allowed; otherwise refuse the handshake with no_application_protocol. */ - return allow_http1 ? SSL_TLSEXT_ERR_NOACK : SSL_TLSEXT_ERR_ALERT_FATAL; -} - -void us_ssl_ctx_enable_http2_alpn(SSL_CTX *ctx, int allow_http1) { - SSL_CTX_set_alpn_select_cb(ctx, us_alpn_select_h2, allow_http1 ? (void *) 1 : NULL); -} - int us_socket_alpn_is_h2(struct us_socket_t *s) { if (!s->ssl) return 0; const unsigned char *proto = NULL; @@ -1850,7 +1847,7 @@ int us_socket_alpn_is_h2(struct us_socket_t *s) { /* ── Per-socket SSL attach/detach ────────────────────────────────────────── */ /* A client verifies per SSL: raising the mode of the CTX would make a server that shares it send CertificateRequest. */ -void us_internal_ssl_client_verify_defaults(SSL *ssl, SSL_CTX *ctx) { +static void ssl_client_verify_defaults(SSL *ssl, SSL_CTX *ctx) { if (SSL_CTX_get_verify_mode(ctx) != SSL_VERIFY_NONE) return; SSL_set_verify(ssl, SSL_VERIFY_PEER, us_verify_callback); /* A store that holds user CAs (ca / caFile / addCACert) stays in place. */ @@ -1859,35 +1856,42 @@ void us_internal_ssl_client_verify_defaults(SSL *ssl, SSL_CTX *ctx) { if (roots) SSL_set0_verify_cert_store(ssl, roots); } -void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, - int is_client, const char *sni, - struct us_listen_socket_t *listener) { - us_internal_init_loop_ssl_data(s->group->loop); - struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)s->group->loop->data.ssl_data; - +/* An SSL in its role, for either engine. The caller gives it a BIO. */ +SSL *us_ssl_new(SSL_CTX *ctx, int is_client) { SSL *ssl = SSL_new(ctx); - SSL_set_bio(ssl, loop_ssl_data->shared_rbio, loop_ssl_data->shared_wbio); - BIO_up_ref(loop_ssl_data->shared_rbio); - BIO_up_ref(loop_ssl_data->shared_wbio); + if (!ssl) Bun__outOfMemory(); /* renegotiation: ssl_renegotiate_explicit lets us bound it on the client * (issues #6197/#5363); never on the server (DoS vector). */ if (is_client) { SSL_set_renegotiate_mode(ssl, ssl_renegotiate_explicit); SSL_set_connect_state(ssl); - if (sni) SSL_set_tlsext_host_name(ssl, sni); /* The CTX's session id context partitions a server's sessions by context * configuration (create_ssl_context_with_digest). A client fails its * handshake when a resumed session's id differs from its own, so clients * keep none: a `session` stays usable under any client options. */ SSL_set_session_id_context(ssl, NULL, 0); - us_internal_ssl_client_verify_defaults(ssl, ctx); + ssl_client_verify_defaults(ssl, ctx); } else { SSL_set_accept_state(ssl); SSL_set_renegotiate_mode(ssl, ssl_renegotiate_never); - /* sni_cb recovers the names per-SSL — never via the shared SSL_CTX. */ - if (listener) us_ssl_rare_ensure(ssl)->server_names = us_server_names_ref(listener); } + return ssl; +} + +void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, + int is_client, const char *sni, + struct us_listen_socket_t *listener) { + us_internal_init_loop_ssl_data(s->group->loop); + struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)s->group->loop->data.ssl_data; + + SSL *ssl = us_ssl_new(ctx, is_client); + SSL_set_bio(ssl, loop_ssl_data->shared_rbio, loop_ssl_data->shared_wbio); + BIO_up_ref(loop_ssl_data->shared_rbio); + BIO_up_ref(loop_ssl_data->shared_wbio); + if (is_client && sni) SSL_set_tlsext_host_name(ssl, sni); + /* sni_cb recovers the names per-SSL — never via the shared SSL_CTX. */ + if (!is_client && listener) us_ssl_rare_ensure(ssl)->server_names = us_server_names_ref(listener); s->ssl = ssl; s->ssl_handshake_state = HANDSHAKE_PENDING; @@ -1905,15 +1909,6 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, s->ssl_pending_close_code = 0; s->ssl_first_flight_before_fin = 0; s->ssl_shutdown_after_first_flight = 0; - s->ssl_send_rejected = 0; - s->ssl_is_server = is_client ? 0 : 1; - s->ssl_inline_reject = 0; - s->ssl_verify_failed = 0; - s->ssl_identity_checked = 0; - s->ssl_peer_chain_checked = 0; - s->ssl_sni_pending = US_SNI_NONE; - s->ssl_sni_resolver = 0; - s->ssl_has_pending_events = 0; } void us_internal_ssl_detach(struct us_socket_t *s) { @@ -2018,7 +2013,7 @@ struct us_bun_verify_error_t us_ssl_socket_verify_error_from_ssl(SSL *ssl) { * peer's certificate: on an open socket the SSL alone answers, like node's * TLSWrap::VerifyError * (https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1840-L1853). */ -struct us_bun_verify_error_t us_internal_ssl_verify_error(struct us_socket_t *s) { +static struct us_bun_verify_error_t us_internal_ssl_verify_error(struct us_socket_t *s) { if (!s->ssl || !s_ssl(s) || us_socket_is_closed(s)) { return (struct us_bun_verify_error_t){.error = 0, .code = NULL, .reason = NULL}; } @@ -2029,7 +2024,8 @@ struct us_bun_verify_error_t us_internal_ssl_verify_error(struct us_socket_t *s) * chain that it checked: node:tls reads a failure with an X509 code as an * established session. */ static struct us_bun_verify_error_t ssl_failed_handshake_verify_error(struct us_socket_t *s) { - if (us_internal_ssl_is_shut_down(s) && !s->ssl_peer_chain_checked) { + struct us_ssl_rare_t *rare = s->ssl ? us_ssl_rare(s_ssl(s)) : NULL; + if (us_internal_ssl_is_shut_down(s) && !(rare && rare->peer_chain_checked)) { return (struct us_bun_verify_error_t){.error = 0, .code = NULL, .reason = NULL}; } return us_internal_ssl_verify_error(s); @@ -2052,6 +2048,18 @@ int us_ssl_take_error_reason(char *reason, size_t length) { return 1; } +/* A fatal protocol error. `reason` is borrowed: the owner copies it before its handshake callback returns. */ +struct us_bun_verify_error_t us_ssl_protocol_error(const char *reason) { + return (struct us_bun_verify_error_t){.error = -71, .code = "EPROTO", .reason = reason}; +} + +/* The peer left before the handshake finished. */ +struct us_bun_verify_error_t us_ssl_peer_disconnected_error(void) { + return (struct us_bun_verify_error_t){ + .error = -46, .code = "ECONNRESET", + .reason = "Client network socket disconnected before secure TLS connection was established"}; +} + /* Park the fatal OpenSSL reason behind a failed SSL_* call where the * handshake-failure dispatch can find it, then drain the queue and mark the * socket fatal. Only parks while the first handshake is unfinished: that dispatch is @@ -2086,15 +2094,13 @@ static int ssl_dispatch_parked_reason(struct us_socket_t *s) { memcpy(reason, loop_ssl_data->ssl_last_fatal_error, sizeof(reason)); loop_ssl_data->ssl_last_fatal_error[0] = 0; loop_ssl_data->ssl_last_fatal_error_owner = NULL; - struct us_bun_verify_error_t verify_error = { - .error = -71, .code = "EPROTO", .reason = reason}; - us_dispatch_handshake(s, 0, verify_error); + us_dispatch_handshake(s, 0, us_ssl_protocol_error(reason)); return 1; } static void ssl_trigger_handshake(struct us_socket_t *s, int success) { /* Read before the state flip below turns tripped() off. */ - int inline_rejected = us_ssl_inline_reject_tripped(s); + int inline_rejected = ssl_inline_reject_tripped(s); s->ssl_handshake_state = HANDSHAKE_COMPLETED; /* Inline rejection: the rejecting client's post-verify flight was * suppressed - the session must not be reported as established. */ @@ -2147,10 +2153,7 @@ static void ssl_trigger_handshake_econnreset(struct us_socket_t *s) { if (ssl_dispatch_parked_reason(s)) { return; } - struct us_bun_verify_error_t verify_error = { - .error = -46, .code = "ECONNRESET", - .reason = "Client network socket disconnected before secure TLS connection was established"}; - us_dispatch_handshake(s, 0, verify_error); + us_dispatch_handshake(s, 0, us_ssl_peer_disconnected_error()); } /* True once a re-entrant us_socket_close() has run inside a dispatch. Any @@ -2159,7 +2162,7 @@ static inline int ssl_gone(struct us_socket_t *s) { return us_socket_is_closed(s) || s->ssl == NULL; } -static int ssl_renegotiate(struct us_socket_t *s) { +int us_ssl_renegotiate(SSL *ssl) { /* Server-forced renegotiation (HelloRequest -> SSL_ERROR_WANT_RENEGOTIATE). * Enforce the per-context policy (default 3 per 600s, Node's * CLIENT_RENEG_LIMIT/CLIENT_RENEG_WINDOW) before re-entering a full @@ -2168,8 +2171,8 @@ static int ssl_renegotiate(struct us_socket_t *s) { * == 0 means the per-connection counter never resets. Returning 0 makes * the caller treat this as SSL_ERROR_SSL and close the connection. */ uint32_t limit, window; - us_reneg_policy(s_ssl(s), &limit, &window); - struct us_ssl_rare_t *st = us_ssl_rare_ensure(s_ssl(s)); + us_reneg_policy(ssl, &limit, &window); + struct us_ssl_rare_t *st = us_ssl_rare_ensure(ssl); /* Wall-clock time can step backwards (NTP, manual adjustment); the * unsigned subtraction below would underflow and reset the window every * time. Only treat the window as elapsed when time has moved forward. */ @@ -2181,11 +2184,9 @@ static int ssl_renegotiate(struct us_socket_t *s) { st->reneg_count = 0; } /* Over the limit BoringSSL refuses, so every refusal queues the same SSL_R_NO_RENEGOTIATION. */ - if (st->reneg_count >= limit) SSL_set_renegotiate_mode(s_ssl(s), ssl_renegotiate_never); + if (st->reneg_count >= limit) SSL_set_renegotiate_mode(ssl, ssl_renegotiate_never); st->reneg_count++; - if (!SSL_renegotiate(s_ssl(s))) return 0; - s->ssl_handshake_state = HANDSHAKE_RENEGOTIATION_PENDING; - return 1; + return SSL_renegotiate(ssl); } /* Returns 1 if shutdown is complete (or impossible) and the TCP socket may be @@ -2211,16 +2212,22 @@ static int ssl_handle_shutdown(struct us_socket_t *s) { s->ssl_fatal_error = 1; return 1; } - /* The kernel refused the close_notify, and SSL_SENT_SHUTDOWN (is_shut_down) ends the writable - * poll that would drain it: close now without it, the peer still observes the FIN. Node parks - * the alert in its stream write queue (crypto_tls.cc DoShutdown). */ - return ret == 1 || s->ssl_spilled; + /* A close_notify that the kernel refused is spilled, and the writable event sends it. */ + return ret == 1; } return 1; } -/* Seconds a close waits for the peer to take the spill (at most one flush unit, so 13 KB/s), then for its close_notify: Bun.serve's default idleTimeout. */ -#define US_SSL_CLOSE_AFTER_SPILL_TIMEOUT 10 +/* Nothing can be written behind a sealed close_notify: whatever the owner had to send is in the kernel or in the spill. */ +int us_internal_ssl_sealed_close_notify(struct us_socket_t *s) { + return !ssl_gone(s) && (SSL_get_shutdown(s_ssl(s)) & SSL_SENT_SHUTDOWN); +} + +/* A graceful close has sealed its close_notify and waits for the peer's. Its owner let go. */ +static int ssl_close_waits_for_peer(struct us_socket_t *s) { + return (SSL_get_shutdown(s_ssl(s)) & (SSL_SENT_SHUTDOWN | SSL_RECEIVED_SHUTDOWN)) == SSL_SENT_SHUTDOWN && + us_internal_socket_can_raw_write(s) && !s->ssl_shutdown_after_spill && !s->ssl_fatal_error; +} struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void *reason) { if (s->ssl && s->ssl_in_use) { @@ -2235,6 +2242,9 @@ struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void s->ssl_pending_close_code = (unsigned char) code; return s; } + /* A second graceful close leaves the first one waiting, for the kernel or for the peer, with its deadline. */ + if (code == LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN && !reason && !ssl_gone(s) && + ((s->ssl_close_after_spill && !s->ssl_fatal_error) || ssl_close_waits_for_peer(s))) return s; { /* Ciphertext batched in this dispatch and still held (the handshake's * final flight, a fatal alert sealed by a failing SSL_read) goes to the @@ -2262,8 +2272,7 @@ struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void * forceful FAST_SHUTDOWN (on_close would see an abortive teardown). */ s->ssl_pending_close_code = (unsigned char) code; /* The owner let go, so its timeouts are over and nobody else bounds this wait. */ - us_socket_timeout(s, US_SSL_CLOSE_AFTER_SPILL_TIMEOUT); - us_socket_long_timeout(s, 0); + us_socket_timeout(s, LIBUS_LINGER_SECONDS); return s; } } @@ -2311,8 +2320,7 @@ struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void /* Only a read delivers the peer's reply, and the owner that paused let go with this close. */ us_socket_resume(s); /* So nobody else bounds the wait for a peer that does not answer, whatever else it sends (us_internal_ssl_on_timeout). */ - us_socket_timeout(s, US_SSL_CLOSE_AFTER_SPILL_TIMEOUT); - us_socket_long_timeout(s, 0); + us_socket_timeout(s, LIBUS_LINGER_SECONDS); return s; } #define ssl_close us_internal_ssl_close @@ -2320,7 +2328,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 us_internal_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 @@ -2342,7 +2352,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->send_rejected)) { ssl_trigger_handshake(s, 0); return; } @@ -2367,7 +2377,7 @@ static void ssl_update_handshake(struct us_socket_t *s, int fin_ends_handshake) /* Inline rejection tripped during this drive: nothing more will arrive (a * TLS 1.2 client would otherwise wait forever for the server's Finished). * ssl_trigger_handshake reports the recorded X509 verdict and closes. */ - if (us_ssl_inline_reject_tripped(s)) { + if (ssl_inline_reject_tripped(s)) { ssl_trigger_handshake(s, 0); return; } @@ -2459,8 +2469,6 @@ static struct us_socket_t *ssl_deliver_eof(struct us_socket_t *s) { return us_dispatch_end(s); } -static struct us_socket_t *ssl_on_writable(struct us_socket_t *s); - /* Retry a JS write parked on WANT_READ (written before the handshake * finished). No-op while this socket's spill is undrained: the flag is kept * so the retry happens after on_writable drains it. */ @@ -2469,7 +2477,7 @@ static struct us_socket_t *ssl_retry_parked_write(struct us_socket_t *s) { s->ssl_write_wants_read = 0; /* Behind the close_notify there is no write to retry, and the owner would take the event for the end of its flush and close again. */ if (SSL_get_shutdown(s_ssl(s)) & SSL_SENT_SHUTDOWN) return s; - return ssl_on_writable(s); + return us_internal_ssl_on_writable(s); } struct us_socket_t *us_internal_ssl_on_end(struct us_socket_t *s) { @@ -2530,16 +2538,20 @@ struct us_socket_t *us_internal_ssl_on_timeout(struct us_socket_t *s) { ssl_release_spill(s->group->loop, s); return us_internal_socket_close_raw(s, s->ssl_pending_close_code, NULL); } - /* close_notify without the FIN: the spill drained, and the graceful close now waits for the peer's reply. */ - if ((SSL_get_shutdown(s_ssl(s)) & SSL_SENT_SHUTDOWN) && us_internal_socket_can_raw_write(s)) { + if (ssl_close_waits_for_peer(s)) { return us_internal_socket_close_raw(s, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, NULL); } return us_dispatch_timeout(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; +struct us_socket_t *us_internal_ssl_close_after_rejected_send(struct us_socket_t *s, int unread) { + if (unread) { + /* The handshake can't complete, so the queued read only reports the failure. Resume a paused socket so that read happens. */ + if (s->ssl_handshake_state == HANDSHAKE_PENDING) us_socket_resume(s); + /* A Bun socket holds a write open for these records, and a reader that stopped may be waiting for that write. */ + else if (us_socket_kind(s) == BUN_SOCKET_KIND_BUN_SOCKET_TLS) return us_dispatch_writable(s); + return s; + } if (s->ssl_handshake_state == HANDSHAKE_PENDING) { ssl_set_loop_data(s); ssl_trigger_handshake_econnreset(s); @@ -2550,34 +2562,20 @@ static struct us_socket_t *ssl_close_after_rejected_send(struct us_socket_t *s, if ((SSL_get_shutdown(s_ssl(s)) & both_ended) == both_ended) { return us_internal_socket_close_raw(s, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, NULL); } - return us_internal_socket_close_raw(s, send_error > 2 ? send_error : LIBUS_ECONNRESET, NULL); -} - -struct us_socket_t *us_internal_ssl_on_end_after_rejected_send(struct us_socket_t *s) { - return ssl_close_after_rejected_send(s, LIBUS_ECONNRESET); + return us_internal_socket_close_raw(s, LIBUS_ECONNRESET, NULL); } struct us_socket_t *us_internal_ssl_on_writable(struct us_socket_t *s) { - /* Past its FIN a socket polls for writable only when ssl_raw_write asked for this event. */ - if (!us_internal_socket_can_raw_write(s)) return ssl_close_after_rejected_send(s, LIBUS_ECONNRESET); - s = ssl_on_writable(s); - /* Asked for from inside this dispatch, that event never comes: loop.c drops the writable interest of a shut-down socket. */ - if (s && !ssl_gone(s) && s->ssl_send_rejected) return ssl_close_after_rejected_send(s, LIBUS_ECONNRESET); - return s; -} - -static struct us_socket_t *ssl_on_writable(struct us_socket_t *s) { struct loop_ssl_data *loop_ssl_data = ssl_set_loop_data(s); s = ssl_close_if_fatal(s); if (!s || ssl_gone(s)) return s; /* Ciphertext from a partial batch flush goes out before anything else; * while it is pending nothing new may be written for this socket. */ - loop_ssl_data->ssl_send_error = 0; - if (!ssl_drain_spill(loop_ssl_data, s)) return s; - if (loop_ssl_data->ssl_send_error) return ssl_close_after_rejected_send(s, loop_ssl_data->ssl_send_error); + if (!ssl_drain_spill(loop_ssl_data, s) || s->send_rejected) return s; if (s->ssl_shutdown_after_spill) { s->ssl_shutdown_after_spill = 0; - us_internal_ssl_shutdown(s); + if (SSL_get_shutdown(s_ssl(s)) & SSL_SENT_SHUTDOWN) us_internal_socket_raw_shutdown(s); + else us_internal_ssl_shutdown(s); if (ssl_gone(s)) return s; } if (s->ssl_close_after_spill) { @@ -2642,13 +2640,28 @@ static struct us_socket_t *ssl_fail_established_session(struct us_socket_t *s, s } s->ssl_fatal_error = 1; if (has_reason) { - us_dispatch_handshake(s, 0, (struct us_bun_verify_error_t){.error = -71, .code = "EPROTO", .reason = reason}); + us_dispatch_handshake(s, 0, us_ssl_protocol_error(reason)); if (ssl_gone(s)) return NULL; } ssl_close(s, 0, NULL); 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; @@ -2748,7 +2761,10 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *socket, char *da /* The HelloRequest can share a read with the server's Finished. */ s = ssl_report_finished_handshake(s, loop_ssl_data, 0); if (!s) return NULL; - if (ssl_renegotiate(s)) continue; + if (us_ssl_renegotiate(s_ssl(s))) { + s->ssl_handshake_state = HANDSHAKE_RENEGOTIATION_PENDING; + continue; + } /* After our own close_notify or FIN the request has no answer: that is the end of our close. */ if (us_internal_ssl_is_shut_down(s)) ERR_clear_error(); return ssl_fail_established_session(s, loop_ssl_data, read); @@ -2814,9 +2830,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 buffer its input until the SNICallback answers. + * Otherwise unread ciphertext means the TLS framing is broken, so 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 @@ -2908,15 +2925,6 @@ int us_internal_ssl_is_shut_down(struct us_socket_t *s) { return !s->ssl || !s_ssl(s) || (SSL_get_shutdown(s_ssl(s)) & SSL_SENT_SHUTDOWN) || s->ssl_fatal_error; } -int us_internal_ssl_is_handshake_finished(struct us_socket_t *s) { - if (!s->ssl || !s_ssl(s)) return 0; - return SSL_is_init_finished(s_ssl(s)); -} - -int us_internal_ssl_handshake_callback_has_fired(struct us_socket_t *s) { - return s->ssl && s->ssl_handshake_state == HANDSHAKE_COMPLETED; -} - void *us_internal_ssl_get_native_handle(struct us_socket_t *s) { return s->ssl ? s_ssl(s) : NULL; } @@ -2966,7 +2974,6 @@ int us_internal_ssl_writev(struct us_socket_t *s, const struct us_iovec_t *iov, /* Earlier batched records of ours must reach the wire before anything new: * SSL already counts them as written. While they cannot be delivered, the * caller buffers plaintext (return 0), bounding the in-flight ciphertext. */ - loop_ssl_data->ssl_send_error = 0; if (!ssl_drain_spill(loop_ssl_data, s) || !us_internal_socket_can_raw_write(s)) { return 0; } @@ -3059,9 +3066,7 @@ int us_internal_ssl_writev(struct us_socket_t *s, const struct us_iovec_t *iov, int us_internal_ssl_fatal_write_error(struct us_socket_t *s) { /* Before the handshake is reported, its dispatch carries the failure. */ if (!s->ssl || s->ssl_handshake_state != HANDSHAKE_COMPLETED) return 0; - if (!us_internal_socket_can_raw_write(s)) { - return ((struct loop_ssl_data *)s->group->loop->data.ssl_data)->ssl_send_error; - } + if (s->send_rejected) return us_socket_rejected_send_error(s); if (!s->ssl_fatal_error) return 0; #ifdef _WIN32 return -UV_EPROTO; @@ -3106,8 +3111,9 @@ void us_internal_ssl_shutdown(struct us_socket_t *s) { * prepends any pending TLS 1.3 NewSessionTicket flight to the alert, so * tickets are still delivered) and owns the error handling. */ ssl_handle_shutdown(s); - ssl_release_spill(s->group->loop, s); - us_internal_socket_raw_shutdown(s); + /* The FIN follows a close_notify that waits for the kernel, from the writable event. */ + if (s->ssl_spilled) s->ssl_shutdown_after_spill = 1; + else us_internal_socket_raw_shutdown(s); return; } @@ -3144,33 +3150,28 @@ void us_socket_sni_resolve(struct us_socket_t *s, struct ssl_ctx_st *ctx, int er if (ctx) SSL_CTX_free(ctx); return; } - if (s->ssl_sni_pending != US_SNI_WAITING) { - /* Not actually suspended (late/duplicate resolution). */ - if (ctx) SSL_CTX_free(ctx); - return; - } + if (!us_ssl_sni_resolve(s_ssl(s), ctx, error)) return; 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; - if (error) { - s->ssl_sni_pending = US_SNI_ERROR; - if (ctx) SSL_CTX_free(ctx); - /* Match the synchronous abort path: the connection is dropped WITHOUT a - * TLS alert (Node's behavior for SNICallback errors). Mark the socket for - * the deferred close before re-driving the handshake, so the BIO swallows - * the handshake_failure alert BoringSSL queues for select_cert_error and - * the epilogue closes the socket; the client just sees the connection go - * away ("disconnected before secure TLS connection was established"). */ - s->ssl_pending_detach = 1; - s->ssl_pending_close_code = 0; - } else { - s->ssl_sni_pending = US_SNI_RESOLVED; - loop_ssl_data->ssl_sni_resolved_ctx = ctx; /* may be NULL = default ctx */ - } + /* Take it before re-driving the handshake, which can free the SSL and `rare` 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; /* Re-drive the handshake; select_cert_cb re-fires and consumes the state. */ ssl_update_handshake(s, 1); - 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; + /* If the handshake just failed, close here. No read or writable event is coming to do it. */ + 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); + } } /* ── Adopt-TLS (STARTTLS / Bun.connect upgrade) ──────────────────────────── */ @@ -3199,16 +3200,7 @@ struct us_socket_t *us_socket_adopt_tls(struct us_socket_t *s, * `new tls.TLSSocket(acceptedSocket, { isServer: true })`); there is no * listener for an adopted socket, so SNI resolves from the single ssl_ctx. */ us_internal_ssl_attach(new_s, ssl_ctx, is_client, sni, NULL); - if (!is_client && new_s->ssl) { - /* As in Node's TLSWrap::SetVerifyMode, the policy is this TLSSocket's, not the one the shared SSL_CTX was built with. */ - SSL_set_verify(new_s->ssl, - request_cert - ? SSL_VERIFY_PEER | (reject_unauthorized - ? SSL_VERIFY_FAIL_IF_NO_PEER_CERT - : 0) - : SSL_VERIFY_NONE, - us_verify_callback); - } + if (!is_client && new_s->ssl) us_ssl_set_verify_mode(new_s->ssl, request_cert, reject_unauthorized); us_socket_resume(new_s); /* Do NOT kick the handshake or dispatch on_open here — the caller hasn't * repointed the ext slot yet, so any dispatch (open/handshake/close) would @@ -3235,13 +3227,12 @@ static void sni_node_destructor(void *user) { /* A ClientHello can arrive after its listen socket closed: one reference for the listen socket, one per accepted SSL. */ struct us_server_names_t { unsigned int refs; - /* NULL once the listen socket closed. */ - struct us_listen_socket_t *ls; /* 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. */ - struct ssl_ctx_st *(*on_server_name)(struct us_listen_socket_t *, const char *hostname, int *abort_handshake, struct us_socket_t *socket); + /* See us_listen_socket_set_alpn. NULL leaves the selection to us_dispatch_alpn_select. */ + unsigned char *alpn; + unsigned int alpn_length; + int alpn_required; }; static struct us_server_names_t *us_listen_socket_server_names(struct us_listen_socket_t *ls) { @@ -3249,7 +3240,6 @@ static struct us_server_names_t *us_listen_socket_server_names(struct us_listen_ struct us_server_names_t *names = us_calloc(1, sizeof(*names)); if (!names) Bun__outOfMemory(); names->refs = 1; - names->ls = ls; ls->server_names = names; } return ls->server_names; @@ -3264,41 +3254,48 @@ static struct us_server_names_t *us_server_names_ref(struct us_listen_socket_t * static void us_server_names_unref(struct us_server_names_t *names) { if (--names->refs) return; if (names->tree) sni_free(names->tree, sni_node_destructor); + us_free(names->alpn); us_free(names); } +void us_listen_socket_set_alpn(struct us_listen_socket_t *ls, const unsigned char *protocols, unsigned int length, int required) { + struct us_server_names_t *names = us_listen_socket_server_names(ls); + us_free(names->alpn); + names->alpn = us_malloc(length); + if (!names->alpn) Bun__outOfMemory(); + memcpy(names->alpn, protocols, length); + names->alpn_length = length; + names->alpn_required = required; +} + +/* On every SSL_CTX. Listeners share contexts, so what a server selects hangs off the connection. */ +static int us_alpn_select_cb(SSL *ssl, const unsigned char **out, unsigned char *outlen, + const unsigned char *in, unsigned int inlen, void *arg) { + (void)arg; + struct us_ssl_rare_t *rare = us_ssl_rare(ssl); + struct us_server_names_t *names = rare ? rare->server_names : NULL; + if (!names || !names->alpn) return us_dispatch_alpn_select(ssl, out, outlen, in, inlen); + if (SSL_select_next_proto((unsigned char **)out, outlen, names->alpn, names->alpn_length, in, inlen) == OPENSSL_NPN_NEGOTIATED) { + return SSL_TLSEXT_ERR_OK; + } + return names->alpn_required ? SSL_TLSEXT_ERR_ALERT_FATAL : SSL_TLSEXT_ERR_NOACK; +} + static struct sni_node_t *us_server_names_find(struct us_server_names_t *names, const char *hostname) { if (!names || !names->tree) return NULL; return (struct sni_node_t *)sni_find(names->tree, hostname); } -#define US_SNI_POLICY_REQUEST_CERT ((uintptr_t)1) -#define US_SNI_POLICY_REJECT_UNAUTHORIZED ((uintptr_t)2) - -static _Atomic uint32_t us_sni_sid_ctx_counter = 0; - -/* Record the client-certificate policy of a per-serverName entry on its - * SSL_CTX and give it a session-id context of its own: SSL_set_SSL_CTX copies - * the certificate (with its sid_ctx) but not verify_mode, so the SNI switch - * reapplies the policy per connection, and a session established under any - * other context is never resumed under this one (a resumed handshake skips - * client authentication entirely). */ -void us_ssl_ctx_set_sni_policy(SSL_CTX *ctx, int request_cert, int reject_unauthorized) { - if (!ctx) return; - us_ex_idx_ensure(); - uintptr_t packed = (request_cert ? US_SNI_POLICY_REQUEST_CERT : 0) | - (reject_unauthorized ? US_SNI_POLICY_REJECT_UNAUTHORIZED : 0); - SSL_CTX_set_ex_data(ctx, us_ctx_sni_policy_ex_idx, (void *)packed); - uint32_t sid = atomic_fetch_add(&us_sni_sid_ctx_counter, 1) + 1; +/* A resumed handshake skips client authentication, so a session of any other context must not resume under this one. */ +void us_ssl_ctx_set_own_session_id_context(SSL_CTX *ctx) { + static _Atomic uint32_t counter = 0; + uint32_t sid = atomic_fetch_add(&counter, 1) + 1; SSL_CTX_set_session_id_context(ctx, (const uint8_t *)&sid, sizeof(sid)); } -/* 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 - * it (the connection's inherited requirement is kept). A context without a - * recorded policy (node:tls SecureContext, whose policy is server-level) - * leaves the connection's verify mode untouched. +static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *hello); + +/* Every SNI context switch goes through here. The connection keeps the verify mode it inherited at accept. * * SSL_set_SSL_CTX also copies the context's session id context, which for * contexts built in Rust is the digest of their options @@ -3308,28 +3305,26 @@ void us_ssl_ctx_set_sni_policy(SSL_CTX *ctx, int request_cert, int reject_unauth * (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) { + struct us_ssl_rare_t *rare = us_ssl_rare(ssl); + 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); - if (!(policy & US_SNI_POLICY_REQUEST_CERT)) return; - int mode = SSL_get_verify_mode(ssl) | SSL_VERIFY_PEER; - if (policy & US_SNI_POLICY_REJECT_UNAUTHORIZED) mode |= SSL_VERIFY_FAIL_IF_NO_PEER_CERT; - SSL_set_verify(ssl, mode, us_verify_callback); } -/* Whether the SNI-selected context of this connection demands closing on a - * client-certificate verification error (requestCert && rejectUnauthorized - * of the per-serverName entry). */ +/* us_ssl_ctx_build_raw sets FAIL_IF_NO_PEER_CERT iff the options have request_cert and reject_unauthorized. */ int us_ssl_ctx_reject_unauthorized(SSL_CTX *ctx) { - if (!ctx || us_ctx_sni_policy_ex_idx < 0) return 0; - uintptr_t packed = (uintptr_t)SSL_CTX_get_ex_data(ctx, us_ctx_sni_policy_ex_idx); - return (packed & US_SNI_POLICY_REQUEST_CERT) && - (packed & US_SNI_POLICY_REJECT_UNAUTHORIZED); + return ctx && (SSL_CTX_get_verify_mode(ctx) & SSL_VERIFY_FAIL_IF_NO_PEER_CERT); } -int us_socket_server_name_reject_unauthorized(struct us_socket_t *s) { - if (!s->ssl) return 0; - return us_ssl_ctx_reject_unauthorized(SSL_get_SSL_CTX(s_ssl(s))); +int us_socket_rejects_unauthorized(struct us_socket_t *s) { + return s->ssl && (SSL_get_verify_mode(s_ssl(s)) & SSL_VERIFY_FAIL_IF_NO_PEER_CERT); } /* Extracts the host_name from the ClientHello's server_name extension. @@ -3366,61 +3361,43 @@ 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 - * 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) { +/* An SNICallback's error or invalid context drops the connection without a TLS alert, as in Node. `s` swallows the alert in its BIO and closes once the SSL_* call returns. An SSLWrapper asks us_ssl_sni_refused. */ +static enum ssl_select_cert_result_t ssl_refuse_server_name(struct us_socket_t *s, struct us_ssl_rare_t *rare) { + rare->sni_pending = US_SNI_REFUSED; + if (s) { + s->ssl_pending_detach = 1; + s->ssl_pending_close_code = 0; + } + return ssl_select_cert_error; +} + +/* The SNI stage of us_select_cert_cb: asks the JS resolver (a user SNICallback) for a context. A resolver that + * can't answer synchronously suspends the handshake (ssl_select_cert_retry -> SSL_ERROR_PENDING_CERTIFICATE) + * until us_socket_sni_resolve() resumes it. */ +static enum ssl_select_cert_result_t us_select_cert_sni(const SSL_CLIENT_HELLO *hello, struct us_ssl_rare_t *rare) { 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; + struct us_socket_t *s = us_ssl_socket(ssl); /* A previous suspension being resumed: consume the stored result. */ - if (s->ssl_sni_pending == US_SNI_RESOLVED) { - s->ssl_sni_pending = US_SNI_NONE; - struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)BIO_get_data(SSL_get_wbio(ssl)); - SSL_CTX *resolved_ctx = loop_ssl_data->ssl_sni_resolved_ctx; - loop_ssl_data->ssl_sni_resolved_ctx = NULL; + if (rare->sni_pending == US_SNI_RESOLVED) { + rare->sni_pending = US_SNI_NONE; + SSL_CTX *resolved_ctx = rare->sni_resolved_ctx; + rare->sni_resolved_ctx = NULL; 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; } - if (s->ssl_sni_pending == US_SNI_ERROR) { - s->ssl_sni_pending = US_SNI_NONE; - return ssl_select_cert_error; + if (rare->sni_pending == US_SNI_REFUSED) { + return ssl_refuse_server_name(s, rare); } - if (s->ssl_sni_pending == US_SNI_WAITING) { + if (rare->sni_pending == US_SNI_WAITING) { /* Still waiting (a spurious re-drive); keep suspending. */ return ssl_select_cert_retry; } - /* With no listener resolver, the socket may still carry its own: a - * server-side socket adopted into TLS with its own SNICallback. */ - const int no_listener_resolver = (!names || !names->on_server_name); - if (no_listener_resolver && !s->ssl_sni_resolver) { + if (!rare->sni_resolver) { return ssl_select_cert_success; } @@ -3429,47 +3406,89 @@ 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. */ - + /* Only the resolver picks the context. In Node a user SNICallback replaces the default one, which is the only code + * that reads 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; - SSL_CTX *dyn = - no_listener_resolver ? us_dispatch_socket_server_name(s, hostname, &abort_handshake) - : names->on_server_name(names->ls, hostname, &abort_handshake, s); + SSL_CTX *dyn = us_dispatch_server_name(rare->bun_socket, hostname, &abort_handshake); us_internal_ssl_loop_state_restore(saved_loop_state); if (abort_handshake == 1) { - /* Error/invalid context: drop the connection without an alert (the - * deferred-close + BIO-swallow path, same as sni_cb). */ - s->ssl_pending_detach = 1; - s->ssl_pending_close_code = 0; - return ssl_select_cert_error; + return ssl_refuse_server_name(s, rare); } if (abort_handshake == 2) { - /* The JS resolver answered "pending": suspend until us_socket_sni_resolve. */ - s->ssl_sni_pending = US_SNI_WAITING; + /* The JS resolver answered "pending": suspend until us_ssl_sni_resolve. */ + rare->sni_pending = US_SNI_WAITING; return ssl_select_cert_retry; } 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; + } + enum ssl_select_cert_result_t result = rare ? us_select_cert_sni(hello, rare) : ssl_select_cert_success; + 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) { @@ -3480,12 +3499,8 @@ static int sni_cb(SSL *ssl, int *al, void *arg) { struct us_ssl_rare_t *rare = us_ssl_rare(ssl); 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. */ + if (rare->sni_resolver) { + /* us_select_cert_cb already ran the resolver, and a socket that has one doesn't consult the tree. */ return SSL_TLSEXT_ERR_OK; } const char *hostname = SSL_get_servername(ssl, TLSEXT_NAMETYPE_host_name); @@ -3495,6 +3510,9 @@ static int sni_cb(SSL *ssl, int *al, void *arg) { struct sni_node_t *node = us_server_names_find(names, hostname); if (node) { us_ssl_apply_selected_ctx(ssl, node->ctx); + if (node->own_client_auth) { + SSL_set_verify(ssl, SSL_get_verify_mode(ssl) | SSL_CTX_get_verify_mode(node->ctx), us_verify_callback); + } } } return SSL_TLSEXT_ERR_OK; @@ -3502,7 +3520,7 @@ static int sni_cb(SSL *ssl, int *al, void *arg) { void us_listen_socket_add_server_name(struct us_listen_socket_t *ls, const char *hostname_pattern, - SSL_CTX *ctx) { + SSL_CTX *ctx, int own_client_auth) { SSL_CTX *default_ctx = ls->ssl_ctx; if (!default_ctx) return; @@ -3516,24 +3534,12 @@ void us_listen_socket_add_server_name(struct us_listen_socket_t *ls, struct sni_node_t *node = us_xmalloc(sizeof(struct sni_node_t)); node->ctx = ctx; + node->own_client_auth = own_client_auth; SSL_CTX_up_ref(ctx); sni_node_destructor(sni_add(names->tree, hostname_pattern, node)); } -/* Returns the SSL_CTX registered for `hostname_pattern` via - * us_listen_socket_add_server_name, or NULL. Owned - the caller must release - * the reference. The on_server_name resolvers return owned references (the - * SNI dispatcher frees them after SSL_set_SSL_CTX takes its own), so the - * tree's reference must not be handed out as a borrow. */ -struct ssl_ctx_st *us_listen_socket_find_server_name_ctx(struct us_listen_socket_t *ls, - const char *hostname_pattern) { - struct sni_node_t *node = us_server_names_find(ls->server_names, hostname_pattern); - if (!node || !node->ctx) return NULL; - SSL_CTX_up_ref(node->ctx); - return node->ctx; -} - void us_listen_socket_set_default_ssl_ctx(struct us_listen_socket_t *ls, SSL_CTX *ctx) { if (ls->ssl_ctx == ctx) return; @@ -3543,44 +3549,69 @@ void us_listen_socket_set_default_ssl_ctx(struct us_listen_socket_t *ls, if (names && names->tree) { SSL_CTX_set_tlsext_servername_callback(ctx, sni_cb); } - if (names && names->on_server_name) { - SSL_CTX_set_select_certificate_cb(ctx, us_select_cert_cb); - } if (ls->ssl_ctx) { us_internal_ssl_ctx_unref(ls->ssl_ctx); } ls->ssl_ctx = ctx; } -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 *, int *, struct us_socket_t *)) { - if (!ls->ssl_ctx) return; - 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). */ - SSL_CTX_set_select_certificate_cb(ls->ssl_ctx, us_select_cert_cb); +/* us_dispatch_server_name then resolves the server name of this server-side SSL. Call it before the handshake is driven. */ +void us_ssl_on_server_name(SSL *ssl) { + us_ssl_rare_ensure(ssl)->sni_resolver = 1; + /* Only this early stage supports retry, which an asynchronous SNICallback needs. Permanent on a possibly shared SSL_CTX, so us_select_cert_cb is a no-op on an SSL without a resolver. */ + SSL_CTX_set_select_certificate_cb(SSL_get_SSL_CTX(ssl), us_select_cert_cb); +} + +/* Takes the reference `ctx` carries (NULL: the default context). Returns 1 if the handshake waited for this answer: the caller then drives it again. */ +int us_ssl_sni_resolve(SSL *ssl, SSL_CTX *ctx, int error) { + struct us_ssl_rare_t *rare = us_ssl_rare(ssl); + if (!rare || rare->sni_pending != US_SNI_WAITING || error) SSL_CTX_free(ctx); + if (!rare || rare->sni_pending != US_SNI_WAITING) return 0; + rare->sni_pending = error ? US_SNI_REFUSED : US_SNI_RESOLVED; + if (!error) rare->sni_resolved_ctx = ctx; + return 1; } -/* Register a socket-level SNI resolver on an already-attached server-side SSL. - * Must run after us_socket_adopt_tls and before the handshake is driven. */ -void us_socket_on_server_name(struct us_socket_t *s) { - if (!s || !s->ssl || !s_ssl(s)) return; - SSL *ssl = s_ssl(s); - s->ssl_sni_resolver = 1; - /* Only the early select-certificate stage supports retry, which an async - * SNICallback needs. The CTX is a memoized SecureContext possibly shared - * with a listener, and this install is permanent, so us_select_cert_cb must - * stay a no-op on any SSL carrying neither resolver. */ - SSL_CTX *ctx = SSL_get_SSL_CTX(ssl); - if (ctx) SSL_CTX_set_select_certificate_cb(ctx, us_select_cert_cb); +unsigned int us_ssl_sni_held_max(void) { + return US_SSL_SNI_HELD_MAX; } -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); +int us_ssl_sni_waiting(const SSL *ssl) { + struct us_ssl_rare_t *rare = us_ssl_rare(ssl); + return rare && rare->sni_pending == US_SNI_WAITING; +} + +int us_ssl_sni_refused(const SSL *ssl) { + struct us_ssl_rare_t *rare = us_ssl_rare(ssl); + return rare && rare->sni_pending == US_SNI_REFUSED; +} + +/* 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_ssl_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))); + /* After the ServerHello the new session already has the old session id context, but the client is verified against `ctx`'s CAs. */ + 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); } void us_internal_listen_socket_ssl_free(struct us_listen_socket_t *ls) { @@ -3589,7 +3620,6 @@ void us_internal_listen_socket_ssl_free(struct us_listen_socket_t *ls) { ls->ssl_ctx = NULL; } if (ls->server_names) { - ls->server_names->ls = NULL; us_server_names_unref(ls->server_names); ls->server_names = NULL; } diff --git a/packages/bun-usockets/src/crypto/root_certs.cpp b/packages/bun-usockets/src/crypto/root_certs.cpp index 8bf3f0fbb8f2..7c7ba5c2fe8e 100644 --- a/packages/bun-usockets/src/crypto/root_certs.cpp +++ b/packages/bun-usockets/src/crypto/root_certs.cpp @@ -48,18 +48,12 @@ 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) -// bun_core::Global::CAStore, from --use-system-ca, --use-openssl-ca and --use-bundled-ca. -enum class us_ca_store_t : uint8_t { bundled = 0, system = 1, openssl = 2 }; -extern "C" us_ca_store_t Bun__Node__CAStore; - -static us_ca_store_t us_ca_store() { - if (Bun__Node__CAStore != us_ca_store_t::bundled) { - return Bun__Node__CAStore; - } - // Not every command parses the flags. - const char *use_system_ca = getenv("NODE_USE_SYSTEM_CA"); - return use_system_ca && strcmp(use_system_ca, "1") == 0 ? us_ca_store_t::system : us_ca_store_t::bundled; -} +// bun_core::Global decides from --use-*-ca and NODE_USE_SYSTEM_CA. tls.getCACertificates("default") lists the same sources. +struct us_default_ca_sources_t { + bool bundled; + bool system; +}; +extern "C" us_default_ca_sources_t Bun__defaultCASources(); // Platform-specific system certificate loading implementations are separated: // - macOS: root_certs_darwin.cpp (Security framework with dynamic loading) @@ -207,7 +201,7 @@ static const us_openssl_default_cert_file &us_get_openssl_default_cert_file() { const uint8_t *const *bundled_certs = nullptr; const size_t *bundled_lens = nullptr; size_t bundled_count = - us_ca_store() == us_ca_store_t::openssl ? 0 : us_bundled_root_certs_der(&bundled_certs, &bundled_lens); + Bun__defaultCASources().bundled ? us_bundled_root_certs_der(&bundled_certs, &bundled_lens) : 0; std::unordered_set bundled; bundled.reserve(bundled_count); for (size_t i = 0; i < bundled_count; i++) { @@ -296,10 +290,10 @@ extern "C" X509_STORE *us_get_default_ca_store() { return NULL; } X509_STORE_set_flags(store, X509_V_FLAG_IGNORE_EXPIRED_TRUST_ANCHORS); - us_ca_store_t ca_store = us_ca_store(); + us_default_ca_sources_t sources = Bun__defaultCASources(); // https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_context.cc#L1099 - if (ca_store != us_ca_store_t::openssl) { + if (sources.bundled) { 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); @@ -335,7 +329,7 @@ extern "C" X509_STORE *us_get_default_ca_store() { } } - if (ca_store == us_ca_store_t::system) { + if (sources.system) { const us_system_certs_t &system = us_get_root_system_certs(); if (system.lazy != nullptr && !X509_STORE_add_lazy_cert_set(store, system.lazy)) { X509_STORE_free(store); diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index fb7b5ff4945f..ff5d6d84c3db 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; @@ -343,13 +343,20 @@ static void us_internal_dispatch_ready_polls(struct us_loop_t *loop) { break; } } + /* A nested tick resumes an outer pass's batch, and that pass dispatched this entry's bits with its earlier entry. */ + for (int j = first - 1; !merged && j >= 0; j--) { + if (GET_READY_POLL(loop, j) == poll) { + coalesced[i] = (struct kevent_flags){ .skip = 1 }; + merged = 1; + } + } if (!merged) { coalesced[i] = bits; } } /* 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 +431,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 +466,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 +475,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,10 +497,18 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout /* Emit pre callback */ us_internal_loop_pre(loop); - /* 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 - * here so QUIC retransmit/idle timers fire without other I/O waking us. */ + /* 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 ran the QUIC drivers and stored the soonest tick they want, so their timers fire without other I/O. */ struct timespec quic_ts; if (loop->data.quic_head && loop->data.quic_next_tick_us >= 0) { long long us = loop->data.quic_next_tick_us; @@ -500,7 +521,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)); @@ -533,7 +554,9 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout /* A zero timespec already has a fast path in ep_poll (fs/eventpoll.c): * it sets timed_out=1 (line 1952) and returns before any scheduler * interaction (line 1975). No equivalent of KEVENT_FLAG_IMMEDIATE needed. */ - loop->num_ready_polls = bun_epoll_pwait2(loop->fd, loop->ready_polls, LIBUS_MAX_READY_POLLS, timeout); + static const struct timespec no_wait = { 0, 0 }; + /* A counted wakeup whose eventfd signal an earlier poll already returned leaves nothing ready, and nothing above prepared this tick to park. */ + loop->num_ready_polls = bun_epoll_pwait2(loop->fd, loop->ready_polls, LIBUS_MAX_READY_POLLS, will_idle_inside_event_loop ? timeout : &no_wait); #else loop->num_ready_polls = bun_kevent64_wait(loop->fd, loop->ready_polls, LIBUS_MAX_READY_POLLS, /* When we won't idle (pending wakeups or zero timeout), use KEVENT_FLAG_IMMEDIATE. @@ -555,7 +578,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 +947,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 f6c85a86d6d0..cdd9d8d09656 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 */ @@ -581,6 +585,13 @@ void us_timer_set(struct us_timer_t *t, void (*cb)(struct us_timer_t *t), } } +void us_timer_again(struct us_timer_t *t) { + uv_timer_t *uv_timer = (uv_timer_t *)((struct us_internal_callback_t *)t + 1); + // uv_timer_again() counts from the loop's cached time. + uv_update_time(uv_timer->loop); + uv_timer_again(uv_timer); +} + struct us_loop_t *us_timer_loop(struct us_timer_t *t) { struct us_internal_callback_t *internal_cb = (struct us_internal_callback_t *)t; diff --git a/packages/bun-usockets/src/internal/eventing/libuv.h b/packages/bun-usockets/src/internal/eventing/libuv.h index 3f287f40ad74..f7c4b51bc3b7 100644 --- a/packages/bun-usockets/src/internal/eventing/libuv.h +++ b/packages/bun-usockets/src/internal/eventing/libuv.h @@ -26,6 +26,8 @@ /* Defined in eventing/libuv.c; used by the sweep escalation in loop.c. */ int us_internal_libuv_peer_reset_probe(LIBUS_SOCKET_DESCRIPTOR fd); +/* Makes a repeating timer's next run a whole period from now. */ +void us_timer_again(struct us_timer_t *t); struct us_loop_t { alignas(LIBUS_EXT_ALIGNMENT) struct us_internal_loop_data_t data; diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 343852e54abc..9c745b3d450b 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -107,13 +107,18 @@ static inline void *us_xcalloc(size_t count, size_t size) { #define IS_EINTR(rc) (rc == SOCKET_ERROR && WSAGetLastError() == WSAEINTR) #define LIBUS_ERR WSAGetLastError() #define LIBUS_ECONNRESET WSAECONNRESET +#define LIBUS_ECONNREFUSED WSAECONNREFUSED +#define LIBUS_EADDRINUSE WSAEADDRINUSE /* What libuv translates to UV_ECANCELED (uv_translate_sys_error). */ #define LIBUS_ECANCELED WSAEINTR #else #include +#include #define IS_EINTR(rc) (rc == -1 && errno == EINTR) #define LIBUS_ERR errno #define LIBUS_ECONNRESET ECONNRESET +#define LIBUS_ECONNREFUSED ECONNREFUSED +#define LIBUS_EADDRINUSE EADDRINUSE #define LIBUS_ECANCELED ECANCELED #endif #include @@ -131,10 +136,8 @@ enum { POLL_TYPE_POLLING_IN = 16, }; -#define POLL_TYPE_BITSIZE 5 // make sure to update epoll_kqueue.h if you change this #define POLL_TYPE_KIND_MASK 0b111 #define POLL_TYPE_POLLING_MASK 0b11000 -#define POLL_TYPE_MASK (POLL_TYPE_KIND_MASK | POLL_TYPE_POLLING_MASK) /* Bun APIs implemented in Zig */ void Bun__lock(zig_mutex_t *lock); @@ -164,19 +167,23 @@ extern struct us_socket_t *us_dispatch_fd(us_socket_r s, int fd); extern struct us_socket_t *us_dispatch_writable(us_socket_r s); extern struct us_socket_t *us_dispatch_close(us_socket_r s, int code, void *reason); extern struct us_socket_t *us_dispatch_timeout(us_socket_r s); -extern struct us_socket_t *us_dispatch_long_timeout(us_socket_r s); extern struct us_socket_t *us_dispatch_end(us_socket_r s); extern struct us_socket_t *us_dispatch_connect_error(us_socket_r s, int code); extern struct us_connecting_socket_t *us_dispatch_connecting_error(struct us_connecting_socket_t *c, int code); extern void us_dispatch_handshake(us_socket_r s, int success, struct us_bun_verify_error_t err); -extern void us_dispatch_session(us_socket_r s, const unsigned char *data, int length); -extern void us_dispatch_keylog(us_socket_r s, const unsigned char *data, int length); -struct ssl_session_st; -/* Runs inside SSL_read/SSL_do_handshake: must not run JS. Returns 1 to get us_dispatch_session later. */ -extern int us_dispatch_new_session(us_socket_r s, struct ssl_session_st *session); +/* A serialized session or a keylog line for the socket given to us_ssl_set_bun_socket. Returns 0 once that socket has closed. */ +extern int us_dispatch_tls_event(void *bun_socket, int is_keylog, const unsigned char *data, unsigned int length); /* 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 same, asked of the socket given to us_ssl_set_bun_socket. */ +extern int us_dispatch_bun_socket_identity(void *bun_socket, struct ssl_st *ssl); +/* Asks the socket given to us_ssl_set_bun_socket for the SSL_CTX to serve for `hostname` on this handshake only. NULL keeps the default one. abort_handshake: 1 refuses, 2 suspends until us_ssl_sni_resolve. */ +extern struct ssl_ctx_st *us_dispatch_server_name(void *bun_socket, 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); +/* BoringSSL's ALPN select callback, for a server-side SSL whose listen socket has no us_listen_socket_set_alpn list. */ +extern int us_dispatch_alpn_select(struct ssl_st *ssl, const unsigned char **out, unsigned char *outlen, const unsigned char *in, unsigned int inlen); 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(); @@ -235,12 +242,6 @@ void us_internal_loop_data_free(us_loop_r loop); void us_internal_loop_pre(us_loop_r loop); void us_internal_loop_post(us_loop_r loop); -/* node:quic loop driver (node_quic_shim.c): per-turn engine pass. */ -struct us_nq_driver_s; -void us_nq_loop_flush_if_pending(struct us_loop_t *loop); -void us_nq_loop_drain(struct us_loop_t *loop); -void us_nq_loop_register(struct us_loop_t *loop, struct us_nq_driver_s *d, void *owner); -void us_nq_loop_unregister(struct us_loop_t *loop, struct us_nq_driver_s *d); /* Asyncs (old) */ struct us_internal_async *us_internal_create_async(struct us_loop_t *loop, @@ -287,13 +288,12 @@ struct us_socket_t *us_internal_ssl_on_open(us_socket_r s, int is_client, char * struct us_socket_t *us_internal_ssl_on_data(us_socket_r s, char *data, int length); struct us_socket_t *us_internal_ssl_on_writable(us_socket_r s); struct us_socket_t *us_internal_ssl_on_timeout(us_socket_r s); +int us_internal_ssl_sealed_close_notify(us_socket_r s); struct us_socket_t *us_internal_ssl_on_close(us_socket_r s, int code, void *reason); struct us_socket_t *us_internal_ssl_on_end(us_socket_r s); -struct us_socket_t *us_internal_ssl_on_end_after_rejected_send(us_socket_r s); +struct us_socket_t *us_internal_ssl_close_after_rejected_send(us_socket_r s, int unread); int us_internal_ssl_is_low_prio(us_socket_r s); -int us_internal_ssl_is_handshake_finished(us_socket_r s); -int us_internal_ssl_handshake_callback_has_fired(us_socket_r s); int us_internal_ssl_is_shut_down(us_socket_r s); void us_internal_ssl_shutdown(us_socket_r s); int us_internal_ssl_write(us_socket_r s, const char *data, int length); @@ -302,8 +302,6 @@ int us_internal_ssl_writev(us_socket_r s, const struct us_iovec_t *iov, int coun int us_internal_ssl_fatal_write_error(us_socket_r s); unsigned int us_internal_ssl_spill_pending(us_socket_r s); void *us_internal_ssl_get_native_handle(us_socket_r s); -struct us_bun_verify_error_t us_internal_ssl_verify_error(us_socket_r s); -const char *us_internal_ssl_sni_servername(us_socket_r s); /* Drops the listen socket's references to ls->ssl_ctx and ls->server_names. Called from us_listen_socket_close. */ void us_internal_listen_socket_ssl_free(struct us_listen_socket_t *ls); /* Opaque SSL_CTX_up_ref/SSL_CTX_free so context.c needn't include OpenSSL. */ @@ -314,9 +312,54 @@ void us_internal_socket_raw_shutdown(us_socket_r s); /* us_socket_raw_write that stores us_internal_classify_failed_send's answer for a failed send(). */ int us_internal_socket_raw_write(us_socket_r s, const char *data, int length, int *fatal_send_error); void us_internal_rearm_writable(us_socket_r s); +/* `error` (LIBUS_ERR numbering) is why a send() failed for good. Ends the write side and asks for the writable event that + * runs us_internal_socket_close_after_rejected_send: no write may close under its caller. */ +void us_internal_socket_reject_send(us_socket_r s, int error); +/* Closes with the send's error, unless the kernel still holds bytes of the peer: then the read side ends the connection. */ +struct us_socket_t *us_internal_socket_close_after_rejected_send(us_socket_r s); int us_internal_handle_dns_results(us_loop_r loop); +#define LIBUS_NO_TIMEOUT UINT32_MAX + +/* More than us_internal_coarse_ms() can lag behind the time: a jiffy at HZ=100 is 10 ms, a Windows tick 16 ms. */ +#define LIBUS_COARSE_CLOCK_LAG_MS 50 + +/* Cheap enough to read whenever a timeout is set. Only differences over a few seconds mean anything. */ +static inline uint64_t us_internal_coarse_ms(void) { +#if defined(_WIN32) + return GetTickCount64(); +#elif defined(__APPLE__) + return clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW_APPROX) / 1000000; +#else + struct timespec ts; +#if defined(CLOCK_MONOTONIC_COARSE) + clock_gettime(CLOCK_MONOTONIC_COARSE, &ts); +#elif defined(CLOCK_MONOTONIC_FAST) + clock_gettime(CLOCK_MONOTONIC_FAST, &ts); +#else + clock_gettime(CLOCK_MONOTONIC, &ts); +#endif + return (uint64_t) ts.tv_sec * 1000 + (uint64_t) ts.tv_nsec / 1000000; +#endif +} + +/* Sweeps are at least a period apart, so the n-th from now comes no earlier than n periods after the last one: a timeout never fires early. */ +static inline uint32_t us_internal_timeout_tick(struct us_loop_t *loop, unsigned int seconds) { + if (!seconds) { + return LIBUS_NO_TIMEOUT; + } + const uint64_t period = LIBUS_TIMEOUT_GRANULARITY * 1000; + uint64_t ms = (uint64_t) seconds * 1000; + /* With no socket there are no sweeps, and the first socket starts a whole period. */ + if (loop->data.sweep_timer_count) { + uint64_t since_sweep = us_internal_coarse_ms() - loop->data.sweep_time_ms + LIBUS_COARSE_CLOCK_LAG_MS; + /* A sweep that is overdue still comes after this call, and the next a whole period after that one. */ + ms += since_sweep < period ? since_sweep : period; + } + return loop->data.sweep_tick + (uint32_t) ((ms + period - 1) / period); +} + /* Sockets are polls */ struct us_socket_flags { @@ -339,8 +382,8 @@ struct us_socket_flags { struct us_socket_t { alignas(LIBUS_EXT_ALIGNMENT) struct us_poll_t p; - unsigned char timeout; - unsigned char long_timeout; + /* The tick of `group` at which on_timeout is due, or LIBUS_NO_TIMEOUT. */ + uint32_t timeout; struct us_socket_flags flags; /* enum SocketKind. Selects the static dispatch arm in us_dispatch_*. */ unsigned char kind; @@ -354,7 +397,6 @@ struct us_socket_t { * sets it. */ unsigned char ssl_write_parked : 1; unsigned char ssl_fatal_error : 1; - unsigned char ssl_is_server : 1; /* If set, us_internal_ssl_on_data() first dispatches the still-encrypted * bytes via us_dispatch_ssl_raw_tap() before feeding them to SSL_read. * Used by Bun's `socket.upgradeTLS()` so the returned [raw, tls] pair's @@ -382,49 +424,48 @@ struct us_socket_t { * the driver's epilogue via ssl_pending_detach. */ unsigned char ssl_in_use : 1; unsigned char ssl_pending_detach : 1; - /* Client with rejectUnauthorized on: a failed chain fails the handshake before the Certificate flight. */ - unsigned char ssl_inline_reject : 1; - /* The verify callback saw an error in this handshake. */ - unsigned char ssl_verify_failed : 1; - /* The owner checked the server's name inside this handshake. */ - unsigned char ssl_identity_checked : 1; - /* The peer sent a certificate chain in this handshake, and it was checked. */ - unsigned char ssl_peer_chain_checked : 1; - /* US_SNI_*: an async SNICallback has the handshake suspended. */ - unsigned char ssl_sni_pending : 2; - /* Server-side socket adopted into TLS with its own SNICallback. */ - 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). */ unsigned char ssl_pending_close_code : 2; + /* us_socket_close_lingering: the owner let go. loop.c drops what the peer still sends, and closes at its FIN or at the timeout. */ + unsigned char lingering : 1; /* us_socket_set_first_flight_before_fin: the first handshake step is still due, and a shutdown waits for it. */ unsigned char ssl_first_flight_before_fin : 1; /* us_internal_ssl_shutdown held its FIN back for that step. */ unsigned char ssl_shutdown_after_first_flight : 1; - /* A send that no retry can save shut the write side down, not the owner. */ - unsigned char ssl_send_rejected : 1; - /* The same on a socket without TLS: loop.c closes it after its next event. */ + /* A send that no retry can save shut the write side down, not the owner (us_internal_socket_reject_send). */ unsigned char send_rejected : 1; - /* Consecutive send() failures with an errno that is neither - * would-block/transient nor a known peer-gone error (see - * 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; + union { + /* Consecutive send() failures with an errno that is neither + * would-block/transient nor a known peer-gone error (see + * 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; + /* Once send_rejected is set nothing is sent again, and this is what us_socket_rejected_send_error decodes. */ + unsigned char rejected_send_error; + }; + /* Timer-sweep ticks this socket has spent parked in the low-priority queue since us_socket_timeout() armed its timeout. Fits in the same gap. */ + unsigned char low_prio_ticks; 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; }; @@ -432,6 +473,17 @@ struct us_socket_t { _Static_assert(sizeof(struct us_socket_flags) == 1, "us_socket_flags grew"); #endif +/* The state every socket starts in. `s->p` belongs to us_poll_init. */ +void us_internal_socket_init(struct us_socket_t *s, struct us_socket_group_t *group, unsigned char kind, int allow_half_open); + +/* 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; @@ -471,11 +523,13 @@ struct us_connecting_socket_t { * different namespaces that overlap numerically, so consumers of `error` * must check this bit first. */ unsigned int closed : 1, shutdown : 1, shutdown_read : 1, pending_resolve_callback : 1, error_is_dns : 1; - unsigned char timeout; - unsigned char long_timeout; unsigned char kind; uint16_t port; + /* Every candidate socket starts with this us_socket_t.timeout. */ + uint32_t timeout; 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; @@ -556,6 +610,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/loop_data.h b/packages/bun-usockets/src/internal/loop_data.h index 4b6ef00d6f76..cf8ccaabd302 100644 --- a/packages/bun-usockets/src/internal/loop_data.h +++ b/packages/bun-usockets/src/internal/loop_data.h @@ -33,8 +33,7 @@ typedef void* zig_mutex_t; #endif // IMPORTANT: When changing this, don't forget to update the Rust mirror in src/uws_sys/InternalLoopData.rs as well! -struct us_quic_socket_context_s; -struct us_nq_driver_s; +struct us_quic_driver_s; /* A socket that us_socket_resume() failed to re-register with the kernel, and * its close code. */ @@ -51,26 +50,22 @@ struct us_internal_loop_data_t { * timeout — no timerfd, no EVFILT_TIMER. */ long long sweep_next_tick_ns; #endif + /* The sockets a sweep visits: linked in a group, connecting, or parked in the low-priority queue. Plus one per QUIC context. The sweep timer is armed while this isn't 0. */ int sweep_timer_count; + /* Counts the sweeps. us_socket_t.timeout is the count at which that socket's timeout is due. */ + uint32_t sweep_tick; + /* us_internal_coarse_ms() at the last sweep, or at the first socket if there was none since. */ + uint64_t sweep_time_ms; struct us_internal_async *wakeup_async; struct us_socket_group_t *head; /* QUIC engines on this loop. us_quic_loop_process walks the list from * loop_post / drainMicrotasks; the lazy fallthrough timer only wakes the * loop for lsquic's time-driven state (RTO, ACK delay) — its callback * just calls us_quic_loop_process. */ - struct us_quic_socket_context_s *quic_head; - /* µs until lsquic next wants process_conns (min earliest_adv_tick - * across engines), or -1 for "no deadline". Written by - * us_quic_loop_process from loop_post; read by Bun's getTimeout() to - * bound the epoll_pwait2 timeout. No timerfd, no scheduling syscall — - * the gap between loop_post and getTimeout is sub-µs so storing the - * relative diff is precise enough. */ + struct us_quic_driver_s *quic_head; + int quic_walk_depth; + /* µs until a driver wants its next pass, or -1. us_quic_loop_process writes it, and us_loop_run_bun_tick bounds the poll timeout with it right after loop_pre. */ long long quic_next_tick_us; - /* node:quic endpoints on this loop. us_nq_loop_flush_if_pending walks it - * from loop_pre/loop_post and the microtask drain, running each endpoint's - * full process pass only when it flagged pending work -- one engine pass - * per loop turn instead of one per native call. */ - struct us_nq_driver_s *nq_head; #ifdef LIBUS_USE_LIBUV /* A fallthrough us_timer_t armed to quic_next_tick_us so the uv loop wakes * for lsquic's time-driven state. POSIX folds the deadline into the diff --git a/packages/bun-usockets/src/internal/networking/bsd.h b/packages/bun-usockets/src/internal/networking/bsd.h index e269a5ca81c5..5cb0aab34b3e 100644 --- a/packages/bun-usockets/src/internal/networking/bsd.h +++ b/packages/bun-usockets/src/internal/networking/bsd.h @@ -33,7 +33,6 @@ #include #include #pragma comment(lib, "ws2_32.lib") -#define SETSOCKOPT_PTR_TYPE const char * #define LIBUS_SOCKET_ERROR INVALID_SOCKET #else /* POSIX */ #ifndef _GNU_SOURCE @@ -42,7 +41,6 @@ /* For socklen_t */ #include #include -#define SETSOCKOPT_PTR_TYPE int * #define LIBUS_SOCKET_ERROR -1 #endif @@ -189,12 +187,22 @@ 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); void bsd_socket_flush(LIBUS_SOCKET_DESCRIPTOR fd); LIBUS_SOCKET_DESCRIPTOR bsd_create_socket(int domain, int type, int protocol, int *err); +/* Takes ownership of a descriptor opened elsewhere without touching it. EINVAL: not a socket of `type` (0 allows anything). EEXIST, not on Windows: a socket in this process owns it. */ +int bsd_adopt_socket(LIBUS_SOCKET_DESCRIPTOR fd, int type); +/* Gives up ownership without closing. */ +void bsd_disown_socket(LIBUS_SOCKET_DESCRIPTOR fd); +#ifdef _WIN32 +/* Ends the sending side of every socket this process made and shared with no other, on any thread's loop. For the exit of the process, where Winsock resets what is still open. */ +void bsd_shutdown_owned_sockets(void); +#endif void bsd_close_socket(LIBUS_SOCKET_DESCRIPTOR fd); void bsd_shutdown_socket(LIBUS_SOCKET_DESCRIPTOR fd); void bsd_shutdown_socket_read(LIBUS_SOCKET_DESCRIPTOR fd); @@ -209,8 +217,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 @@ -231,18 +239,21 @@ 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); +LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket_unix(const char *path, size_t pathlen, int* error); +/* Binds without listening: node:cluster's primary binds the path its workers listen on. */ +LIBUS_SOCKET_DESCRIPTOR bsd_create_bound_socket_unix(const char *path, size_t pathlen, int* error); /* Creates an UDP socket bound to the hostname and port */ LIBUS_SOCKET_DESCRIPTOR bsd_create_udp_socket(const char *host, int port, int options, int *err); int bsd_connect_udp_socket(LIBUS_SOCKET_DESCRIPTOR fd, const char *host, int port); 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(struct sockaddr_storage *addr, struct sockaddr_storage *local_addr); -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 *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..29e199a6a239 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -96,13 +96,12 @@ /* Small 16KB shared send buffer for UDP packet metadata */ #define LIBUS_SEND_BUFFER_LENGTH (1 << 14) -/* A timeout granularity of 4 seconds means give or take 4 seconds from set timeout */ +/* Seconds between two timeout sweeps */ #define LIBUS_TIMEOUT_GRANULARITY 4 /* 32 byte padding of receive buffer ends */ #define LIBUS_RECV_BUFFER_PADDING 32 /* Guaranteed alignment of extension memory */ #define LIBUS_EXT_ALIGNMENT 16 -#define ALLOW_SERVER_RENEGOTIATION 0 /* close() codes — two orthogonal bits collapsed into three states: * 0 graceful: TLS sends close_notify and DEFERS the fd close until the @@ -151,7 +150,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, @@ -202,16 +202,8 @@ int us_udp_socket_bound_port(struct us_udp_socket_t *s); /* Peeks peer addr (sockaddr) of received packet */ char *us_udp_packet_buffer_peer(struct us_udp_packet_buffer_t *buf, int index); -/* Receives a set of packets into specified packet buffer */ -int us_udp_socket_receive(struct us_udp_socket_t *s, struct us_udp_packet_buffer_t *buf); - -void us_udp_buffer_set_packet_payload(struct us_udp_packet_buffer_t *send_buf, int index, int offset, void *payload, int length, void *peer_addr); - int us_udp_socket_send(struct us_udp_socket_t *s, void** payloads, size_t* lengths, void** addresses, int num); -/* Allocates a packet buffer that is reuable per thread. Mutated by us_udp_socket_receive. */ -struct us_udp_packet_buffer_t *us_create_udp_packet_buffer(); - /* Creates a (heavy-weight) UDP socket with a user space ring buffer. Again, this one is heavy weight and * shoud be reused. One entire QUIC server can be implemented using only one single UDP socket so weight * is not a concern as is the case for TCP sockets which are 1-to-1 with TCP connections. */ @@ -242,9 +234,6 @@ struct us_udp_socket_t *us_create_udp_socket_from_fd(us_loop_r loop, void (*data /* This one is ugly, should be ext! not user */ void *us_udp_socket_user(struct us_udp_socket_t *s); -/* Binds the UDP socket to an interface and port */ -int us_udp_socket_bind(struct us_udp_socket_t *s, const char *hostname, unsigned int port); - /* Public interfaces for timers. libuv (Windows) only — epoll/kqueue schedules * on bun.JSC.EventLoopTimer, no file descriptor or syscall. */ #ifdef _WIN32 @@ -305,7 +294,6 @@ struct us_socket_vtable_t { struct us_socket_t *(*on_writable)(us_socket_r); struct us_socket_t *(*on_close)(us_socket_r, int code, void *reason); struct us_socket_t *(*on_timeout)(us_socket_r); - struct us_socket_t *(*on_long_timeout)(us_socket_r); struct us_socket_t *(*on_end)(us_socket_r); struct us_socket_t *(*on_connect_error)(us_socket_r, int code); struct us_connecting_socket_t *(*on_connecting_error)(struct us_connecting_socket_t *, int code); @@ -323,13 +311,10 @@ struct us_socket_group_t { struct us_listen_socket_t *head_listen_sockets; struct us_socket_t *iterator; struct us_socket_group_t *prev, *next; - uint32_t global_tick; /* Sockets currently parked in loop->data.low_prio_head with s->group == this. * They are NOT in head_sockets while queued, so close_all/deinit must * account for them separately. */ uint16_t low_prio_count; - unsigned char timestamp; - unsigned char long_timestamp; unsigned char linked; }; @@ -350,19 +335,18 @@ void us_socket_group_deinit(us_socket_group_r group) nonnull_fn_decl; void us_socket_group_close_all(us_socket_group_r group) nonnull_fn_decl; /* As above; `also_listeners=0` leaves head_listen_sockets alone (process-exit * teardown — listen sockets are owned by a Listener/App that frees them in - * finalize, closing here would be a UAF on the owner's raw pointer). */ -void us_socket_group_close_all_ex(us_socket_group_r group, int also_listeners) nonnull_fn_decl; + * finalize, closing here would be a UAF on the owner's raw pointer). + * `thread_ends`: a TLS socket sends no close_notify and ends with a FIN, as at the exit of a process. Otherwise it is reset, after a close_notify unless its last write fell short. */ +void us_socket_group_close_all_ex(us_socket_group_r group, int also_listeners, int thread_ends) nonnull_fn_decl; -/* Teardown: close_all_ex(g, 0) on every group currently linked to the loop +/* Teardown: close_all_ex(g, 0, 1) on every group currently linked to the loop * (Listener-, App-, and RareData-owned alike). Returns 1 if any group had open * connections, 0 if the loop was already idle. Listen sockets are NOT closed * (see close_all_ex). */ int us_loop_close_all_groups(us_loop_r loop) nonnull_fn_decl; -unsigned short us_socket_group_timestamp(us_socket_group_r group) nonnull_fn_decl; struct us_loop_t *us_socket_group_loop(us_socket_group_r group) nonnull_fn_decl __attribute((returns_nonnull)); void *us_socket_group_ext(us_socket_group_r group) nonnull_fn_decl; -struct us_socket_group_t *us_socket_group_next(us_socket_group_r group) nonnull_fn_decl; /* Move an open socket between groups / kinds, optionally resizing its ext. * Replaces us_socket_context_adopt_socket + us_create_child_socket_context. @@ -386,11 +370,15 @@ struct us_socket_t *us_socket_tls_feed(us_socket_r s, const char *data, int leng /* Send ClientHello after adopt_tls. Separate so the caller can repoint the * ext slot before any dispatch can fire. */ void us_socket_start_tls_handshake(us_socket_r s) nonnull_fn_decl; -/* Client TLS socket whose rejectUnauthorized policy is on: refuse a bad chain +/* Client whose rejectUnauthorized policy is on: refuse a bad chain * during the handshake, so the client's own Certificate flight never reaches a * server that fails verification. Must run before the handshake is driven * (on_open, or between adopt_tls and start_tls_handshake). No-op otherwise. */ -void us_socket_set_inline_reject(us_socket_r s) nonnull_fn_decl; +void us_ssl_set_inline_reject(struct ssl_st *ssl); +/* 1 once that client's verify step has failed. Whoever drives `ssl` then drops its output and fails the handshake. */ +int us_ssl_inline_reject_tripped(struct ssl_st *ssl); +/* requestCert / rejectUnauthorized as BoringSSL's verify mode. A failed chain never fails the handshake: the owner reads the verdict after it. */ +void us_ssl_set_verify_mode(struct ssl_st *ssl, int request_cert, int reject_unauthorized); /* For an owner that refuses the peer in its handshake callback: drops the flight held across that callback. */ void us_socket_release_held_flight(us_socket_r s) nonnull_fn_decl; /* Call it from on_open. A shutdown before the first handshake step sends its FIN after that step. */ @@ -399,11 +387,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) @@ -416,12 +409,10 @@ void us_listen_socket_close(struct us_listen_socket_t *ls) nonnull_fn_decl; /* SNI: the names outlive us_listen_socket_close() for the connections already * accepted. ssl_ctx is up_ref'd. The last registration of a name replaces the - * earlier one. */ + * earlier one. With own_client_auth a connection that names it adds ssl_ctx's verify + * mode to the listener's (Bun.serve). Without, it keeps the listener's (node:tls). */ void us_listen_socket_add_server_name(struct us_listen_socket_t *ls, - const char *hostname_pattern, struct ssl_ctx_st *ssl_ctx) nonnull_fn_decl; -/* Returns an owned reference; the caller must release it. */ -struct ssl_ctx_st *us_listen_socket_find_server_name_ctx(struct us_listen_socket_t *ls, - const char *hostname_pattern) nonnull_fn_decl; + const char *hostname_pattern, struct ssl_ctx_st *ssl_ctx, int own_client_auth) nonnull_fn_decl; /* tls.Server#setSecureContext(): swap the default SSL_CTX used for NEWLY * accepted sockets (SNI-selected contexts are untouched). Up_refs ctx; live * connections keep the previous context alive through their own SSL refs. */ @@ -432,27 +423,32 @@ 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`. */ -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 * set abort_handshake = 2). `ctx` may be NULL (use the default context); the * call consumes the reference. `error` != 0 aborts the handshake. Safe to call * after the socket closed (no-op). */ void us_socket_sni_resolve(us_socket_r s, struct ssl_ctx_st *ctx, int error); -/* Records a per-serverName entry's client-certificate policy on its SSL_CTX - * so the SNI switch adds it to the connection's inherited one, and gives the - * context its own session-id context so sessions from other contexts are not - * resumed under it. Contexts without one keep the server-level policy. */ -void us_ssl_ctx_set_sni_policy(struct ssl_ctx_st *ctx, int request_cert, - int reject_unauthorized); -/* 1 iff the SNI-selected context for this connection demands closing on a - * client-certificate verification error. */ -int us_socket_server_name_reject_unauthorized(us_socket_r s); +/* For a context that didn't get the digest of its options as session id context (create_ssl_context_with_digest). */ +void us_ssl_ctx_set_own_session_id_context(struct ssl_ctx_st *ctx); +/* 1 iff a client-certificate verification error has to close the connection: request_cert and reject_unauthorized. */ +int us_socket_rejects_unauthorized(us_socket_r s); 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); +/* The resolver of one server-side SSL, whose Bun socket has a `serverName` handler: us_dispatch_server_name then resolves. */ +void us_ssl_on_server_name(struct ssl_st *ssl); +/* us_socket_sni_resolve for whoever else drives `ssl`. Returns 1 if the handshake waited for the answer: drive it again. */ +int us_ssl_sni_resolve(struct ssl_st *ssl, struct ssl_ctx_st *ctx, int error); +/* How many bytes may wait behind the ClientHello of a suspended handshake. A peer that sends more is dropped. */ +unsigned int us_ssl_sni_held_max(void); +/* 1 while the handshake is suspended for an asynchronous SNICallback. */ +int us_ssl_sni_waiting(const struct ssl_st *ssl); +/* 1 once the resolver refused the name: the connection is dropped without a TLS alert. */ +int us_ssl_sni_refused(const struct ssl_st *ssl); +/* 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 @@ -471,8 +467,8 @@ struct us_socket_t *us_socket_group_connect_unix(us_socket_group_r group, int us_socket_is_established(us_socket_r s) nonnull_fn_decl; void us_connecting_socket_free(struct us_connecting_socket_t *c) nonnull_fn_decl; void us_connecting_socket_close(struct us_connecting_socket_t *c) nonnull_fn_decl; +/* us_socket_timeout for every address this connect tries. Nothing fires while the name is still being resolved. */ void us_connecting_socket_timeout(struct us_connecting_socket_t *c, unsigned int seconds) nonnull_fn_decl; -void us_connecting_socket_long_timeout(struct us_connecting_socket_t *c, unsigned int minutes) nonnull_fn_decl; void us_connecting_socket_shutdown(struct us_connecting_socket_t *c) nonnull_fn_decl; void us_connecting_socket_shutdown_read(struct us_connecting_socket_t *c) nonnull_fn_decl; int us_connecting_socket_is_shut_down(struct us_connecting_socket_t *c) nonnull_fn_decl; @@ -486,12 +482,8 @@ void *us_connecting_socket_get_native_handle(struct us_connecting_socket_t *c) n struct us_socket_group_t *us_connecting_socket_group(struct us_connecting_socket_t *c) nonnull_fn_decl; unsigned char us_connecting_socket_kind(struct us_connecting_socket_t *c) nonnull_fn_decl; -struct us_bun_verify_error_t us_socket_verify_error(struct us_socket_t *s); - -/* SNI hostname the peer sent in its ClientHello (server-side TLS sockets), or - * NULL when the socket is not TLS or no SNI extension was sent. The returned - * pointer is owned by the SSL object and only valid while the socket lives. */ -const char *us_socket_sni_servername(struct us_socket_t *s); +/* The X509 verdict on the peer's chain, as it stands now. */ +struct us_bun_verify_error_t us_ssl_socket_verify_error_from_ssl(struct ssl_st *ssl); /* ── SSL_CTX construction ───────────────────────────────────────────────── * The expensive bit (cert/key/CA parse, cipher list, DH params) is decoupled @@ -527,6 +519,8 @@ struct us_bun_socket_context_options_t { unsigned int crl_count; /* Sets X509_V_FLAG_PARTIAL_CHAIN on the context's certificate store. */ int allow_partial_trust_chain; + /* `ca` is the process-wide default list: trusted, but not named in a CertificateRequest. */ + int ca_is_default; /* Colon-separated signature algorithm list applied via * SSL_CTX_set1_sigalgs_list. */ const char *sigalgs; @@ -563,27 +557,37 @@ struct ssl_ctx_st *us_ssl_ctx_from_options( * (uWS App.h) that don't pull in BoringSSL headers. */ void us_internal_ssl_ctx_up_ref(struct ssl_ctx_st *ssl_ctx); void us_internal_ssl_ctx_unref(struct ssl_ctx_st *ssl_ctx); -/* Install an ALPN selector that prefers "h2", then "http/1.1" (when - * allow_http1). Used by uWS when an App has an HTTP/2 context attached. */ -void us_ssl_ctx_enable_http2_alpn(struct ssl_ctx_st *ssl_ctx, int allow_http1); +/* ALPN for the connections `ls` accepts. `protocols` is entries of (u8 length, bytes) in the server's order of preference, copied. + * A client that offers ALPN but none of them goes on without it, or gets no_application_protocol if `required`. */ +void us_listen_socket_set_alpn(struct us_listen_socket_t *ls, const unsigned char *protocols, unsigned int length, int required) nonnull_fn_decl; /* 1 iff the completed handshake on `s` negotiated ALPN "h2". */ int us_socket_alpn_is_h2(us_socket_r s); long us_ssl_ctx_live_count(void); /* Appends the certificates in the PEM `content` to `ctx`'s trust store; * returns 0 when nothing could be added. */ int us_ssl_ctx_add_ca_cert(struct ssl_ctx_st *ctx, const char *content); -/* The client half of us_internal_ssl_attach, for an SSL that no us_socket_t drives. */ -void us_internal_ssl_client_verify_defaults(struct ssl_st *ssl, struct ssl_ctx_st *ctx); +/* SSL_new and the client or server setup of us_internal_ssl_attach, for an SSL that no us_socket_t drives. */ +struct ssl_st *us_ssl_new(struct ssl_ctx_st *ctx, int is_client); +/* Answers SSL_ERROR_WANT_RENEGOTIATE within the context's budget. 0 if refused: whoever drives `ssl` then fails the session. */ +int us_ssl_renegotiate(struct ssl_st *ssl); +struct us_bun_verify_error_t us_ssl_protocol_error(const char *reason); +struct us_bun_verify_error_t us_ssl_peer_disconnected_error(void); /* 1 when the verify step of this handshake asked the owner for the server's name. */ int us_ssl_identity_checked(struct ssl_st *ssl); /* Why the SSL_* call that just returned failed, NUL-terminated. Clears the thread's queue. 0 when nothing is queued. */ int us_ssl_take_error_reason(char *reason, size_t length); -/* For an SSL that no us_socket_t drives: its callbacks go to `wrapper`, which must outlive `ssl`. */ -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); -/* 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 *)); +/* The Bun.connect / Bun.listen socket that `ssl` belongs to: it gets the 'session' and 'keylog' events. NULL takes it back. */ +void us_ssl_set_bun_socket(struct ssl_st *ssl, void *bun_socket); +void *us_ssl_bun_socket(const struct ssl_st *ssl); +/* Hands the sessions and keylog lines that BoringSSL delivered inside an SSL_* call to that socket. Runs JS, which may free `ssl`. */ +void us_ssl_flush_pending_events(struct ssl_st *ssl); +/* The session a Bun socket's SSL got last, borrowed. NULL if none. */ +struct ssl_session_st *us_ssl_latest_session(const struct ssl_st *ssl); +/* For a client SSL that no us_socket_t drives: `check(owner, ssl)` gives the US_IDENTITY_* verdict of the verify step. `owner` must outlive `ssl`. */ +void us_ssl_set_identity_check(struct ssl_st *ssl, int (*check)(void *owner, struct ssl_st *ssl), void *owner); +/* `on_session(sink, session)` runs inside SSL_read/SSL_do_handshake, so it must not run JS. `on_free(sink)` runs once, on SSL_free. */ +struct ssl_session_st; +void us_socket_set_session_sink(us_socket_r s, void *sink, void (*on_session)(void *, struct ssl_session_st *), void (*on_free)(void *)); void *us_socket_session_sink(us_socket_r s); /* Public interfaces for loops */ @@ -671,8 +675,8 @@ int us_socket_raw_write(us_socket_r s, const char *data, int length); * its ciphertext, or EPROTO for a failed SSL_write, once its handshake was reported. */ int us_socket_write_check_error(us_socket_r s, const char *data, int length, int *fatal_write_error); +/* on_timeout fires once, at the first sweep after `seconds` have passed. Takes any number of seconds, and 0 disarms. */ void us_socket_timeout(us_socket_r s, unsigned int seconds) nonnull_fn_decl; -void us_socket_long_timeout(us_socket_r s, unsigned int minutes) nonnull_fn_decl; void *us_socket_ext(us_socket_r s) nonnull_fn_decl; void *us_connecting_socket_ext(struct us_connecting_socket_t *c) nonnull_fn_decl; @@ -700,12 +704,9 @@ int us_socket_is_closed(us_socket_r s) nonnull_fn_decl; * from one the peer has already written to or closed. */ int us_socket_queued_input(us_socket_r s) nonnull_fn_decl; -/* TLS sockets only: nothing sets the bit up for a plain one. The kernel rejected a send of TLS records, so the error - * code of the close that follows stands for the lost records, not for a failed read. */ -int us_socket_ssl_send_rejected(us_socket_r s) nonnull_fn_decl; +/* Why the kernel rejected a send for good (LIBUS_ERR numbering), or 0. The socket reads on, then closes by itself with ECONNRESET. */ +int us_socket_rejected_send_error(us_socket_r s) nonnull_fn_decl; -int us_socket_is_ssl_handshake_finished(us_socket_r s) nonnull_fn_decl; -int us_socket_ssl_handshake_callback_has_fired(us_socket_r s) nonnull_fn_decl; /* TLS ciphertext bytes already sealed for this socket and reported as * written by us_socket_write(), still waiting on a writable event to reach * the kernel (the connection's spill). 0 for @@ -717,6 +718,10 @@ int us_socket_ssl_spill_dropped(us_socket_r s) nonnull_fn_decl; struct us_socket_t *us_socket_close(us_socket_r s, int code, void *reason) __attribute__((nonnull(1))); /* Closes before it returns. A TLS socket first sends what the kernel takes now: its unsent ciphertext, then close_notify. */ struct us_socket_t *us_socket_close_now(us_socket_r s) nonnull_fn_decl; +/* How long a socket whose owner let go waits for its peer: to answer the FIN, or over TLS to take the unsent ciphertext and then to answer the close_notify. */ +#define LIBUS_LINGER_SECONDS 10 +/* For an owner that has sent its last byte: sends the FIN, drops input, and closes at the peer's FIN or after LIBUS_LINGER_SECONDS. close(2) over input that is unread or still arrives would reset the connection, and a reset can take what the peer hasn't read yet. on_close is the only event the owner still gets. */ +struct us_socket_t *us_socket_close_lingering(us_socket_r s) nonnull_fn_decl; int us_socket_local_port(us_socket_r s) nonnull_fn_decl; int us_socket_remote_port(us_socket_r s) nonnull_fn_decl; @@ -724,11 +729,11 @@ 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; -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; +/* 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; +/* 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. */ @@ -739,14 +744,11 @@ LIBUS_SOCKET_DESCRIPTOR us_socket_get_fd(us_socket_r s) nonnull_fn_decl; int us_socket_buffer_size(us_socket_r s, int is_recv, int size, int *out) nonnull_fn_decl; /* Bun extras */ -struct us_socket_t *us_socket_pair(us_socket_group_r group, unsigned char kind, int socket_ext_size, LIBUS_SOCKET_DESCRIPTOR *fds) nonnull_fn_decl; struct us_socket_t *us_socket_from_fd(us_socket_group_r group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, int socket_ext_size, LIBUS_SOCKET_DESCRIPTOR fd, int options, int ipc) __attribute__((nonnull(1))); /* ssl_ctx nullable */ -struct us_socket_t *us_socket_open(struct us_socket_t *s, int is_client, char *ip, int ip_length); /* The bundled Mozilla root certificates, DER-encoded, in static memory. Returns the count. */ size_t us_bundled_root_certs_der(const uint8_t *const **out_certs, const size_t **out_lens); -unsigned int us_get_remote_address_info(char *buf, us_socket_r s, const char **dest, int *port, int *is_ipv6); -unsigned int us_get_local_address_info(char *buf, us_socket_r s, const char **dest, int *port, int *is_ipv6); +unsigned int us_get_remote_address_info(char *buf, us_socket_r s, int *port); int us_socket_get_error(us_socket_r s); /* A writable event's write made zero progress: does that prove the peer is * gone? On epoll/kqueue a writable event implies real send-buffer space, so @@ -756,6 +758,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 1094da9d2c2e..168caf5c806a 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -55,10 +55,17 @@ void sweep_timer_cb(struct us_internal_callback_t *cb); // when the sweep timer is disabled, we don't need to do anything void sweep_timer_noop(struct us_timer_t *timer) {} +/* The coarse reading comes first, so the next sweep is no less than a period after it. */ +static void us_internal_restart_sweep_timer(struct us_loop_t *loop) { + loop->data.sweep_time_ms = us_internal_coarse_ms(); + us_timer_again(loop->data.sweep_timer); +} + void us_internal_enable_sweep_timer(struct us_loop_t *loop) { loop->data.sweep_timer_count++; if (loop->data.sweep_timer_count == 1) { us_timer_set(loop->data.sweep_timer, (void (*)(struct us_timer_t *)) sweep_timer_cb, LIBUS_TIMEOUT_GRANULARITY * 1000, LIBUS_TIMEOUT_GRANULARITY * 1000); + us_internal_restart_sweep_timer(loop); Bun__internal_ensureDateHeaderTimerIsEnabled(loop); } } @@ -80,10 +87,16 @@ uint64_t us_internal_monotonic_ns(void) { return (uint64_t) ts.tv_sec * 1000000000ULL + (uint64_t) ts.tv_nsec; } +/* The coarse reading comes first, so the next sweep is no less than a period after it. */ +static void us_internal_restart_sweep_timer(struct us_loop_t *loop) { + loop->data.sweep_time_ms = us_internal_coarse_ms(); + loop->data.sweep_next_tick_ns = (long long) us_internal_monotonic_ns() + LIBUS_TIMEOUT_GRANULARITY_NS; +} + void us_internal_enable_sweep_timer(struct us_loop_t *loop) { loop->data.sweep_timer_count++; if (loop->data.sweep_timer_count == 1) { - loop->data.sweep_next_tick_ns = (long long) us_internal_monotonic_ns() + LIBUS_TIMEOUT_GRANULARITY_NS; + us_internal_restart_sweep_timer(loop); Bun__internal_ensureDateHeaderTimerIsEnabled(loop); } } @@ -106,15 +119,9 @@ long long us_internal_sweep_timeout_ns(struct us_loop_t *loop) { } void us_internal_sweep_if_due(struct us_loop_t *loop) { - if (loop->data.sweep_next_tick_ns < 0) { + if (loop->data.sweep_next_tick_ns < 0 || (long long) us_internal_monotonic_ns() < loop->data.sweep_next_tick_ns) { return; } - long long now = (long long) us_internal_monotonic_ns(); - if (now < loop->data.sweep_next_tick_ns) { - return; - } - /* Re-arm first: a timeout handler may unlink the last socket and disarm. */ - loop->data.sweep_next_tick_ns = now + LIBUS_TIMEOUT_GRANULARITY_NS; us_internal_timer_sweep(loop); } @@ -226,7 +233,7 @@ int us_loop_close_all_groups(struct us_loop_t *loop) { * runtime's stop phase has already stopped those owners before this sweep, * and closing a listen socket from under one that was not would be a UAF. */ if (g->head_sockets || g->head_connecting_sockets || g->low_prio_count) { - us_socket_group_close_all_ex(g, /* also_listeners */ 0); + us_socket_group_close_all_ex(g, /* also_listeners */ 0, /* thread_ends */ 1); any = 1; } /* close_all → unlink may have spliced our cached `next` out too (an @@ -238,27 +245,30 @@ int us_loop_close_all_groups(struct us_loop_t *loop) { return any; } +/* 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; + /* The next sweep is a whole period after this one. First, because a timeout handler may unlink the last socket and disarm. */ + if (loop_data->sweep_timer_count) { + us_internal_restart_sweep_timer(loop); + } + const uint32_t tick = ++loop_data->sweep_tick; /* For all socket groups in this loop */ loop_data->iterator = loop_data->head; while (loop_data->iterator) { struct us_socket_group_t *group = loop_data->iterator; - /* Update this group's timestamps (this could be moved to loop and done once) */ - group->global_tick++; - unsigned char short_ticks = group->timestamp = group->global_tick % 240; - unsigned char long_ticks = group->long_timestamp = (group->global_tick / 15) % 240; - /* Begin at head */ struct us_socket_t *s = group->head_sockets; while (s) { /* Seek until end or timeout found (tightest loop) */ while (1) { /* We only read from 1 random cache line here */ - if (short_ticks == s->timeout || long_ticks == s->long_timeout) { + if (tick >= s->timeout) { break; } @@ -271,10 +281,9 @@ void us_internal_timer_sweep(struct us_loop_t *loop) { /* Here we have a timeout to emit (slow path) */ group->iterator = s; - if (short_ticks == s->timeout) { - s->timeout = 255; - s->ssl ? us_internal_ssl_on_timeout(s) : us_dispatch_timeout(s); - } + s->timeout = LIBUS_NO_TIMEOUT; + if (s->lingering) us_internal_socket_close_raw(s, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, NULL); + else 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 * and did it anyway: loop_data->iterator would have been advanced past @@ -282,12 +291,6 @@ void us_internal_timer_sweep(struct us_loop_t *loop) { * must not touch it again. */ if (loop_data->iterator != group) goto outer_continue; - if (group->iterator == s && long_ticks == s->long_timeout) { - s->long_timeout = 255; - us_dispatch_long_timeout(s); - } - if (loop_data->iterator != group) goto outer_continue; - /* Check for unlink / link (if the event handler did not modify the chain, we step 1) */ if (s == group->iterator) { s = s->next; @@ -304,26 +307,22 @@ 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: push each one back a tick per sweep, for at most + * MAX_LOW_PRIO_TICKS per us_socket_timeout() call. 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 (!s->ssl_close_after_spill && s->low_prio_ticks < MAX_LOW_PRIO_TICKS) { + s->low_prio_ticks++; + if (s->timeout != LIBUS_NO_TIMEOUT) { + s->timeout++; + } + } else if (tick >= s->timeout) { + s->timeout = LIBUS_NO_TIMEOUT; + us_internal_ssl_on_timeout(s); if (loop_data->low_prio_iterator != s) continue; } loop_data->low_prio_iterator = s->next; @@ -332,9 +331,17 @@ 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. The bound lets timers and other sockets run between batches. 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; @@ -351,10 +358,12 @@ void us_internal_handle_low_priority_sockets(struct us_loop_t *loop) { if(us_socket_is_closed(s)) { s->flags.low_prio_state = 2; + us_internal_disable_sweep_timer(loop); continue; } us_internal_socket_group_link_socket(s->group, s); + us_internal_disable_sweep_timer(loop); if (s->flags.is_paused) { /* us_socket_pause found the reads already off and only set the flag. Hand back an * ordinary paused socket: us_socket_resume arms the reads, and the readable dispatch @@ -388,7 +397,7 @@ void us_internal_dns_callback_threadsafe(struct us_connecting_socket_t *c, void* us_wakeup_loop(loop); } -void us_internal_drain_pending_dns_resolve(struct us_loop_t *loop, struct us_connecting_socket_t *s) { +static void us_internal_drain_pending_dns_resolve(struct us_connecting_socket_t *s) { while (s) { struct us_connecting_socket_t *next = s->next; us_internal_socket_after_resolve(s); @@ -401,7 +410,7 @@ int us_internal_handle_dns_results(struct us_loop_t *loop) { struct us_connecting_socket_t *s = loop->data.dns_ready_head; loop->data.dns_ready_head = NULL; Bun__unlock(&loop->data.mutex); - us_internal_drain_pending_dns_resolve(loop, s); + us_internal_drain_pending_dns_resolve(s); return s != NULL; } @@ -469,7 +478,10 @@ void us_internal_free_closed_sockets(struct us_loop_t *loop) { #ifdef LIBUS_USE_LIBUV void sweep_timer_cb(struct us_internal_callback_t *cb) { - us_internal_timer_sweep(cb->loop); + /* libuv's timer keeps running without sockets (us_timer_set), and a sweep then would break what us_internal_timeout_tick counts on. */ + if (cb->loop->data.sweep_timer_count) { + us_internal_timer_sweep(cb->loop); + } } #endif @@ -483,18 +495,15 @@ void us_internal_loop_pre(struct us_loop_t *loop) { /* Flush stream writes that JS tasks made before this tick (timers, * immediates, promise resolutions outside on_read) so they go out * before epoll blocks. loop_post handles what this iteration receives. */ - if (loop->data.quic_head) us_quic_loop_process(loop); + if (loop->data.quic_head) us_quic_loop_process(loop, US_QUIC_BEFORE_POLL); #endif - /* Same corking as the QUIC block above, for node:quic's engines. */ - if (loop->data.nq_head) us_nq_loop_flush_if_pending(loop); } void us_internal_loop_post(struct us_loop_t *loop) { us_internal_handle_dns_results(loop); #ifdef LIBUS_USE_QUIC - if (loop->data.quic_head) us_quic_loop_process(loop); + if (loop->data.quic_head) us_quic_loop_process(loop, US_QUIC_AFTER_POLL); #endif - if (loop->data.nq_head) us_nq_loop_flush_if_pending(loop); /* Runs at every tick depth because a nested tick may be waiting for this close. */ us_internal_close_unresumable_sockets(loop); /* A poll callback may re-enter the loop (e.g. expect().toThrow() → @@ -508,12 +517,6 @@ void us_internal_loop_post(struct us_loop_t *loop) { loop->data.post_cb(loop); } -#ifdef WIN32 -#define us_ioctl ioctlsocket -#else -#define us_ioctl ioctl -#endif - void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, int events) { switch (us_internal_poll_type(p)) { case POLL_TYPE_CALLBACK: { @@ -525,6 +528,12 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in us_internal_accept_poll_event(p); #endif } + #ifdef LIBUS_USE_EPOLL + /* This event is the signal of every wakeup counted so far (the eventfd is edge-triggered), so the next tick has none of them to expect. */ + if ((struct us_internal_async *) cb == cb->loop->data.wakeup_async) { + __atomic_store_n(&cb->loop->pending_wakeups, 0, __ATOMIC_RELAXED); + } + #endif cb->cb(cb->cb_expects_the_loop ? (struct us_internal_callback_t *) cb->loop : (struct us_internal_callback_t *) &cb->p); break; } @@ -537,16 +546,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 +565,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 +573,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); @@ -579,32 +588,19 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in struct us_socket_t *s = (struct us_socket_t *) accepted_p; - s->group = accept_group; - s->kind = listen_socket->accept_kind; - s->ssl = NULL; - s->connect_state = NULL; - s->timeout = 255; - s->long_timeout = 255; - s->flags.low_prio_state = 0; - s->flags.allow_half_open = listen_socket->s.flags.allow_half_open; + us_internal_socket_init(s, accept_group, listen_socket->accept_kind, listen_socket->s.flags.allow_half_open); s->flags.is_paused = listen_socket->accept_paused; - s->flags.is_ipc = 0; - s->flags.is_closed = 0; - s->flags.adopted = 0; - s->flags.last_write_failed = 0; - s->unclassified_send_failures = 0; - s->read_eof = 0; - s->hangup_closes_unsent = 0; - s->send_rejected = 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 +611,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 +624,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; @@ -643,7 +642,7 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in /* Set once recv() returns 0 below: the only proof that the peer's stream ended with a FIN. The * eof hint this dispatch was called with (EPOLLHUP, kqueue's EV_EOF) also rides on a reset. */ int read_fin = 0; - if (events & LIBUS_SOCKET_WRITABLE && !error && !s->send_rejected) { + if (events & LIBUS_SOCKET_WRITABLE && !error) { s->flags.last_write_failed = 0; #ifdef LIBUS_USE_KQUEUE /* Kqueue EVFILT_WRITE is one-shot so the filter is removed after delivery. @@ -654,16 +653,23 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in p->state.poll_type = us_internal_poll_type(p) | (p->state.poll_type & POLL_TYPE_POLLING_IN); #endif - s = s->ssl ? us_internal_ssl_on_writable(s) : us_dispatch_writable(s); - /* After socket adoption, track the new socket; the old one becomes invalid */ - s = us_internal_socket_follow_adopted(s); + /* Past its FIN a socket polls for writable only when a rejected send asked for this event. */ + if (!s->send_rejected) { + s = s->ssl ? us_internal_ssl_on_writable(s) : us_dispatch_writable(s); + /* After socket adoption, track the new socket; the old one becomes invalid */ + s = us_internal_socket_follow_adopted(s); + } + /* A send rejected in that dispatch gets no event of its own: the writable interest of a shut-down socket is dropped below. */ + if (s && s->send_rejected && !us_socket_is_closed(s)) { + s = us_internal_socket_close_after_rejected_send(s); + } if (!s || us_socket_is_closed(s)) { return; } - /* If we have no failed write or if we shut down, then stop polling for more writable */ - if (!s->flags.last_write_failed || us_socket_is_shut_down(s)) { + /* If we have no failed write or if we shut down, then stop polling for more writable. A spilled close_notify still has to leave. */ + if (!s->flags.last_write_failed || (us_socket_is_shut_down(s) && !s->ssl_spilled)) { us_poll_change(&s->p, loop, us_poll_events(&s->p) & LIBUS_SOCKET_READABLE); } else { #ifdef LIBUS_USE_KQUEUE @@ -671,6 +677,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 @@ -719,6 +730,8 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in * maybe_unlink() inside it still sees the group as * non-empty. */ g->low_prio_count++; + /* The queue's hold on the sweep timer (see sweep_timer_count). Before the group's goes, so the count doesn't pass through 0. */ + us_internal_enable_sweep_timer(loop); us_internal_socket_group_unlink_socket(g, s); /* Link this socket to the low-priority queue - we use a LIFO queue, to prioritize newer clients that are @@ -749,7 +762,8 @@ 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. The buffer has room for more, so that extra ones are received and can be closed. */ + 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 +788,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)); @@ -808,7 +828,7 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in #endif if (length > 0) { - s = s->ssl ? us_internal_ssl_on_data(s, loop->data.recv_buf + LIBUS_RECV_BUFFER_PADDING, length) + s = s->lingering ? s : s->ssl ? us_internal_ssl_on_data(s, loop->data.recv_buf + LIBUS_RECV_BUFFER_PADDING, length) : us_dispatch_data(s, loop->data.recv_buf + LIBUS_RECV_BUFFER_PADDING, length); /* After socket adoption, track the new socket; the old one becomes invalid */ s = us_internal_socket_follow_adopted(s); @@ -912,12 +932,6 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in } while (s); } - /* No write may close under its caller, so the dispatch after a rejected send does. */ - if (s && s->send_rejected && !us_socket_is_closed(s)) { - us_internal_socket_close_raw(s, LIBUS_ECONNRESET, NULL); - return; - } - /* The EOF hint (kqueue EV_EOF riding the final data's readable event, * epoll's EPOLLHUP once both directions are down, AFD DISCONNECT on a * shut-down socket) can arrive with the tail of the peer's stream still @@ -929,11 +943,11 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in * read_eof is set there is nothing left to drain and deferring would only * lose the close. Error-flagged events keep the error path. A hangup * takes the write side down too, so a hangup_closes_unsent socket does - * not wait when the write this event retried failed again. A TLS socket - * retries nothing after a rejected send. */ + * not wait when the write this event retried failed again. Nothing is + * retried after a rejected send. */ const int eof_deferrable = eof && s && !error && !us_socket_is_closed(s) && !s->read_eof; const int unsent_is_lost = hangup && s && s->hangup_closes_unsent && - (s->flags.last_write_failed || (s->ssl && s->ssl_send_rejected)); + (s->flags.last_write_failed || s->send_rejected); if (eof_deferrable && s->flags.is_paused && !unsent_is_lost) { #ifdef LIBUS_USE_EPOLL /* EPOLLHUP is unmaskable: leave epoll while paused so it cannot re-fire; the unread tail stays in @@ -975,13 +989,13 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in } if (us_socket_is_shut_down(s)) { /* We got FIN back after sending it */ - if (s->ssl && s->ssl_send_rejected && unsent_is_lost) { + if (s->send_rejected && unsent_is_lost) { /* This owner reads nothing until the lost bytes drain: left open for what is queued, its hangup would fire for ever. */ s = us_internal_socket_close_raw(s, LIBUS_ECONNRESET, NULL); return; } - s = s->ssl && s->ssl_send_rejected ? us_internal_ssl_on_end_after_rejected_send(s) - : us_internal_socket_close_raw(s, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, NULL); + s = s->send_rejected ? us_internal_socket_close_after_rejected_send(s) + : us_internal_socket_close_raw(s, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, NULL); return; } if (s->flags.allow_half_open && !hangup && s->read_eof) { @@ -998,9 +1012,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. */ @@ -1031,7 +1050,12 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in * (see us_internal_libuv_peer_reset_probe), so it is taken * there, and the CRT's ECONNRESET reached JS as ESHUTDOWN. */ int socket_error = us_socket_get_error(s); - s = us_internal_socket_close_raw(s, socket_error > 2 ? socket_error : LIBUS_ECONNRESET, NULL); + int code = socket_error > 2 ? socket_error : LIBUS_ECONNRESET; +#ifdef LIBUS_USE_KQUEUE + /* A unix peer that closed both directions is flagged like a reset. Its FIN was read and the socket has no error: epoll's hangup. */ + if (socket_error <= 2 && s->read_eof) code = LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN; +#endif + s = us_internal_socket_close_raw(s, code, NULL); return; } break; @@ -1093,7 +1117,8 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in const int run_recv = events & LIBUS_SOCKET_READABLE; #endif if (run_recv && !u->closed) { - + /* Return to the event loop after 32 reads, as libuv's uv__udp_recvmsg does, so timers and other polls run in between. The poll is level-triggered, so the next turn reads on. */ + int reads_left = 32; do { struct udp_recvbuf recvbuf; bsd_udp_setup_recvbuf(&recvbuf, u->loop->data.recv_buf, LIBUS_RECV_BUFFER_LENGTH); @@ -1145,7 +1170,13 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in break; } - } while (!u->closed); + } while (!u->closed && --reads_left); +#if defined(__linux__) + /* Datagrams are left, so a residual EPOLLERR is the next turn's to judge. */ + if (reads_left == 0) { + recv_would_block_only = 1; + } +#endif } if (events & LIBUS_SOCKET_WRITABLE && !u->closed) { @@ -1190,5 +1221,3 @@ void us_loop_integrate(struct us_loop_t *loop) { __attribute__((always_inline)) void *us_loop_ext(struct us_loop_t *loop) { return loop + 1; } - -#undef us_ioctl diff --git a/packages/bun-usockets/src/node_quic_shim.c b/packages/bun-usockets/src/node_quic_shim.c index 11cac3b9920f..7b79448767c0 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, @@ -34,22 +35,22 @@ struct us_nq_vtable { const struct sockaddr *old_peer); void (*on_origin)(void *conn_ctx, const unsigned char *chunk, size_t len, int fin); - SSL_CTX *(*get_ssl_ctx)(void *owner, const struct sockaddr *local); - SSL_CTX *(*get_client_ssl_ctx)(void *owner, const struct sockaddr *local); SSL_CTX *(*lookup_cert)(void *owner, const struct sockaddr *local, 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 +66,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 +149,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 +244,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 +258,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 +360,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, @@ -365,37 +375,11 @@ static const struct lsquic_stream_if nq_stream_if = { .on_origin = nq_on_origin, }; -static SSL_CTX *nq_get_ssl_ctx(void *peer_ctx, const struct sockaddr *local) { - struct us_nq_vtable *vt = *(struct us_nq_vtable **) peer_ctx; - return vt->get_ssl_ctx(vt->owner, local); -} -static SSL_CTX *nq_get_client_ssl_ctx(void *peer_ctx, const struct sockaddr *local) { - struct us_nq_vtable *vt = *(struct us_nq_vtable **) peer_ctx; - return vt->get_client_ssl_ctx(vt->owner, local); -} static SSL_CTX *nq_lookup_cert(void *cert_ctx, const struct sockaddr *local, const char *sni) { struct us_nq_vtable *vt = cert_ctx; return vt->lookup_cert(vt->owner, local, sni); } -static int nq_packets_out(void *out_ctx, const struct lsquic_out_spec *specs, - unsigned n) { - struct us_nq_vtable *vt = out_ctx; - 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); } @@ -418,14 +402,6 @@ struct us_nq_tp { char original_dcid[2 * MAX_CID_LEN + 1]; }; -extern int lsquic_conn_transport_params(const lsquic_conn_t *c, int peer, - struct us_nq_tp *out); - -int us_nq_conn_transport_params(const lsquic_conn_t *c, int peer, - struct us_nq_tp *out) { - return lsquic_conn_transport_params(c, peer, out); -} - size_t us_nq_tp_size(void) { return sizeof(struct us_nq_tp); } size_t us_nq_settings_size(void) { return sizeof(struct lsquic_engine_settings); } @@ -435,15 +411,24 @@ void us_nq_settings_init(struct lsquic_engine_settings *s, int is_server, unsigned flags = (is_server ? LSENG_SERVER : 0) | (is_http ? LSENG_HTTP : 0); lsquic_engine_init_settings(s, flags); s->es_versions = (1u << LSQVER_I001) | (1u << LSQVER_I002); - s->es_ecn = 0; /* Static-table-only encoding, as quic.c does. The dynamic table also makes * the decoder stream carry acks, and lsquic delays a server's MAX_STREAMS * grant while that stream has unsent data (lsquic_qdh_arm_if_unsent). */ s->es_qpack_enc_max_size = 0; s->es_qpack_enc_max_blocked = 0; - /* 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) \ @@ -487,10 +472,8 @@ NQ_SET(max_datagram_frame_size, unsigned short) NQ_SET(max_h3_header_pairs, unsigned short) NQ_SET(max_h3_header_bytes, unsigned) NQ_SET(allow_migration, int) -NQ_SET(scid_len, unsigned) NQ_SET(silent_close, int) NQ_SET(cc_algo, unsigned) -NQ_SET(delay_onclose, int) #define NQ_GET(f, ty) \ ty us_nq_settings_get_##f(const struct lsquic_engine_settings *s) { return s->es_##f; } @@ -508,113 +491,13 @@ NQ_GET(datagrams, int) NQ_GET(max_datagram_frame_size, unsigned short) #undef NQ_SET -/* `vt` must outlive the engine; `settings` is copied by lsquic. */ -lsquic_engine_t *us_nq_engine_new(int is_server, int is_http, +/* `vt` must outlive the engine. */ +lsquic_engine_t *us_nq_engine_new(us_quic_driver_t *driver, int is_server, int is_http, struct us_nq_vtable *vt, - const struct lsquic_engine_settings *settings, - const char *alpn) { - struct lsquic_engine_api api; - memset(&api, 0, sizeof(api)); - api.ea_settings = settings; - api.ea_stream_if = &nq_stream_if; - api.ea_stream_if_ctx = vt; - api.ea_packets_out = nq_packets_out; - api.ea_packets_out_ctx = vt; - api.ea_get_ssl_ctx = is_server ? nq_get_ssl_ctx : nq_get_client_ssl_ctx; - if (is_server) { - api.ea_lookup_cert = nq_lookup_cert; - api.ea_cert_lu_ctx = vt; - } - api.ea_alpn = alpn; - unsigned flags = is_server ? LSENG_SERVER : 0; - if (is_http) { - flags |= LSENG_HTTP; - api.ea_hsi_if = &nq_hset_if; - api.ea_hsi_ctx = NQ_HSI_CTX_PACK(settings->es_max_h3_header_pairs, - settings->es_max_h3_header_bytes); - } - return lsquic_engine_new(flags, &api); -} - -const struct sockaddr *us_nq_spec_dest(const struct lsquic_out_spec *s) { return s->dest_sa; } -const struct sockaddr *us_nq_spec_local(const struct lsquic_out_spec *s) { return s->local_sa; } -void *us_nq_spec_peer_ctx(const struct lsquic_out_spec *s) { return s->peer_ctx; } -const struct iovec *us_nq_spec_iov(const struct lsquic_out_spec *s, size_t *n) { - *n = s->iovlen; - return s->iov; -} -size_t us_nq_spec_stride(void) { return sizeof(struct lsquic_out_spec); } - -void us_nq_stream_reset(lsquic_stream_t *s, uint64_t code) { - /* RFC 9000 §3.1 allows RST in Data Sent state. */ - lsquic_stream_force_reset_ext(s, code); -} - -/* ───── node:quic loop driver ───── - * - * The corking model quic.c uses for Bun.serve's HTTP/3 listener: JS marks the - * endpoint pending and one pass runs per loop turn, so a burst of writes is - * one engine pass and one sendmmsg batch instead of one per call. - */ - -struct us_nq_driver_s { - struct us_nq_driver_s *next; - void *owner; - int pending; -}; - -/* endpoint.rs: the full process pass (engines + event dispatch). */ -extern void Bun__nodeQuic__processEndpoint(void *owner); - -void us_nq_loop_register(struct us_loop_t *loop, struct us_nq_driver_s *d, - void *owner) { - d->owner = owner; - d->pending = 0; - d->next = loop->data.nq_head; - loop->data.nq_head = d; -} - -void us_nq_loop_unregister(struct us_loop_t *loop, struct us_nq_driver_s *d) { - struct us_nq_driver_s **pp = &loop->data.nq_head; - while (*pp) { - /* Leave d->next intact: an in-progress walk holds it as its - * successor, and clearing it would end that pass early. Nothing else - * reads it, and register() overwrites it. */ - if (*pp == d) { *pp = d->next; return; } - pp = &(*pp)->next; - } -} - -/* endpoint.rs: full pass, but session close events are held for the next - * loop point -- dispatching a close in the middle of a running microtask - * chain is an interleaving node never produces. */ -extern void Bun__nodeQuic__drainEndpoint(void *owner); - -/* Runs from the microtask drain: keeps the chain's packets and non-close - * events moving without ending sessions mid-chain. */ -void us_nq_loop_drain(struct us_loop_t *loop) { - struct us_nq_driver_s *d = loop->data.nq_head; - while (d) { - struct us_nq_driver_s *next = d->next; - if (d->pending) { - d->pending = 0; - if (d->owner) Bun__nodeQuic__drainEndpoint(d->owner); - } - d = next; - } -} - -void us_nq_loop_flush_if_pending(struct us_loop_t *loop) { - struct us_nq_driver_s *d = loop->data.nq_head; - while (d) { - /* The pass runs JS, which can destroy the endpoint and unlink `d`; - * take the successor first. */ - struct us_nq_driver_s *next = d->next; - if (d->pending) { - d->pending = 0; - if (d->owner) Bun__nodeQuic__processEndpoint(d->owner); - } - d = next; - } + const struct lsquic_engine_settings *settings) { + return us_quic_driver_add_engine(driver, + (is_server ? LSENG_SERVER : 0) | (is_http ? LSENG_HTTP : 0), settings, + &nq_stream_if, vt, is_http ? &nq_hset_if : NULL, + NQ_HSI_CTX_PACK(settings->es_max_h3_header_pairs, settings->es_max_h3_header_bytes), + is_server ? nq_lookup_cert : NULL, vt); } - diff --git a/packages/bun-usockets/src/quic.c b/packages/bun-usockets/src/quic.c index f5badaaba80f..df8dfe2bdbc9 100644 --- a/packages/bun-usockets/src/quic.c +++ b/packages/bun-usockets/src/quic.c @@ -27,8 +27,6 @@ extern SSL_CTX *us_ssl_ctx_build_raw( struct us_bun_socket_context_options_t options, enum create_bun_socket_error_t *err); -extern X509_STORE *us_get_default_ca_store(void); -extern int us_ssl_ctx_set_verify_signature_algorithms(SSL_CTX *ctx); extern int us_x509_verify_cert(X509_STORE_CTX *ctx, int purpose); extern struct us_bun_verify_error_t us_ssl_socket_verify_error_from_ssl(SSL *ssl); @@ -54,7 +52,6 @@ struct us_quic_hset { struct lsxpack_header scratch; struct us_quic_header_t *headers; unsigned int count, hcap; - int is_server; /* Size as RFC 9114 §4.2.2 counts it, and the context's max_header_set_size. */ size_t size, max_size; /* Fields decoded so far, pseudo-headers and dropped ones included. */ @@ -62,46 +59,67 @@ struct us_quic_hset { int too_large; }; -struct us_quic_socket_context_s { +struct us_quic_driver_s { struct us_loop_t *loop; + struct us_quic_driver_s *next; /* loop->data.quic_head list */ + us_quic_pass_fn pass; + void *owner; + lsquic_engine_t **engines; + unsigned int engine_count; + /* An engine is on the stack. */ + int processing; + /* The owner queued work in an engine since the last pass. */ + int pending; + /* lsquic keeps a socket as the peer_ctx of its paths after the fd closes, so both lists are freed after the engines. */ + us_quic_udp_t *udps; + us_quic_udp_t *closed_udps; + /* Borrowed. A handshake starts with `connect_ssl_ctx`, which us_quic_udp_connect sets for its call, or else `ssl_ctx`. */ + SSL_CTX *ssl_ctx; + SSL_CTX *connect_ssl_ctx; + /* `processing` was cleared: the owner can do what it put off while it couldn't enter the engines. */ + void (*on_leave)(us_quic_driver_t *); + void (*on_udp_close)(us_quic_udp_t *); +}; + +struct us_quic_udp_s { + struct us_udp_socket_t *udp; + us_quic_driver_t *driver; + struct sockaddr_storage local; + struct us_quic_udp_s *next; /* driver->udps, then driver->closed_udps */ + /* Where the owner counts what this socket sent, or NULL. */ + uint64_t *packets_sent, *bytes_sent; + /* us_quic_listen_socket_close ran while `processing` was set; us_quic_after_engine_call closes the fd. */ + int close_pending; +}; + +struct us_quic_socket_context_s { + us_quic_driver_t *driver; lsquic_engine_t *engine; - struct lsquic_engine_settings settings; SSL_CTX *ssl_ctx; /* serverName -> SSL_CTX*, in the tree TCP listeners use (crypto/sni_tree.cpp). */ void *sni; - int processing; int closing; - /* us_quic_socket_context_shutdown ran while `processing` was set; - * us_quic_engine_leave finishes it. */ + /* us_quic_socket_context_shutdown ran while the driver was processing. */ int shutdown_pending; + /* us_quic_listen_socket_close ran, or the last UDP fd closed under live conns, while the driver was processing. */ + 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; unsigned int conn_count; unsigned int conn_ext_size; - /* Stream bytes written since the last process_conns. Once this exceeds - * roughly one full sendmmsg(64) batch, flush immediately instead of - * waiting for loop_post — keeps large bodies streaming while small - * responses still batch. */ - unsigned int pending_write_bytes; - struct us_quic_socket_context_s *next; /* loop->data.quic_head list */ unsigned int stream_ext_size; - /* Listen sockets stay reachable as lsquic peer_ctx after the UDP fd - * closes; defer freeing until the engine itself is torn down. `listeners` - * tracks live ones so context_free can close any the caller never did. */ - struct us_quic_listen_socket_s *listeners; - struct us_quic_listen_socket_s *closed_listeners; /* Client only: shared UDP endpoint for all outbound conns on this loop. */ - struct us_quic_listen_socket_s *client_udp; + us_quic_udp_t *client_udp; /* Live conns, so listen_socket_close can lsquic_conn_close each one * before the UDP fd disappears (otherwise abrupt stop just black-holes * the peer until idle timeout). */ struct us_quic_socket_s *conns; - void (*on_open)(us_quic_socket_t *); 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); @@ -112,16 +130,6 @@ struct us_quic_socket_context_s { /* ext follows */ }; -struct us_quic_listen_socket_s { - struct us_udp_socket_t *udp; - us_quic_socket_context_t *ctx; - struct sockaddr_storage local; - struct us_quic_listen_socket_s *next; /* live list, then reused for closed list */ - /* us_quic_listen_socket_close ran while `processing` was set; - * us_quic_engine_leave closes the fd. */ - int close_pending; -}; - struct us_quic_socket_s { lsquic_conn_t *conn; us_quic_socket_context_t *ctx; @@ -157,7 +165,7 @@ struct us_quic_stream_s { * the reactive path (packets arrived → handler ran → response written). * 2. lsquic's time-driven state (RTO retransmit, delayed ACK, idle * timeout): the min earliest_adv_tick across engines is written to - * loop->data.quic_next_tick_us; Bun's getTimeout() folds it into the + * loop->data.quic_next_tick_us; us_loop_run_bun_tick folds it into the * epoll_pwait2 timeout. No timerfd, no scheduling syscall. * * There is no per-write kick. Http3Response writes call lsquic_stream_write @@ -168,48 +176,76 @@ struct us_quic_stream_s { #ifdef LIBUS_USE_LIBUV static void us_quic_on_timer(struct us_timer_t *t) { - us_quic_loop_process(us_timer_loop(t)); + us_quic_loop_process(us_timer_loop(t), US_QUIC_BEFORE_POLL); } #endif -static void us_quic_after_engine_call(us_quic_socket_context_t *ctx); - /* lsquic forbids entering the engine from a callback it made. Every engine * call that can make callbacks sits between this pair. */ -static int us_quic_engine_enter(us_quic_socket_context_t *ctx) { - if (ctx->processing || !ctx->engine) return 0; - ctx->processing = 1; +static int us_quic_driver_enter(us_quic_driver_t *d) { + if (d->processing || !d->engine_count) return 0; + d->processing = 1; return 1; } -static void us_quic_engine_leave(us_quic_socket_context_t *ctx) { - ctx->processing = 0; - us_quic_after_engine_call(ctx); +static void us_quic_driver_leave(us_quic_driver_t *d) { + d->processing = 0; + if (d->on_leave) d->on_leave(d); } -void us_quic_loop_process(struct us_loop_t *loop) { - int min_diff = 0, have_tick = 0; - for (us_quic_socket_context_t *ctx = loop->data.quic_head; ctx; ctx = ctx->next) { - if (!us_quic_engine_enter(ctx)) continue; - ctx->pending_write_bytes = 0; - lsquic_engine_process_conns(ctx->engine); - us_quic_engine_leave(ctx); - int diff; - if (lsquic_engine_earliest_adv_tick(ctx->engine, &diff)) { - if (!have_tick || diff < min_diff) min_diff = diff; - have_tick = 1; - } +void us_quic_driver_process(us_quic_driver_t *d) { + if (!us_quic_driver_enter(d)) return; + d->pending = 0; + for (unsigned int i = 0; i < d->engine_count; i++) lsquic_engine_process_conns(d->engines[i]); + us_quic_driver_leave(d); +} + +static void us_quic_driver_send_unsent(us_quic_driver_t *d) { + if (!us_quic_driver_enter(d)) return; + for (unsigned int i = 0; i < d->engine_count; i++) lsquic_engine_send_unsent_packets(d->engines[i]); + us_quic_driver_leave(d); +} + +int us_quic_driver_next_tick_us(us_quic_driver_t *d) { + int min_diff = -1, diff; + for (unsigned int i = 0; i < d->engine_count; i++) { + if (!lsquic_engine_earliest_adv_tick(d->engines[i], &diff)) continue; + if (diff < 0) diff = 0; + if (min_diff < 0 || diff < min_diff) min_diff = diff; + } + return min_diff; +} + +void us_quic_driver_set_pending(us_quic_driver_t *d) { d->pending = 1; } +int us_quic_driver_is_pending(us_quic_driver_t *d) { return d->pending; } +void us_quic_driver_set_ssl_ctx(us_quic_driver_t *d, SSL_CTX *ssl_ctx) { d->ssl_ctx = ssl_ctx; } + +/* A pass can run JS that frees any driver or walks the list again, so a driver freed during a walk stays linked until the outermost walk ends. */ +static void us_quic_loop_end_walk(struct us_loop_t *loop) { + if (--loop->data.quic_walk_depth) return; + for (us_quic_driver_t **pp = &loop->data.quic_head; *pp; ) { + us_quic_driver_t *d = *pp; + if (d->pass) { pp = &d->next; continue; } + *pp = d->next; + us_free(d); } - /* Relative µs from now (≤0 means "tick due"). On epoll/kqueue, - * getTimeout() in src/runtime/timer/mod.rs folds this into the epoll_pwait2 timeout — - * no timerfd. On libuv there's no equivalent hook into the poll - * timeout, so arm a fallthrough uv_timer instead. */ - loop->data.quic_next_tick_us = have_tick ? (min_diff < 0 ? 0 : min_diff) : -1; +} + +void us_quic_loop_process(struct us_loop_t *loop, enum us_quic_pass_point point) { + int min_diff = -1; + loop->data.quic_walk_depth++; + for (us_quic_driver_t *d = loop->data.quic_head; d; d = d->next) { + int diff = d->pass ? d->pass(d, point) : -1; + if (diff >= 0 && (min_diff < 0 || diff < min_diff)) min_diff = diff; + } + us_quic_loop_end_walk(loop); + /* Relative µs from now. us_loop_run_bun_tick folds this into the poll timeout. libuv has no such hook, so arm a fallthrough uv_timer. */ + loop->data.quic_next_tick_us = min_diff; #ifdef LIBUS_USE_LIBUV - if (have_tick) { + if (min_diff >= 0) { if (!loop->data.quic_timer) loop->data.quic_timer = us_create_timer(loop, 1, 0); - int ms = min_diff <= 0 ? 1 : (min_diff + 999) / 1000; + int ms = min_diff == 0 ? 1 : (min_diff + 999) / 1000; us_timer_set(loop->data.quic_timer, us_quic_on_timer, ms, 0); } #endif @@ -219,25 +255,45 @@ void us_quic_loop_process(struct us_loop_t *loop) { * stream wrote since the last process_conns; the common case is one * pointer walk and return. */ void us_quic_loop_flush_if_pending(struct us_loop_t *loop) { - for (us_quic_socket_context_t *ctx = loop->data.quic_head; ctx; ctx = ctx->next) { - if (ctx->pending_write_bytes && !ctx->processing) { - us_quic_loop_process(loop); - return; - } + loop->data.quic_walk_depth++; + for (us_quic_driver_t *d = loop->data.quic_head; d; d = d->next) { + if (d->pass && d->pending && !d->processing) d->pass(d, US_QUIC_AFTER_MICROTASKS); + } + us_quic_loop_end_walk(loop); +} + +us_quic_driver_t *us_quic_driver_create(struct us_loop_t *loop, us_quic_pass_fn pass, void *owner) { + us_quic_driver_t *d = (us_quic_driver_t *) us_calloc(1, sizeof(*d)); + if (!d) Bun__outOfMemory(); + d->loop = loop; + d->pass = pass; + d->owner = owner; + d->next = loop->data.quic_head; + loop->data.quic_head = d; + return d; +} + +void us_quic_driver_close(us_quic_driver_t *d) { + while (d->udps) us_udp_socket_close(d->udps->udp); + for (unsigned int i = 0; i < d->engine_count; i++) lsquic_engine_destroy(d->engines[i]); + us_free(d->engines); + d->engines = NULL; + d->engine_count = 0; + while (d->closed_udps) { + us_quic_udp_t *qu = d->closed_udps; + d->closed_udps = qu->next; + us_free(qu); } } -static void us_quic_process(us_quic_socket_context_t *ctx) { - if (!us_quic_engine_enter(ctx)) return; - lsquic_engine_process_conns(ctx->engine); - us_quic_engine_leave(ctx); +void us_quic_driver_free(us_quic_driver_t *d) { + us_quic_driver_close(d); + d->pass = NULL; + d->loop->data.quic_walk_depth++; + us_quic_loop_end_walk(d->loop); } -static void us_quic_send_unsent(us_quic_socket_context_t *ctx) { - if (!us_quic_engine_enter(ctx)) return; - lsquic_engine_send_unsent_packets(ctx->engine); - us_quic_engine_leave(ctx); -} +void *us_quic_driver_owner(us_quic_driver_t *d) { return d->owner; } /* ───── packets out ───── */ @@ -316,6 +372,27 @@ static int us_quic_sendmmsg(int fd, struct mmsghdr *mm, unsigned k) { } #endif +/* A path whose local address is a sibling socket's (node:quic's preferred address) sends from that socket. */ +static us_quic_udp_t *us_quic_spec_udp(const struct lsquic_out_spec *spec) { + us_quic_udp_t *qu = (us_quic_udp_t *) spec->peer_ctx; + size_t len = sa_len(spec->local_sa); + if (memcmp(spec->local_sa, &qu->local, len) == 0) return qu; + for (us_quic_driver_t *d = qu->driver->loop->data.quic_head; d; d = d->next) { + for (us_quic_udp_t *sibling = d->udps; sibling; sibling = sibling->next) { + if (memcmp(spec->local_sa, &sibling->local, len) == 0) return sibling; + } + } + return qu; +} + +static void us_quic_count_sent(us_quic_udp_t *qu, const struct lsquic_out_spec *specs, unsigned n) { + if (!qu->packets_sent) return; + *qu->packets_sent += n; + for (unsigned i = 0; i < n; i++) { + for (size_t j = 0; j < specs[i].iovlen; j++) *qu->bytes_sent += specs[i].iov[j].iov_len; + } +} + /* lsquic hands back packets in batches; on Linux push them through one * sendmmsg() so a 32-packet flight is a single syscall. macOS's sendmsg_x * can't carry per-datagram addresses (which QUIC needs), so it falls back to @@ -328,10 +405,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 @@ -347,11 +424,11 @@ static int us_quic_packets_out(void *out_ctx, const struct lsquic_out_spec *spec enum { BATCH = 64 }; 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; } - int fd = us_poll_fd((struct us_poll_t *) ls->udp); + us_quic_udp_t *qu = us_quic_spec_udp(&specs[sent]); + if (!qu->udp) { sent++; continue; } + int fd = us_poll_fd((struct us_poll_t *) qu->udp); unsigned k = 0; - while (k < BATCH && sent + k < n && specs[sent + k].peer_ctx == (void *) ls) { + while (k < BATCH && sent + k < n && us_quic_spec_udp(&specs[sent + k]) == qu) { const struct lsquic_out_spec *sp = &specs[sent + k]; memset(&mm[k], 0, sizeof(mm[k])); mm[k].msg_hdr.msg_name = (void *) sp->dest_sa; @@ -378,13 +455,14 @@ static int us_quic_packets_out(void *out_ctx, const struct lsquic_out_spec *spec if (us_quic_send_would_block(errno) || errno == EMSGSIZE) break; r = 1; /* mm[0] is dropped */ } + us_quic_count_sent(qu, &specs[sent], (unsigned) r); sent += (unsigned) r; } #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; } - LIBUS_SOCKET_DESCRIPTOR fd = us_poll_fd((struct us_poll_t *) ls->udp); + us_quic_udp_t *qu = us_quic_spec_udp(&specs[sent]); + if (!qu->udp) continue; + LIBUS_SOCKET_DESCRIPTOR fd = us_poll_fd((struct us_poll_t *) qu->udp); if (us_quic_send_one(fd, &specs[sent]) < 0) { int stop = us_quic_send_would_block(errno) || errno == EMSGSIZE; /* One retry, as above. If it fails the same way, specs[sent] is dropped. */ @@ -392,20 +470,18 @@ static int us_quic_packets_out(void *out_ctx, const struct lsquic_out_spec *spec stop = us_quic_send_would_block(errno) || errno == EMSGSIZE; if (stop) break; } + us_quic_count_sent(qu, &specs[sent], 1); } #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_quic_udp_t *qu = us_quic_spec_udp(&specs[sent]); + us_poll_change((struct us_poll_t *) qu->udp, qu->driver->loop, + LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE); } return (int) sent; } @@ -413,9 +489,8 @@ static int us_quic_packets_out(void *out_ctx, const struct lsquic_out_spec *spec /* ───── UDP callbacks ───── */ static void us_quic_udp_on_data(struct us_udp_socket_t *u, void *recvbuf, int npackets) { - us_quic_listen_socket_t *ls = (us_quic_listen_socket_t *) us_udp_socket_user(u); - us_quic_socket_context_t *ctx = ls->ctx; - if (!ctx->engine) return; + us_quic_udp_t *ls = (us_quic_udp_t *) us_udp_socket_user(u); + us_quic_socket_context_t *ctx = (us_quic_socket_context_t *) ls->driver->owner; for (int i = 0; i < npackets; i++) { char *payload = us_udp_packet_buffer_payload((struct us_udp_packet_buffer_t *) recvbuf, i); int len = us_udp_packet_buffer_payload_length((struct us_udp_packet_buffer_t *) recvbuf, i); @@ -429,32 +504,63 @@ static void us_quic_udp_on_data(struct us_udp_socket_t *u, void *recvbuf, int np } static void us_quic_udp_on_drain(struct us_udp_socket_t *u) { - us_quic_listen_socket_t *ls = (us_quic_listen_socket_t *) us_udp_socket_user(u); - us_quic_send_unsent(ls->ctx); + us_quic_driver_t *d = ((us_quic_udp_t *) us_udp_socket_user(u))->driver; + us_quic_driver_send_unsent(d); + d->pending = 1; } 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; - /* lsquic still holds `ls` as peer_ctx for every connection accepted on - * this socket; freeing now would UAF in packets_out / get_ssl_ctx on the - * next timer tick. Mark the fd gone, unlink from the live list, and defer - * the free to context_free. */ - ls->udp = NULL; + us_quic_udp_t *qu = (us_quic_udp_t *) us_udp_socket_user(u); + us_quic_driver_t *d = qu->driver; + qu->udp = NULL; + for (us_quic_udp_t **pp = &d->udps; *pp; pp = &(*pp)->next) { + if (*pp == qu) { *pp = qu->next; break; } + } + qu->next = d->closed_udps; + d->closed_udps = qu; + if (d->on_udp_close) d->on_udp_close(qu); +} + +/* Closes every conn right away. The engine must be off the stack. cooldown skips a conn that's going away, so abort each one first. */ +static void us_quic_abort_conns(us_quic_socket_context_t *ctx) { + 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_driver_process(ctx->driver); + us_quic_driver_send_unsent(ctx->driver); +} + +int us_quic_socket_context_is_released(us_quic_socket_context_t *ctx) { + return !ctx->driver->udps && 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_context_on_udp_close(us_quic_udp_t *ls) { + us_quic_socket_context_t *ctx = (us_quic_socket_context_t *) ls->driver->owner; if (ctx->client_udp == ls) ctx->client_udp = NULL; - for (us_quic_listen_socket_t **pp = &ctx->listeners; *pp; pp = &(*pp)->next) { - if (*pp == ls) { *pp = ls->next; break; } + /* 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->driver->udps && ctx->conn_count) { + if (ctx->driver->processing) ctx->abort_pending = 1; + else us_quic_abort_conns(ctx); + return; } - ls->next = ctx->closed_listeners; - ctx->closed_listeners = ls; + us_quic_report_if_released(ctx); } /* ───── SSL ───── */ 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; + us_quic_driver_t *d = ((us_quic_udp_t *) peer_ctx)->driver; + return d->connect_ssl_ctx ? d->connect_ssl_ctx : d->ssl_ctx; } static SSL_CTX *us_quic_lookup_cert(void *cert_ctx, const struct sockaddr *local, const char *sni) { @@ -495,35 +601,12 @@ static void *us_quic_hsi_create(void *hsi_ctx, lsquic_stream_t *s, int is_push) (void) s; (void) is_push; struct us_quic_hset *h = (struct us_quic_hset *) us_calloc(1, sizeof(struct us_quic_hset)); if (!h) return NULL; - h->is_server = !((us_quic_socket_context_t *) hsi_ctx)->is_client; h->max_size = ((us_quic_socket_context_t *) hsi_ctx)->max_header_set_size; /* Only max_size bounds the fields of a response, as in the HTTP/2 client. */ - h->max_fields = h->is_server ? US_QUIC_MAX_REQUEST_FIELDS : h->max_size; + h->max_fields = ((us_quic_socket_context_t *) hsi_ctx)->is_client ? h->max_size : US_QUIC_MAX_REQUEST_FIELDS; return h; } -static int us_quic_field_is_malformed(const struct lsxpack_header *hdr) { - const unsigned char *name = (const unsigned char *) lsxpack_header_get_name(hdr); - const unsigned char *val = (const unsigned char *) lsxpack_header_get_value(hdr); - if (hdr->name_len == 0) return 1; - if (name[0] == ':' && hdr->name_len == 1) return 1; - for (unsigned int i = name[0] == ':'; i < hdr->name_len; i++) { - unsigned char c = name[i]; - if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) continue; - switch (c) { - case '!': case '#': case '$': case '%': case '&': case '\'': case '*': - case '+': case '-': case '.': case '^': case '_': case '`': case '|': case '~': - continue; - } - return 1; - } - for (unsigned int i = 0; i < hdr->val_len; i++) { - unsigned char c = val[i]; - if (c == 0 || c == '\r' || c == '\n') return 1; - } - return 0; -} - static struct lsxpack_header *us_quic_hsi_prepare(void *hset_p, struct lsxpack_header *hdr, size_t space) { struct us_quic_hset *h = (struct us_quic_hset *) hset_p; /* `space` covers the bytes lsqpack has in hand (patches/lsqpack/decode-room-for-bytes-in-hand.patch), @@ -566,7 +649,6 @@ static int us_quic_hsi_process(void *hset_p, struct lsxpack_header *hdr) { return h->size > us_quic_hset_hard_cap(h) || h->fields > US_QUIC_HSET_HARD_CAP_FACTOR * h->max_fields ? -1 : 0; } - if (h->is_server && us_quic_field_is_malformed(hdr)) return 1; if (h->count == h->hcap) { unsigned int ncap = h->hcap ? h->hcap * 2 : 16; struct us_quic_header_t *nh = (struct us_quic_header_t *) @@ -648,15 +730,14 @@ static lsquic_conn_ctx_t *us_quic_on_new_conn(void *if_ctx, lsquic_conn_t *conn) * the listen socket's uv_poll_t already keeps the loop alive until * conn_count drops to 0 and we close it. */ #ifndef LIBUS_USE_LIBUV - ctx->loop->num_polls++; + ctx->driver->loop->num_polls++; #endif ctx->conn_count++; /* Arm the sweep-timer refcount so DateHeaderTimer runs for h3-only * servers; the TCP path does this per-socket in context.c. */ - us_internal_enable_sweep_timer(ctx->loop); + us_internal_enable_sweep_timer(ctx->driver->loop); qs->next = ctx->conns; ctx->conns = qs; - if (ctx->on_open) ctx->on_open(qs); return (lsquic_conn_ctx_t *) qs; } @@ -672,14 +753,18 @@ static void us_quic_on_conn_closed(lsquic_conn_t *conn) { us_free(qs->hostname); us_free(qs); #ifndef LIBUS_USE_LIBUV - ctx->loop->num_polls--; + ctx->driver->loop->num_polls--; #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); + us_internal_disable_sweep_timer(ctx->driver->loop); + if (ctx->conn_count == 0) { + if (ctx->closing && ctx->driver->udps) { + /* 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->driver->udps) us_udp_socket_close(ctx->driver->udps->udp); + } else { + us_quic_report_if_released(ctx); + } } } @@ -725,22 +810,18 @@ static void us_quic_on_read(lsquic_stream_t *stream, lsquic_stream_ctx_t *h) { us_quic_stream_t *s = (us_quic_stream_t *) h; us_quic_socket_context_t *ctx = s->ctx; - /* lsquic queues a fresh hset for every HEADERS block (1xx interims, - * the final response, trailers). lsquic_stream_get_hset returns the - * next undelivered one and lsquic_stream_read fails while one is - * queued, so re-dispatch on_stream_headers each time instead of - * latching after the first. */ - if (us_quic_deliver_hset(stream, s) < 0) return; - ssize_t r; - while ((r = lsquic_stream_read(stream, ctx->read_buf, US_QUIC_READ_BUF)) > 0) { - if (ctx->on_stream_data) + int delivered; + do { + /* lsquic queues each HEADERS block (1xx, the final response, trailers). Until it's claimed, a read fails and sets no errno. */ + if ((delivered = us_quic_deliver_hset(stream, s)) < 0) return; + r = lsquic_stream_read(stream, ctx->read_buf, US_QUIC_READ_BUF); + if (r > 0 && ctx->on_stream_data) ctx->on_stream_data(s, ctx->read_buf, (unsigned int) r, 0); if (!s->stream) return; - } + } while (r > 0 || delivered); if (r == 0 && !s->fin_delivered) { /* lsquic returns 0 even if this read decoded one more header block. */ - int delivered; while ((delivered = us_quic_deliver_hset(stream, s)) > 0) {} if (delivered < 0) return; s->fin_delivered = 1; @@ -751,6 +832,9 @@ static void us_quic_on_read(lsquic_stream_t *stream, lsquic_stream_ctx_t *h) { /* lsquic resets a request above the id of a GOAWAY without on_reset, * and calls on_read on every tick until the stream is closed. */ lsquic_stream_close(stream); + } else if (r < 0 && errno == EBADMSG && lsquic_conn_status(lsquic_stream_conn(stream), NULL, 0) != LSCONN_ST_ERROR) { + /* No frame error, which closes the connection: there's no header block where one belongs (RFC 9114 §4.1). lsquic calls on_read on every tick for this too. */ + us_quic_stream_reset(s, US_QUIC_H3_MESSAGE_ERROR); } } @@ -821,10 +905,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,12 +918,101 @@ void us_quic_global_init(void) { #endif } -static void us_quic_prepare_ssl_ctx(SSL_CTX *ssl, const struct us_bun_socket_context_options_t *options) { +#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 + +static 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; +} + +lsquic_engine_t *us_quic_driver_add_engine(us_quic_driver_t *d, unsigned flags, + const struct lsquic_engine_settings *settings, + const struct lsquic_stream_if *stream_if, void *stream_if_ctx, + const struct lsquic_hset_if *hset_if, void *hset_ctx, + SSL_CTX *(*lookup_cert)(void *, const struct sockaddr *, const char *), void *lookup_cert_ctx) +{ + if (us_quic_global_init() != 0) return NULL; + lsquic_engine_t **engines = (lsquic_engine_t **) + us_realloc(d->engines, (d->engine_count + 1) * sizeof(*engines)); + if (!engines) return NULL; + d->engines = engines; + + struct lsquic_engine_settings ours = *settings; + /* us_quic_packets_out sets no ECN bits, and every lsquic_engine_packet_in call passes 0 for them. */ + ours.es_ecn = 0; + /* lsquic then parses nothing past an unclaimed header block. A client's default of 2 lets data arrive ahead of the block before it. */ + ours.es_max_header_sets = 1; + + struct lsquic_engine_api api; + memset(&api, 0, sizeof(api)); + api.ea_settings = &ours; + api.ea_stream_if = stream_if; + api.ea_stream_if_ctx = stream_if_ctx; + api.ea_packets_out = us_quic_packets_out; + api.ea_get_ssl_ctx = us_quic_get_ssl_ctx; + api.ea_lookup_cert = lookup_cert; + api.ea_cert_lu_ctx = lookup_cert_ctx; + api.ea_hsi_if = hset_if; + api.ea_hsi_ctx = hset_ctx; + + lsquic_engine_t *engine = lsquic_engine_new(flags, &api); + if (engine) d->engines[d->engine_count++] = engine; + return engine; +} + +static void us_quic_after_engine_call(us_quic_driver_t *d); + +static int us_quic_context_pass(us_quic_driver_t *d, enum us_quic_pass_point point) { + (void) point; + us_quic_driver_process(d); + return us_quic_driver_next_tick_us(d); +} + +/* Takes `ssl`, also on failure. */ +static us_quic_socket_context_t *us_quic_context_create(struct us_loop_t *loop, SSL_CTX *ssl, + unsigned int ext_size, unsigned flags, const struct lsquic_engine_settings *settings) +{ + us_quic_socket_context_t *ctx = (us_quic_socket_context_t *) + us_calloc(1, sizeof(us_quic_socket_context_t) + ext_size); + if (ctx) { + us_quic_driver_t *d = us_quic_driver_create(loop, us_quic_context_pass, ctx); + d->ssl_ctx = ssl; + d->on_leave = us_quic_after_engine_call; + d->on_udp_close = us_quic_context_on_udp_close; + ctx->driver = d; + ctx->ssl_ctx = ssl; + ctx->is_client = !(flags & LSENG_SERVER); + ctx->max_header_set_size = settings->es_max_header_list_size; + ctx->engine = us_quic_driver_add_engine(d, flags, settings, &us_quic_stream_if, ctx, + &us_quic_hset_if, ctx, ctx->is_client ? NULL : us_quic_lookup_cert, ctx); + if (ctx->engine) return ctx; + us_quic_driver_free(d); + } + SSL_CTX_free(ssl); + us_free(ctx); + return NULL; +} + +static void us_quic_prepare_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_alpn_select_cb(ssl, us_quic_alpn_select, NULL); SSL_CTX_set_early_data_enabled(ssl, 0); - us_ssl_ctx_set_sni_policy(ssl, options->request_cert, options->reject_unauthorized); + us_ssl_ctx_set_own_session_id_context(ssl); } us_quic_socket_context_t *us_create_quic_socket_context( @@ -848,58 +1022,28 @@ us_quic_socket_context_t *us_create_quic_socket_context( enum create_bun_socket_error_t ssl_err = 0; SSL_CTX *ssl = us_ssl_ctx_build_raw(options, &ssl_err); if (!ssl) return NULL; - us_quic_prepare_ssl_ctx(ssl, &options); + us_quic_prepare_ssl_ctx(ssl); - us_quic_socket_context_t *ctx = (us_quic_socket_context_t *) - us_calloc(1, sizeof(us_quic_socket_context_t) + ext_size); - if (!ctx) { SSL_CTX_free(ssl); return NULL; } - ctx->loop = loop; - ctx->ssl_ctx = ssl; - - lsquic_engine_init_settings(&ctx->settings, LSENG_HTTP_SERVER); - ctx->settings.es_versions = LSQUIC_DF_VERSIONS & LSQUIC_IETF_VERSIONS; - ctx->settings.es_ecn = 0; + struct lsquic_engine_settings settings; + lsquic_engine_init_settings(&settings, LSENG_HTTP_SERVER); + settings.es_versions = LSQUIC_DF_VERSIONS & LSQUIC_IETF_VERSIONS; /* Same value as Http2Context::maxHeaderListSize. */ size_t max_header_size = BUN_DEFAULT_MAX_HTTP_HEADER_SIZE ? BUN_DEFAULT_MAX_HTTP_HEADER_SIZE : 64 * 1024; - ctx->max_header_set_size = max_header_size > 0xffffff ? 0xffffff : (unsigned) max_header_size; - ctx->settings.es_max_header_list_size = ctx->max_header_set_size; - ctx->settings.es_init_max_streams_bidi = 100; + settings.es_max_header_list_size = max_header_size > 0xffffff ? 0xffffff : (unsigned) max_header_size; + settings.es_init_max_streams_bidi = 100; /* Static-table-only response encoding: skips the per-header dynamic * table search (lsqpack_enc_encode + XXH32 + header_out_dynamic_entry * were ~8% of a hello-world profile). Clients still get correct * responses; the wire is a few bytes larger per uncommon header. */ - ctx->settings.es_qpack_enc_max_size = 0; - ctx->settings.es_qpack_enc_max_blocked = 0; + settings.es_qpack_enc_max_size = 0; + settings.es_qpack_enc_max_blocked = 0; /* We never set per-stream priority; with this off, lsquic skips the RFC * 9218 scheduler and the patched determine_bpt short-circuits the O(N) * stream-hash walk on every write. */ - ctx->settings.es_ext_http_prio = 0; - if (idle_timeout_s) ctx->settings.es_idle_timeout = idle_timeout_s > 600 ? 600 : idle_timeout_s; + settings.es_ext_http_prio = 0; + if (idle_timeout_s) settings.es_idle_timeout = idle_timeout_s > 600 ? 600 : idle_timeout_s; - struct lsquic_engine_api api; - memset(&api, 0, sizeof(api)); - api.ea_settings = &ctx->settings; - api.ea_stream_if = &us_quic_stream_if; - api.ea_stream_if_ctx = ctx; - api.ea_packets_out = us_quic_packets_out; - api.ea_packets_out_ctx = ctx; - api.ea_get_ssl_ctx = us_quic_get_ssl_ctx; - api.ea_lookup_cert = us_quic_lookup_cert; - api.ea_cert_lu_ctx = ctx; - api.ea_hsi_if = &us_quic_hset_if; - api.ea_hsi_ctx = ctx; - - ctx->engine = lsquic_engine_new(LSENG_HTTP_SERVER, &api); - if (!ctx->engine) { - SSL_CTX_free(ssl); - us_free(ctx); - return NULL; - } - - ctx->next = loop->data.quic_head; - loop->data.quic_head = ctx; - - return ctx; + return us_quic_context_create(loop, ssl, ext_size, LSENG_HTTP_SERVER, &settings); } static void us_quic_sni_free(void *ssl_ctx) { @@ -912,7 +1056,7 @@ int us_quic_socket_context_add_server_name(us_quic_socket_context_t *ctx, enum create_bun_socket_error_t ssl_err = 0; SSL_CTX *ssl = us_ssl_ctx_build_raw(options, &ssl_err); if (!ssl) return -1; - us_quic_prepare_ssl_ctx(ssl, &options); + us_quic_prepare_ssl_ctx(ssl); SSL_CTX_set_verify(ssl, SSL_CTX_get_verify_mode(ssl) | SSL_CTX_get_verify_mode(ctx->ssl_ctx), SSL_CTX_get_verify_callback(ssl)); if (!ctx->sni) ctx->sni = sni_new(); @@ -924,19 +1068,19 @@ static void us_quic_socket_context_finish_shutdown(us_quic_socket_context_t *ctx /* GOAWAY every conn and flush; loop_post keeps ticking so in-flight * streams drain. New conns are rejected in on_new_conn while closing. */ lsquic_engine_cooldown(ctx->engine); - us_quic_send_unsent(ctx); - us_quic_process(ctx); + us_quic_driver_send_unsent(ctx->driver); + us_quic_driver_process(ctx->driver); /* Nothing to drain — release the UDP fd now so the loop can exit. */ if (ctx->conn_count == 0) { - while (ctx->listeners) us_udp_socket_close(ctx->listeners->udp); + while (ctx->driver->udps) us_udp_socket_close(ctx->driver->udps->udp); } } void us_quic_socket_context_shutdown(us_quic_socket_context_t *ctx) { - if (!ctx || ctx->closing || !ctx->engine) return; + if (!ctx || ctx->closing) return; ctx->closing = 1; /* stop() from a request handler, or from a microtask it resolved. */ - if (ctx->processing) { + if (ctx->driver->processing) { ctx->shutdown_pending = 1; return; } @@ -946,27 +1090,16 @@ 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; - 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; } - } - if (!loop->data.quic_head) loop->data.quic_next_tick_us = -1; - /* Close any UDP fds the caller never closed (graceful drain leaves them - * open); on_close moves each into closed_listeners for the loop below. */ - while (ctx->listeners) us_udp_socket_close(ctx->listeners->udp); - if (ctx->engine) { lsquic_engine_destroy(ctx->engine); ctx->engine = NULL; } - if (ctx->ssl_ctx) { SSL_CTX_free(ctx->ssl_ctx); ctx->ssl_ctx = NULL; } + /* The owner goes away with the context, so the teardown below must not call into it. */ + ctx->on_released = NULL; + us_quic_driver_free(ctx->driver); + SSL_CTX_free(ctx->ssl_ctx); if (ctx->sni) sni_free(ctx->sni, us_quic_sni_free); - for (us_quic_listen_socket_t *ls = ctx->closed_listeners; ls; ) { - us_quic_listen_socket_t *next = ls->next; - us_free(ls); - ls = next; - } us_free(ctx); } void *us_quic_socket_context_ext(us_quic_socket_context_t *ctx) { return ctx + 1; } -struct us_loop_t *us_quic_socket_context_loop(us_quic_socket_context_t *ctx) { return ctx->loop; } +struct us_loop_t *us_quic_socket_context_loop(us_quic_socket_context_t *ctx) { return ctx->driver->loop; } /* RFC 9000 §14: QUIC packets must not be IP-fragmented. _PROBE (vs _DO) sets * DF but ignores the kernel's cached path-MTU, so lsquic's own DPLPMTUD owns @@ -1008,57 +1141,76 @@ static void us_quic_set_dontfrag(struct us_udp_socket_t *udp) { * that port. Its closing conns then go silent. */ static void us_quic_release_pending_port(struct us_loop_t *loop, int port) { if (port == 0) return; - for (us_quic_socket_context_t *ctx = loop->data.quic_head; ctx; ctx = ctx->next) { - for (us_quic_listen_socket_t *ls = ctx->listeners; ls; ) { + for (us_quic_driver_t *d = loop->data.quic_head; d; d = d->next) { + for (us_quic_udp_t *ls = d->udps; ls; ) { if (!ls->close_pending || us_quic_listen_socket_port(ls) != port) { ls = ls->next; continue; } ls->close_pending = 0; us_udp_socket_close(ls->udp); /* udp_on_close unlinked `ls`; restart from the head. */ - ls = ctx->listeners; + ls = d->udps; } } } -us_quic_listen_socket_t *us_quic_socket_context_listen( - us_quic_socket_context_t *ctx, const char *host, int port, int flags, - unsigned int stream_ext_size) +us_quic_udp_t *us_quic_udp_create(us_quic_driver_t *d, const char *host, int port, int flags, int *err, + void (*on_data)(struct us_udp_socket_t *, void *, int), + void (*on_recv_error)(struct us_udp_socket_t *, int, int)) { - ctx->stream_ext_size = stream_ext_size; + us_quic_udp_t *qu = (us_quic_udp_t *) us_calloc(1, sizeof(*qu)); + if (!qu) return NULL; + qu->driver = d; + qu->udp = us_create_udp_socket(d->loop, on_data, us_quic_udp_on_drain, us_quic_udp_on_close, + on_recv_error, host, (unsigned short) port, flags, err, qu); + if (!qu->udp) { us_free(qu); return NULL; } + us_quic_set_dontfrag(qu->udp); - us_quic_release_pending_port(ctx->loop, port); + /* Record actual bound address — packet_in needs sa_local. */ + socklen_t sl = sizeof(qu->local); + getsockname(us_poll_fd((struct us_poll_t *) qu->udp), (struct sockaddr *) &qu->local, &sl); - us_quic_listen_socket_t *ls = (us_quic_listen_socket_t *) us_calloc(1, sizeof(*ls)); - if (!ls) return NULL; - ls->ctx = ctx; + qu->next = d->udps; + d->udps = qu; + return qu; +} - int err = 0; - ls->udp = us_create_udp_socket(ctx->loop, - us_quic_udp_on_data, us_quic_udp_on_drain, us_quic_udp_on_close, NULL, - host, (unsigned short) port, flags, &err, ls); - if (!ls->udp) { us_free(ls); return NULL; } - us_quic_set_dontfrag(ls->udp); +void *us_quic_udp_owner(us_quic_udp_t *qu) { return qu->driver->owner; } +struct us_udp_socket_t *us_quic_udp_socket(us_quic_udp_t *qu) { return qu->udp; } +const struct sockaddr *us_quic_udp_local(us_quic_udp_t *qu) { return (const struct sockaddr *) &qu->local; } - /* Record actual bound address — packet_in needs sa_local. */ - socklen_t sl = sizeof(ls->local); - getsockname(us_poll_fd((struct us_poll_t *) ls->udp), (struct sockaddr *) &ls->local, &sl); - - ls->next = ctx->listeners; - ctx->listeners = ls; - return ls; +void us_quic_udp_count_sent(us_quic_udp_t *qu, uint64_t *packets, uint64_t *bytes) { + qu->packets_sent = packets; + qu->bytes_sent = bytes; } -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); +lsquic_conn_t *us_quic_udp_connect(us_quic_udp_t *qu, lsquic_engine_t *engine, const struct sockaddr *peer, + SSL_CTX *ssl_ctx, void *conn_ctx, const char *sni, const unsigned char *session_resume, size_t session_resume_len) +{ + us_quic_driver_t *d = qu->driver; + /* lsquic calls us_quic_get_ssl_ctx and SSL_new, which takes its own reference, before this returns. */ + d->connect_ssl_ctx = ssl_ctx; + lsquic_conn_t *conn = lsquic_engine_connect(engine, N_LSQVER, (struct sockaddr *) &qu->local, peer, qu, + (lsquic_conn_ctx_t *) conn_ctx, sni, 0, session_resume, session_resume_len, NULL, 0); + d->connect_ssl_ctx = NULL; + /* The next pass sends the Initial flight. The caller can finish setting up the connection's context first. */ + d->pending = 1; + return conn; +} + +us_quic_udp_t *us_quic_socket_context_listen( + us_quic_socket_context_t *ctx, const char *host, int port, int flags, + unsigned int stream_ext_size) +{ + ctx->stream_ext_size = stream_ext_size; + us_quic_release_pending_port(ctx->driver->loop, port); + int err = 0; + return us_quic_udp_create(ctx->driver, host, port, flags, &err, us_quic_udp_on_data, NULL); } -void us_quic_listen_socket_close(us_quic_listen_socket_t *ls) { - if (!ls || !ls->udp) return; +void us_quic_listen_socket_close(us_quic_udp_t *ls) { + if (!ls) return; + us_quic_socket_context_t *ctx = (us_quic_socket_context_t *) ls->driver->owner; + /* 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,42 +1224,49 @@ 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->driver->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; ) { + for (us_quic_udp_t *ls = ctx->driver->udps; 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; + ls = ctx->driver->udps; } } /* The engine has left the stack: do what a callback asked for while it could * not be entered. */ -static void us_quic_after_engine_call(us_quic_socket_context_t *ctx) { +static void us_quic_after_engine_call(us_quic_driver_t *d) { + us_quic_socket_context_t *ctx = (us_quic_socket_context_t *) d->owner; if (ctx->shutdown_pending) { 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) { +int us_quic_listen_socket_port(us_quic_udp_t *ls) { /* ls->udp goes NULL in udp_on_close while ls itself survives until * context_free; read from the cached getsockname() result instead. */ return ntohs(ls->local.ss_family == AF_INET6 @@ -1115,7 +1274,7 @@ int us_quic_listen_socket_port(us_quic_listen_socket_t *ls) { : ((struct sockaddr_in *) &ls->local)->sin_port); } -int us_quic_listen_socket_local_address(us_quic_listen_socket_t *ls, char *buf, int len) { +int us_quic_listen_socket_local_address(us_quic_udp_t *ls, char *buf, int len) { if (ls->local.ss_family == AF_INET6) { if (len < 16) return 0; memcpy(buf, &((struct sockaddr_in6 *) &ls->local)->sin6_addr, 16); @@ -1128,10 +1287,10 @@ int us_quic_listen_socket_local_address(us_quic_listen_socket_t *ls, char *buf, #define DEF_CB(name, sig) \ void us_quic_socket_context_##name(us_quic_socket_context_t *ctx, sig) { ctx->name = cb; } -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)) @@ -1147,22 +1306,23 @@ int us_quic_stream_write(us_quic_stream_t *s, const char *data, unsigned int len * marked tickable and small writes sit until an unrelated alarm fires. * flush() schedules the buffered bytes for the next process_conns — it * doesn't force a packet per call, so back-to-back writes still coalesce. - * pending_write_bytes is the gate for drainQuicIfNecessary / loop_pre. + * `pending` is the gate for drainQuicIfNecessary. * Don't call us_quic_loop_process here — process_conns inside an * Http3Response method could free this stream via on_close. */ if (w > 0) { lsquic_stream_flush(s->stream); - s->ctx->pending_write_bytes += (unsigned int) w; + s->ctx->driver->pending = 1; } 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) { + if (!s->stream) return -1; + return lsquic_stream_wantwrite(s->stream, 1) < 0 ? -1 : 0; } /* lsquic_stream_send_headers only buffers, and a flush is a no-op while lsquic @@ -1184,7 +1344,7 @@ int us_quic_stream_send_informational(us_quic_stream_t *s, const char *status3) int r = lsquic_stream_send_headers(s->stream, &lh, 0); if (r == 0) { us_quic_flush_from_on_write(s); - s->ctx->pending_write_bytes++; + s->ctx->driver->pending = 1; } return r; } @@ -1228,14 +1388,17 @@ 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); if (!end_stream && r == 0) us_quic_flush_from_on_write(s); /* Mark the context dirty so drainQuicIfNecessary picks up header-only * responses (204/304) that never call us_quic_stream_write. */ - if (r == 0) s->ctx->pending_write_bytes += (unsigned int) total + 1; + if (r == 0) s->ctx->driver->pending = 1; return r; } @@ -1243,15 +1406,6 @@ void us_quic_stream_shutdown(us_quic_stream_t *s) { if (s->stream) lsquic_stream_shutdown(s->stream, 1); } -/* lsquic_stream_write buffers until a full packet or shutdown; force the - * partial buffer into a packet so the peer sees streamed bytes promptly. */ -void us_quic_stream_flush(us_quic_stream_t *s) { - if (s->stream) { - lsquic_stream_flush(s->stream); - s->ctx->pending_write_bytes++; - } -} - void us_quic_stream_shutdown_read(us_quic_stream_t *s) { if (s->stream) lsquic_stream_shutdown(s->stream, 0); } @@ -1263,7 +1417,7 @@ void us_quic_stream_close(us_quic_stream_t *s) { /* From lsquic_stream.h (not in the public header). */ void lsquic_stream_maybe_reset(struct lsquic_stream *, uint64_t error_code, int); -/* Abort the send half with RESET_STREAM(H3_REQUEST_CANCELLED) instead of +/* Abort the send half with RESET_STREAM instead of * FIN. lsquic_stream_close/shutdown queue FIN after the buffered tail, * which is a protocol error if a content-length was advertised and the * client is abandoning the upload short — the server's lsquic will @@ -1271,30 +1425,21 @@ void lsquic_stream_maybe_reset(struct lsquic_stream *, uint64_t error_code, int) * the wire-level "I'm cancelling this send" and lets the server treat it * as a stream-level cancellation rather than a malformed message. * Sends nothing once lsquic_stream_close/shutdown has run, so call it first. */ -static void us_quic_stream_reset_with(us_quic_stream_t *s, uint64_t error_code) { +void us_quic_stream_reset(us_quic_stream_t *s, uint64_t error_code) { if (!s->stream) return; /* do_close=0: with no reset due, maybe_reset's own close shuts only the read half. */ lsquic_stream_maybe_reset(s->stream, error_code, 0); lsquic_stream_close(s->stream); } -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. */ -void us_quic_stream_reset_internal_error(us_quic_stream_t *s) { - us_quic_stream_reset_with(s, 0x102); -} - -int us_quic_stream_has_unacked(us_quic_stream_t *s) { - return s->stream ? lsquic_stream_has_unacked_data(s->stream) : 0; -} - 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)); @@ -1323,7 +1468,6 @@ int us_quic_stream_headers_too_large(us_quic_stream_t *s) { } void *us_quic_socket_ext(us_quic_socket_t *s) { return s + 1; } -us_quic_socket_context_t *us_quic_socket_context(us_quic_socket_t *s) { return s->ctx; } void us_quic_socket_remote_address(us_quic_socket_t *s, char *buf, int *len, int *port, int *is_ipv6) { const struct sockaddr *local, *peer; @@ -1393,59 +1537,33 @@ static enum ssl_verify_result_t us_quic_client_verify(SSL *ssl, uint8_t *out_ale return ssl_verify_ok; } +void 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); +} + 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) { - SSL_CTX *ssl = SSL_CTX_new(TLS_method()); + /* No options: the default trust store and no client certificate. */ + enum create_bun_socket_error_t ssl_err = 0; + SSL_CTX *ssl = us_ssl_ctx_build_raw((struct us_bun_socket_context_options_t) {0}, &ssl_err); 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; } + us_quic_prepare_client_ssl_ctx(ssl); - us_quic_socket_context_t *ctx = (us_quic_socket_context_t *) - us_calloc(1, sizeof(us_quic_socket_context_t) + ext_size); - if (!ctx) { SSL_CTX_free(ssl); return NULL; } - ctx->loop = loop; - ctx->ssl_ctx = ssl; - ctx->is_client = 1; - ctx->conn_ext_size = conn_ext_size; - ctx->stream_ext_size = stream_ext_size; - - lsquic_engine_init_settings(&ctx->settings, LSENG_HTTP); - ctx->settings.es_versions = (1u << LSQVER_I001); - ctx->settings.es_ecn = 0; + struct lsquic_engine_settings settings; + lsquic_engine_init_settings(&settings, LSENG_HTTP); + settings.es_versions = (1u << LSQVER_I001); /* Same as the HTTP/2 client's LOCAL_MAX_HEADER_LIST_SIZE. */ - ctx->max_header_set_size = 256 * 1024; - ctx->settings.es_max_header_list_size = ctx->max_header_set_size; - ctx->settings.es_ext_http_prio = 0; - - struct lsquic_engine_api api; - memset(&api, 0, sizeof(api)); - api.ea_settings = &ctx->settings; - api.ea_stream_if = &us_quic_stream_if; - api.ea_stream_if_ctx = ctx; - api.ea_packets_out = us_quic_packets_out; - api.ea_packets_out_ctx = ctx; - api.ea_get_ssl_ctx = us_quic_get_ssl_ctx; - api.ea_hsi_if = &us_quic_hset_if; - api.ea_hsi_ctx = ctx; + settings.es_max_header_list_size = 256 * 1024; + settings.es_ext_http_prio = 0; - ctx->engine = lsquic_engine_new(LSENG_HTTP, &api); - if (!ctx->engine) { - SSL_CTX_free(ssl); - us_free(ctx); - return NULL; - } - - ctx->next = loop->data.quic_head; - loop->data.quic_head = ctx; + us_quic_socket_context_t *ctx = us_quic_context_create(loop, ssl, ext_size, LSENG_HTTP, &settings); + if (!ctx) return NULL; + ctx->conn_ext_size = conn_ext_size; + ctx->stream_ext_size = stream_ext_size; return ctx; } @@ -1455,37 +1573,21 @@ us_quic_socket_context_t *us_create_quic_client_context( * port can serve every outbound conn — fewer fds, and packets_out's sendmmsg * batches across origins. Dual-stack `::` reaches v4 peers via mapped * addresses; if the host has no v6 stack we fall back to a v4-only socket - * and refuse v6 connects. The endpoint lives for the context's lifetime - * (closed in context_free via the `listeners` list). */ -static us_quic_listen_socket_t *us_quic_client_endpoint(us_quic_socket_context_t *ctx) { + * and refuse v6 connects. The endpoint lives for the context's lifetime. */ +static us_quic_udp_t *us_quic_client_endpoint(us_quic_socket_context_t *ctx) { if (ctx->client_udp) return ctx->client_udp; - us_quic_listen_socket_t *ls = (us_quic_listen_socket_t *) us_calloc(1, sizeof(*ls)); - if (!ls) return NULL; - ls->ctx = ctx; int err = 0; - ls->udp = us_create_udp_socket(ctx->loop, - us_quic_udp_on_data, us_quic_udp_on_drain, us_quic_udp_on_close, NULL, - "::", 0, 0, &err, ls); - if (!ls->udp) { - err = 0; - ls->udp = us_create_udp_socket(ctx->loop, - us_quic_udp_on_data, us_quic_udp_on_drain, us_quic_udp_on_close, NULL, - "0.0.0.0", 0, 0, &err, ls); - } - if (!ls->udp) { us_free(ls); return NULL; } - us_quic_set_dontfrag(ls->udp); - socklen_t sl = sizeof(ls->local); - getsockname(us_poll_fd((struct us_poll_t *) ls->udp), (struct sockaddr *) &ls->local, &sl); - ls->next = ctx->listeners; - ctx->listeners = ls; - ctx->client_udp = ls; - return ls; + ctx->client_udp = us_quic_udp_create(ctx->driver, "::", 0, 0, &err, us_quic_udp_on_data, NULL); + if (!ctx->client_udp) + ctx->client_udp = us_quic_udp_create(ctx->driver, "0.0.0.0", 0, 0, &err, us_quic_udp_on_data, NULL); + return ctx->client_udp; } +/* `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); + us_quic_udp_t *ls = us_quic_client_endpoint(ctx); if (!ls) return NULL; /* lsquic's path comparison needs sa_local and peer to be the same family. */ @@ -1504,9 +1606,7 @@ static us_quic_socket_t *us_quic_connect_addr(us_quic_socket_context_t *ctx, return NULL; } - lsquic_conn_t *conn = lsquic_engine_connect(ctx->engine, N_LSQVER, - (struct sockaddr *) &ls->local, peer, ls, NULL, - sni, 0, NULL, 0, NULL, 0); + lsquic_conn_t *conn = us_quic_udp_connect(ls, ctx->engine, peer, ssl_ctx, NULL, sni, NULL, 0); if (!conn) return NULL; us_quic_socket_t *qs = (us_quic_socket_t *) lsquic_conn_get_ctx(conn); if (qs) { @@ -1519,11 +1619,6 @@ static us_quic_socket_t *us_quic_connect_addr(us_quic_socket_context_t *ctx, } } } - /* Don't us_quic_process here — the caller hasn't written its session - * pointer into the conn ext yet, and process_conns can fire on_hsk_done / - * on_conn_closed with that slot still NULL. pending_write_bytes++ ensures - * loop_pre sends the Initial flight on the very next tick. */ - ctx->pending_write_bytes++; return qs; } @@ -1537,7 +1632,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 +1666,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,14 +1677,16 @@ 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, - struct us_quic_pending_connect_s **out_pending, void *user) + int reject_unauthorized, SSL_CTX *ssl_ctx, + us_quic_socket_t **out_qs, + struct us_quic_pending_connect_s **out_pending) { *out_qs = NULL; *out_pending = NULL; @@ -1597,19 +1694,16 @@ int us_quic_socket_context_connect( 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); return *out_qs ? 1 : -1; } struct addrinfo_request *ai_req = NULL; - int cached = Bun__addrinfo_get(ctx->loop, host, (uint16_t) port, &ai_req) == 0; + int cached = Bun__addrinfo_get(ctx->driver->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); return *out_qs ? 1 : -1; } @@ -1625,16 +1719,13 @@ int us_quic_socket_context_connect( } pc->port = port; pc->reject_unauthorized = reject_unauthorized; + if (ssl_ctx) SSL_CTX_up_ref(ssl_ctx); + pc->ssl_ctx = ssl_ctx; pc->ai_req = ai_req; - pc->user = user; *out_pending = pc; return 0; } -void *us_quic_pending_connect_user(struct us_quic_pending_connect_s *pc) { - return pc->user; -} - struct addrinfo_request *us_quic_pending_connect_addrinfo( struct us_quic_pending_connect_s *pc) { return pc->ai_req; } @@ -1645,9 +1736,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 +1747,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); } @@ -1662,7 +1755,7 @@ void us_quic_pending_connect_cancel(struct us_quic_pending_connect_s *pc) { void us_quic_socket_make_stream(us_quic_socket_t *s) { if (!s->conn) return; lsquic_conn_make_stream(s->conn); - s->ctx->pending_write_bytes++; + s->ctx->driver->pending = 1; } unsigned us_quic_socket_streams_avail(us_quic_socket_t *s) { diff --git a/packages/bun-usockets/src/quic.h b/packages/bun-usockets/src/quic.h index 25343693f9fb..445bf27b203d 100644 --- a/packages/bun-usockets/src/quic.h +++ b/packages/bun-usockets/src/quic.h @@ -1,7 +1,7 @@ /* * QUIC / HTTP/3 transport for usockets, backed by lsquic. * - * One us_quic_socket_context_t per server (engine + UDP socket + timer + SSL). + * One us_quic_socket_context_t per server (driver + SSL). * No global state — multiple contexts can coexist on the same loop. * * The header-set API is per-stream: incoming headers are owned by the stream @@ -19,16 +19,72 @@ extern "C" { #endif +struct us_quic_driver_s; +struct us_quic_udp_s; struct us_quic_socket_context_s; -struct us_quic_listen_socket_s; struct us_quic_socket_s; struct us_quic_stream_s; +typedef struct us_quic_driver_s us_quic_driver_t; +typedef struct us_quic_udp_s us_quic_udp_t; typedef struct us_quic_socket_context_s us_quic_socket_context_t; -typedef struct us_quic_listen_socket_s us_quic_listen_socket_t; typedef struct us_quic_socket_s us_quic_socket_t; typedef struct us_quic_stream_s us_quic_stream_t; +struct sockaddr; +struct lsquic_engine; +struct lsquic_engine_settings; +struct lsquic_stream_if; +struct lsquic_hset_if; +struct lsquic_conn; + +/* ───── Driver: what every lsquic engine in Bun runs on (the HTTP/3 contexts below, and node:quic) ───── */ + +/* Before `loop` polls, after it has dispatched what the poll returned, and after a microtask drain. */ +enum us_quic_pass_point { US_QUIC_BEFORE_POLL, US_QUIC_AFTER_POLL, US_QUIC_AFTER_MICROTASKS }; + +/* Runs the owner's engines if they have work. Returns the µs until they want the next pass, or -1. It may free the driver. */ +typedef int (*us_quic_pass_fn)(us_quic_driver_t *d, enum us_quic_pass_point point); + +/* `pass` runs at each point, but after a microtask drain only if the driver is pending. */ +us_quic_driver_t *us_quic_driver_create(struct us_loop_t *loop, us_quic_pass_fn pass, void *owner); +/* Closes the driver's UDP sockets and destroys its engines. `pass` keeps running. */ +void us_quic_driver_close(us_quic_driver_t *d); +void us_quic_driver_free(us_quic_driver_t *d); +void *us_quic_driver_owner(us_quic_driver_t *d); +/* `flags` are LSENG_*. lsquic copies `settings`. The driver owns the engine. */ +struct lsquic_engine *us_quic_driver_add_engine(us_quic_driver_t *d, unsigned flags, + const struct lsquic_engine_settings *settings, + const struct lsquic_stream_if *stream_if, void *stream_if_ctx, + const struct lsquic_hset_if *hset_if, void *hset_ctx, + struct ssl_ctx_st *(*lookup_cert)(void *, const struct sockaddr *, const char *), void *lookup_cert_ctx); +/* lsquic_engine_process_conns on every engine. Does nothing while an engine is on the stack. */ +void us_quic_driver_process(us_quic_driver_t *d); +/* The soonest lsquic_engine_earliest_adv_tick in µs from now, or -1. */ +int us_quic_driver_next_tick_us(us_quic_driver_t *d); +/* The owner queued work in an engine. us_quic_driver_process clears it. */ +void us_quic_driver_set_pending(us_quic_driver_t *d); +int us_quic_driver_is_pending(us_quic_driver_t *d); +/* What a server engine starts a handshake with, before SNI. Borrowed. */ +void us_quic_driver_set_ssl_ctx(us_quic_driver_t *d, struct ssl_ctx_st *ssl_ctx); + +/* A UDP socket for the driver's engines, and the `peer_ctx` of every lsquic_engine_packet_in call for it. Its user data is the us_quic_udp_t. */ +us_quic_udp_t *us_quic_udp_create(us_quic_driver_t *d, const char *host, int port, int flags, int *err, + void (*on_data)(struct us_udp_socket_t *, void *, int), + void (*on_recv_error)(struct us_udp_socket_t *, int, int)); +void *us_quic_udp_owner(us_quic_udp_t *qu); +/* NULL once the fd has closed. */ +struct us_udp_socket_t *us_quic_udp_socket(us_quic_udp_t *qu); +/* The bound address, which is the `sa_local` of every lsquic_engine_packet_in call for this socket. */ +const struct sockaddr *us_quic_udp_local(us_quic_udp_t *qu); +/* Adds every datagram this socket sends to the two counters, which must outlive the driver's engines. */ +void us_quic_udp_count_sent(us_quic_udp_t *qu, uint64_t *packets, uint64_t *bytes); +/* lsquic_engine_connect from this socket. `ssl_ctx` is borrowed. */ +struct lsquic_conn *us_quic_udp_connect(us_quic_udp_t *qu, struct lsquic_engine *engine, const struct sockaddr *peer, + struct ssl_ctx_st *ssl_ctx, void *conn_ctx, const char *sni, const unsigned char *session_resume, size_t session_resume_len); + +/* ───── HTTP/3 contexts ───── */ + /* One name/value pair pointing into stream-owned storage. `qpack_index` is * an optional `enum lsqpack_tnv` hint (0..98) the caller may set on outgoing * headers so the QPACK encoder can skip its name-hash lookup; -1 = no hint. @@ -41,18 +97,16 @@ 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); - 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. */ @@ -62,13 +116,13 @@ int us_quic_socket_context_add_server_name(us_quic_socket_context_t *ctx, void *us_quic_socket_context_ext(us_quic_socket_context_t *ctx); struct us_loop_t *us_quic_socket_context_loop(us_quic_socket_context_t *ctx); -us_quic_listen_socket_t *us_quic_socket_context_listen( +us_quic_udp_t *us_quic_socket_context_listen( us_quic_socket_context_t *ctx, const char *host, int port, int flags, unsigned int stream_ext_size); -void us_quic_listen_socket_close(us_quic_listen_socket_t *ls); -int us_quic_listen_socket_port(us_quic_listen_socket_t *ls); -int us_quic_listen_socket_local_address(us_quic_listen_socket_t *ls, char *buf, int len); +void us_quic_listen_socket_close(us_quic_udp_t *ls); +int us_quic_listen_socket_port(us_quic_udp_t *ls); +int us_quic_listen_socket_local_address(us_quic_udp_t *ls, char *buf, int len); /* Client engine: one shared lsquic engine for all outbound connections on * `loop`. Per-connection cert verification is decided at connect time. */ @@ -79,18 +133,22 @@ us_quic_socket_context_t *us_create_quic_client_context( struct us_quic_pending_connect_s; struct addrinfo_request; +/* Limits `ssl_ctx`, which us_ssl_ctx_build_raw made, to TLS 1.3 and installs the verify step that reads each connection's reject_unauthorized. */ +void us_quic_prepare_client_ssl_ctx(struct ssl_ctx_st *ssl_ctx); + /* Open a QUIC connection to `host:port`. `host` may be an IP literal or * hostname; literals and DNS-cache hits connect synchronously (return 1, * *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. + * `ssl_ctx` (borrowed) went through us_quic_prepare_client_ssl_ctx, or is NULL for 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, - struct us_quic_pending_connect_s **out_pending, void *user); + int reject_unauthorized, struct ssl_ctx_st *ssl_ctx, + us_quic_socket_t **out_qs, + struct us_quic_pending_connect_s **out_pending); -void *us_quic_pending_connect_user(struct us_quic_pending_connect_s *pc); struct addrinfo_request *us_quic_pending_connect_addrinfo( struct us_quic_pending_connect_s *pc); us_quic_socket_t *us_quic_pending_connect_resolved( @@ -105,8 +163,6 @@ unsigned us_quic_socket_streams_avail(us_quic_socket_t *s); int us_quic_socket_status(us_quic_socket_t *s, char *buf, unsigned int len); /* Connection-level callbacks */ -void us_quic_socket_context_on_open(us_quic_socket_context_t *ctx, - void (*on_open)(us_quic_socket_t *)); /* Fires once the TLS handshake completes (client only). ok=0 means the * handshake failed; on_close follows shortly. */ void us_quic_socket_context_on_hsk_done(us_quic_socket_context_t *ctx, @@ -116,6 +172,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,27 +192,37 @@ 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); +/* 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 * header block follows separately. */ int us_quic_stream_send_informational(us_quic_stream_t *s, const char *status3); void us_quic_stream_shutdown(us_quic_stream_t *s); -void us_quic_stream_flush(us_quic_stream_t *s); void us_quic_stream_shutdown_read(us_quic_stream_t *s); void us_quic_stream_close(us_quic_stream_t *s); -void us_quic_stream_reset(us_quic_stream_t *s); -void us_quic_stream_reset_internal_error(us_quic_stream_t *s); -int us_quic_stream_has_unacked(us_quic_stream_t *s); +void us_quic_stream_reset(us_quic_stream_t *s, uint64_t error_code); +/* RFC 9114 §8.1: a message this endpoint can't send or can't finish. */ +#define US_QUIC_H3_INTERNAL_ERROR 0x102 /* RFC 9114 §8.1: the server did no application processing. */ #define US_QUIC_H3_REQUEST_REJECTED 0x10B +/* RFC 9114 §4.1.2: a malformed message. */ +#define US_QUIC_H3_MESSAGE_ERROR 0x10E /* 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. */ @@ -160,11 +232,9 @@ void *us_quic_stream_ext(us_quic_stream_t *s); us_quic_socket_t *us_quic_stream_socket(us_quic_stream_t *s); us_quic_socket_context_t *us_quic_stream_context(us_quic_stream_t *s); -/* Drive every QUIC engine on `loop` and re-arm the per-loop fallthrough - * timer to the soonest earliest_adv_tick. Called from us_internal_loop_post - * and from drainMicrotasks; cheap when quic_head is NULL. */ -void us_quic_loop_process(struct us_loop_t *loop); -/* Flush only if a stream wrote since the last process_conns. */ +/* Runs every driver on `loop` and stores the soonest next tick. Called from us_internal_loop_pre and us_internal_loop_post. */ +void us_quic_loop_process(struct us_loop_t *loop, enum us_quic_pass_point point); +/* Runs the drivers that are pending. Called after a microtask drain. */ void us_quic_loop_flush_if_pending(struct us_loop_t *loop); /* Incoming headers — valid from on_stream_headers until on_stream_close. */ @@ -177,7 +247,6 @@ int us_quic_stream_headers_too_large(us_quic_stream_t *s); /* Connection accessors */ void *us_quic_socket_ext(us_quic_socket_t *s); -us_quic_socket_context_t *us_quic_socket_context(us_quic_socket_t *s); void us_quic_socket_remote_address(us_quic_socket_t *s, char *buf, int *len, int *port, int *is_ipv6); void us_quic_socket_close(us_quic_socket_t *s); diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index b6f38d2d62f6..0b44e5bcdf35 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -98,35 +98,12 @@ 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; - } else { - s->timeout = 255; - } + s->timeout = us_internal_timeout_tick(s->group->loop, seconds); + s->low_prio_ticks = 0; } void us_connecting_socket_timeout(struct us_connecting_socket_t *c, unsigned int seconds) { - if (seconds) { - c->timeout = ((unsigned int)c->group->timestamp + ((seconds + 3) >> 2)) % 240; - } else { - c->timeout = 255; - } -} - -__attribute__((always_inline)) void us_socket_long_timeout(struct us_socket_t *s, unsigned int minutes) { - if (minutes) { - s->long_timeout = ((unsigned int)s->group->long_timestamp + minutes) % 240; - } else { - s->long_timeout = 255; - } -} - -void us_connecting_socket_long_timeout(struct us_connecting_socket_t *c, unsigned int minutes) { - if (minutes) { - c->long_timeout = ((unsigned int)c->group->long_timestamp + minutes) % 240; - } else { - c->long_timeout = 255; - } + c->timeout = us_internal_timeout_tick(c->loop, seconds); } __attribute__((always_inline)) void us_socket_flush(struct us_socket_t *s) { @@ -139,20 +116,6 @@ __attribute__((always_inline)) int us_socket_is_closed(struct us_socket_t *s) { return s->flags.is_closed; } -int us_socket_is_ssl_handshake_finished(struct us_socket_t *s) { - if (s->ssl) { - return us_internal_ssl_is_handshake_finished(s); - } - return 1; -} - -int us_socket_ssl_handshake_callback_has_fired(struct us_socket_t *s) { - if (s->ssl) { - return us_internal_ssl_handshake_callback_has_fired(s); - } - return 1; -} - unsigned int us_socket_ssl_spill_pending(struct us_socket_t *s) { if (s->ssl) { return us_internal_ssl_spill_pending(s); @@ -177,8 +140,14 @@ __attribute__((always_inline)) int us_socket_is_established(struct us_socket_t * return us_internal_poll_type((struct us_poll_t *) s) != POLL_TYPE_SEMI_SOCKET; } -int us_socket_ssl_send_rejected(struct us_socket_t *s) { - return s->ssl_send_rejected; +#ifdef _WIN32 +#define US_SEND_ERROR_BASE WSABASEERR +#else +#define US_SEND_ERROR_BASE 0 +#endif + +int us_socket_rejected_send_error(struct us_socket_t *s) { + return s->send_rejected ? US_SEND_ERROR_BASE + s->rejected_send_error : 0; } int us_socket_queued_input(struct us_socket_t *s) { @@ -267,9 +236,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); @@ -304,6 +273,7 @@ static struct us_socket_t *us_internal_socket_close_and_notify(struct us_socket_ s->next = 0; s->flags.low_prio_state = 0; s->group->low_prio_count--; + us_internal_disable_sweep_timer(loop); /* Mirror the else branch: if this was the last thing keeping the * group linked, drop it from the loop now rather than waiting for * the next link/unlink to notice. */ @@ -338,6 +308,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 @@ -378,20 +350,24 @@ __attribute__((always_inline)) struct us_socket_t *us_socket_close(struct us_soc struct us_socket_t *us_socket_close_now(struct us_socket_t *s) { /* The graceful close sends what it can and may then wait, for the spill or for the peer. A close defers at most once. */ - us_socket_close(s, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, NULL); + /* It ends with a close_notify, which says that the stream is whole. After a write that fell short the owner may hold the rest. */ + us_socket_close(s, s->ssl && s->flags.last_write_failed ? LIBUS_SOCKET_CLOSE_CODE_FAST_SHUTDOWN : LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, NULL); return us_socket_close(s, LIBUS_SOCKET_CLOSE_CODE_FAST_SHUTDOWN, NULL); } -struct us_socket_t *us_socket_pair(struct us_socket_group_t *group, unsigned char kind, int socket_ext_size, LIBUS_SOCKET_DESCRIPTOR *fds) { -#if defined(LIBUS_USE_LIBUV) || defined(WIN32) - return 0; -#else - if (socketpair(AF_UNIX, SOCK_STREAM, 0, fds) != 0) { - return 0; +struct us_socket_t *us_socket_close_lingering(struct us_socket_t *s) { + /* The graceful TLS close waits for the peer's close_notify in the same way. After the peer's FIN there is nothing to wait for. */ + if (s->ssl || s->read_eof || us_socket_is_closed(s) || !us_socket_is_established(s)) { + return us_socket_close(s, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, NULL); } - - return us_socket_from_fd(group, kind, NULL, socket_ext_size, fds[0], 0, 0); -#endif + us_internal_socket_raw_shutdown(s); + /* Only a read sees the peer's FIN. */ + us_socket_resume(s); + us_socket_timeout(s, LIBUS_LINGER_SECONDS); + /* The process doesn't stay alive for it. */ + us_socket_unref(s); + s->lingering = 1; + return s; } /* Re-arm writable for a backpressured write without resuming the read side of @@ -403,22 +379,47 @@ 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); +} + +void us_internal_socket_reject_send(struct us_socket_t *s, int error) { + /* A connect in progress reports its own failure. */ + if (s->send_rejected || !us_socket_is_established(s)) { + return; + } + /* 0 to 2 are close codes. */ + int code = error - US_SEND_ERROR_BASE; + s->send_rejected = 1; + s->rejected_send_error = code > 2 && code <= UINT8_MAX ? code : LIBUS_ECONNRESET - US_SEND_ERROR_BASE; + us_internal_socket_raw_shutdown(s); + us_internal_rearm_writable(s); +} + +struct us_socket_t *us_internal_socket_close_after_rejected_send(struct us_socket_t *s) { + /* An owner that already let go reads nothing. */ + int unread = !s->ssl_close_after_spill && us_socket_queued_input(s) == LIBUS_QUEUED_INPUT_DATA; + if (s->ssl) { + return us_internal_ssl_close_after_rejected_send(s, unread); + } + return unread ? s : us_internal_socket_close_raw(s, LIBUS_ECONNRESET, NULL); +} + /* 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. */ + * take any failed send for backpressure. */ static ssize_t us_internal_reject_excess_send(struct us_socket_t *s, ssize_t written, size_t length) { if (written < 0 || (size_t) written <= length) { return written; } - us_internal_socket_raw_shutdown(s); - if (s->ssl) { - s->ssl_send_rejected = 1; - } else { - s->send_rejected = 1; - } - us_internal_rearm_writable(s); #ifdef _WIN32 + us_internal_socket_reject_send(s, WSAECONNABORTED); WSASetLastError(WSAECONNABORTED); +#else + us_internal_socket_reject_send(s, ECONNABORTED); #endif errno = ECONNABORTED; return -1; @@ -438,6 +439,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; @@ -454,33 +471,23 @@ int us_socket_write2(struct us_socket_t *s, const char *header, int header_lengt } struct us_socket_t *us_socket_from_fd(struct us_socket_group_t *group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, int socket_ext_size, LIBUS_SOCKET_DESCRIPTOR fd, int options, int ipc) { + if (bsd_adopt_socket(fd, 0)) { + return 0; + } struct us_poll_t *p1 = us_create_poll(group->loop, 0, sizeof(struct us_socket_t) + socket_ext_size); us_poll_init(p1, fd, POLL_TYPE_SOCKET); int open_paused = (options & LIBUS_SOCKET_OPEN_PAUSED) && !ssl_ctx; int rc = us_poll_start_rc(p1, group->loop, (open_paused ? 0 : LIBUS_SOCKET_READABLE) | LIBUS_SOCKET_WRITABLE); if (rc != 0) { + bsd_disown_socket(fd); us_poll_free(p1, group->loop); return 0; } struct us_socket_t *s = (struct us_socket_t *) p1; - s->group = group; - s->kind = kind; - s->ssl = NULL; - s->timeout = 255; - s->long_timeout = 255; - s->flags.low_prio_state = 0; - s->flags.allow_half_open = (options & LIBUS_SOCKET_ALLOW_HALF_OPEN) != 0; + us_internal_socket_init(s, group, kind, options & LIBUS_SOCKET_ALLOW_HALF_OPEN); s->flags.is_paused = open_paused; s->flags.is_ipc = ipc; - s->flags.is_closed = 0; - s->flags.adopted = 0; - s->flags.last_write_failed = 0; - s->unclassified_send_failures = 0; - s->read_eof = 0; - s->hangup_closes_unsent = 0; - s->send_rejected = 0; - s->connect_state = NULL; /* We always use nodelay */ bsd_socket_nodelay(fd, 1); @@ -591,10 +598,8 @@ static int us_internal_classify_failed_send(struct us_socket_t *s) { } return errno; #else - /* Windows: report the raw (positive) WSA code. The JS layer already - * traffics in raw negative WSA values on this platform (see - * SocketEmitEndNT / failWrite, which shape unnameable ones as - * ECONNRESET), and a real code keeps fatal sends distinguishable from + /* Windows: report the raw (positive) WSA code, which SocketGroup::errno + * decodes. A real code keeps fatal sends distinguishable from * the legacy -1 closed/shutdown sentinel so the h2 parser can latch * them (flood tests hung on Windows because a fatal send looked like * backpressure). Keep 1 as the floor for a zero/garbage WSA value. @@ -624,8 +629,7 @@ int us_socket_write_check_error(struct us_socket_t *s, const char *data, int len us_internal_rearm_writable(s); return 0; } - /* Fatal send error: report the code to callers that opt in and stop - * polling writable - retrying can never succeed. */ + /* Only reported: an owner that gives up on its bytes calls us_internal_socket_reject_send. */ if (fatal_write_error) *fatal_write_error = fatal_send_error; return 0; } @@ -761,7 +765,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)); } } @@ -790,14 +802,7 @@ int us_connecting_socket_get_dns_error(struct us_connecting_socket_t *c) { return c->error_is_dns ? c->error : 0; } -struct us_socket_t *us_socket_open(struct us_socket_t *s, int is_client, char *ip, int ip_length) { - if (s->ssl) { - return us_internal_ssl_on_open(s, is_client, ip, ip_length); - } - return us_dispatch_open(s, is_client, ip, ip_length); -} - -unsigned int us_get_remote_address_info(char *buf, struct us_socket_t *s, const char **dest, int *port, int *is_ipv6) +unsigned int us_get_remote_address_info(char *buf, struct us_socket_t *s, int *port) { // This function is manual inlining + modification of // us_socket_remote_address @@ -819,24 +824,6 @@ unsigned int us_get_remote_address_info(char *buf, struct us_socket_t *s, const return length; } -unsigned int us_get_local_address_info(char *buf, struct us_socket_t *s, const char **dest, int *port, int *is_ipv6) -{ - struct bsd_addr_t addr; - if (bsd_local_addr(us_poll_fd(&s->p), &addr)) { - return 0; - } - - int length = bsd_addr_get_ip_length(&addr); - if (!length) { - return 0; - } - - memcpy(buf, bsd_addr_get_ip(&addr), length); - *port = bsd_addr_get_port(&addr); - - return length; -} - void us_socket_ref(struct us_socket_t *s) { #ifdef LIBUS_USE_LIBUV uv_ref((uv_handle_t *) s->p.uv_p); diff --git a/packages/bun-usockets/src/udp.c b/packages/bun-usockets/src/udp.c index efc74898f409..d69c02b13e08 100644 --- a/packages/bun-usockets/src/udp.c +++ b/packages/bun-usockets/src/udp.c @@ -175,7 +175,8 @@ struct us_udp_socket_t *us_create_udp_socket_from_fd( int *err, void *user ) { - if (bsd_prepare_adopted_udp_socket(fd)) { + int adopted = bsd_adopt_socket(fd, SOCK_DGRAM) == 0; + if (!adopted || bsd_prepare_adopted_udp_socket(fd)) { if (err != NULL) { #ifdef _WIN32 *err = WSAGetLastError(); @@ -183,6 +184,9 @@ struct us_udp_socket_t *us_create_udp_socket_from_fd( *err = errno; #endif } + if (adopted) { + bsd_disown_socket(fd); + } return 0; } @@ -211,6 +215,7 @@ struct us_udp_socket_t *us_create_udp_socket_from_fd( if (us_poll_start_rc((struct us_poll_t *) udp, udp->loop, LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE) != 0) { int saved_errno = errno; + bsd_disown_socket(fd); us_poll_free(p, loop); if (err) *err = saved_errno; errno = saved_errno; @@ -235,8 +240,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..88b3a491b950 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -58,59 +58,20 @@ namespace uWS { namespace uWS { - /* This one matches us_socket_context_options_t but has default values */ - struct SocketContextOptions { - const char *key_file_name = nullptr; - const char *cert_file_name = nullptr; - const char *passphrase = nullptr; - const char *dh_params_file_name = nullptr; - const char *ca_file_name = nullptr; - const char *ssl_ciphers = nullptr; - int ssl_prefer_low_memory_usage = 0; - - const char **key = nullptr; - unsigned int key_count = 0; - const char **cert = nullptr; - unsigned int cert_count = 0; - const char **ca = nullptr; - unsigned int ca_count = 0; - unsigned int secure_options = 0; - int ssl_min_version = 0; - int ssl_max_version = 0; - int reject_unauthorized = 0; - int request_cert = 0; - unsigned int client_renegotiation_limit = 3; - unsigned int client_renegotiation_window = 600; - int session_timeout = 0; - const char **crl = nullptr; - unsigned int crl_count = 0; - int allow_partial_trust_chain = 0; - const char *sigalgs = nullptr; - const char *ecdh_curve = nullptr; - - /* Conversion operator used internally */ - operator struct us_bun_socket_context_options_t() const { - struct us_bun_socket_context_options_t socket_context_options; - memcpy(&socket_context_options, this, sizeof(SocketContextOptions)); - return socket_context_options; - } - }; - - static_assert(sizeof(struct us_bun_socket_context_options_t) == sizeof(SocketContextOptions), "Mismatching uSockets/uWebSockets ABI"); - template struct TemplatedApp { private: /* 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. */ + /* Shared SSL_CTX for every accepted socket. nullptr for plain HTTP. 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. */ struct PendingServerName { std::string hostname; struct ssl_ctx_st *ctx; + bool ownClientAuth; }; std::vector pendingServerNames; /* No raw us_listen_socket_t* cache here. src/runtime/server/mod.rs's non-abrupt stop calls @@ -137,53 +98,20 @@ struct TemplatedApp { TopicTree *topicTree = nullptr; - /* Server name */ - TemplatedApp &&addServerName(const std::string &hostname_pattern, SocketContextOptions options = {}, bool *success = nullptr, bool applyClientCertPolicy = false) { - - /* Do nothing if not even on SSL */ - if constexpr (SSL) { - enum create_bun_socket_error_t err = CREATE_BUN_SOCKET_ERROR_NONE; - struct ssl_ctx_st *domainCtx = us_ssl_ctx_from_options(options, &err); - if (!domainCtx) { - if (success) *success = false; - return std::move(*this); - } - /* A per-serverName entry carries its own client-certificate - * policy; the default entry's own hostname keeps the app-level - * one. */ - if (applyClientCertPolicy) { - us_ssl_ctx_set_sni_policy(domainCtx, options.request_cert, options.reject_unauthorized); - } - if (httpContext->getSocketContextData()->http2Context) { - us_ssl_ctx_enable_http2_alpn(domainCtx, httpContext->getSocketContextData()->allowHttp1); - } - forEachListenSocket([&](us_listen_socket_t *ls) { - us_listen_socket_add_server_name(ls, hostname_pattern.c_str(), domainCtx); - }); - /* Queue for any listeners not yet created. We hold one SSL_CTX ref; - * each listen socket took its own via SSL_CTX_up_ref. */ - pendingServerNames.push_back({hostname_pattern, domainCtx}); - if (success) *success = true; - } - - return std::move(*this); - } + /* Server name. See us_listen_socket_add_server_name for ownClientAuth. */ + TemplatedApp &&addServerName(const std::string &hostname_pattern, struct ssl_ctx_st *domainCtx, bool ownClientAuth) { + static_assert(SSL); + forEachListenSocket([&](us_listen_socket_t *ls) { + us_listen_socket_add_server_name(ls, hostname_pattern.c_str(), domainCtx, ownClientAuth); + }); + /* Queue for any listeners not yet created. We hold one SSL_CTX ref; + * each listen socket took its own via SSL_CTX_up_ref. */ + us_internal_ssl_ctx_up_ref(domainCtx); + pendingServerNames.push_back({hostname_pattern, domainCtx, ownClientAuth}); - TemplatedApp &&missingServerName(MoveOnlyFunction &&handler) { - if (!constructorFailed()) { - httpContext->getSocketContextData()->missingServerNameHandler = std::move(handler); - forEachListenSocket([&](us_listen_socket_t *ls) { - us_listen_socket_on_server_name(ls, &onMissingServerName); - }); - } return std::move(*this); } - /* Returns the SSL_CTX* of this app, or nullptr. */ - void *getNativeHandle() { - return sslCtx; - } - /* Attaches a "filter" function to track socket connections/disconnections */ TemplatedApp &&filter(MoveOnlyFunction *, int)> &&filterHandler) { httpContext->filter(std::move(filterHandler)); @@ -245,16 +173,14 @@ struct TemplatedApp { ~TemplatedApp() { /* Let's just put everything here */ - if (httpContext) { - if (Http2Context *h2 = httpContext->getSocketContextData()->http2Context) { - h2->detach(httpContext->getSocketContextData()); - } - httpContext->free(); + if (Http2Context *h2 = httpContext->getSocketContextData()->http2Context) { + h2->detach(httpContext->getSocketContextData()); + } + httpContext->free(); - /* Free all our webSocketContexts in a type less way */ - for (auto &webSocketContextDeleter : webSocketContextDeleters) { - webSocketContextDeleter(); - } + /* Free all our webSocketContexts in a type less way */ + for (auto &webSocketContextDeleter : webSocketContextDeleters) { + webSocketContextDeleter(); } if constexpr (SSL) { @@ -287,43 +213,14 @@ struct TemplatedApp { TemplatedApp(TemplatedApp &&other) = delete; private: - static struct ssl_ctx_st *onMissingServerName(struct us_listen_socket_t *ls, const char *hostname, int *abort_handshake, struct us_socket_t *socket) { - /* Bun.serve's missingServerName handler registers a context or lets the - * default serve the request - it never aborts or suspends the handshake. */ - (void) abort_handshake; - (void) socket; - if (!ls) return nullptr; - auto *httpContext = (HttpContext *) us_socket_group_ext(us_listen_socket_group(ls)); - httpContext->getSocketContextData()->missingServerNameHandler(hostname); - /* The handler is expected to have registered the name via - * addServerName(); hand the newly-registered context back so the - * in-flight handshake uses it (the resolver no longer re-checks the - * SNI tree after this callback returns). The handler may also have - * closed the listener, freeing the tree. */ - return us_listen_socket_find_server_name_ctx(ls, hostname); - } - - TemplatedApp(SocketContextOptions options) { - if constexpr (SSL) { - enum create_bun_socket_error_t err = CREATE_BUN_SOCKET_ERROR_NONE; - sslCtx = us_ssl_ctx_from_options(options, &err); - if (!sslCtx) { httpContext = nullptr; return; } - } - httpContext = HttpContext::create(Loop::get(), options.request_cert, options.reject_unauthorized); + /* `ctx` is what every accepted socket starts from, nullptr exactly when !SSL. */ + TemplatedApp(struct ssl_ctx_st *ctx) : httpContext(HttpContext::create(Loop::get())), sslCtx(ctx) { + if constexpr (SSL) us_internal_ssl_ctx_up_ref(ctx); } public: - static TemplatedApp* create(SocketContextOptions options = {}) { - auto *app = new TemplatedApp(options); - if (app->constructorFailed()) { - delete app; - return nullptr; - } - return app; - } - - bool constructorFailed() { - return !httpContext; + static TemplatedApp* create(struct ssl_ctx_st *ctx) { + return new TemplatedApp(ctx); } template @@ -341,8 +238,6 @@ struct TemplatedApp { bool resetIdleTimeoutOnSend = false; /* A good default, esp. for newcomers */ bool sendPingsAutomatically = true; - /* Maximum socket lifetime in minutes before forced closure (defaults to disabled) */ - unsigned short maxLifetime = 0; /* See WebSocketContextData */ bool wsPackage = false; bool autoPong = true; @@ -356,11 +251,27 @@ struct TemplatedApp { MoveOnlyFunction *, int, std::string_view)> close = nullptr; }; - /* Closes all sockets including listen sockets. */ - TemplatedApp &&close() { + /* Closes all sockets including listen sockets. `thread_ends`: see us_socket_group_close_all_ex. */ + TemplatedApp &&close(int thread_ends) { + /* A close handler can tick the loop (waitForPromise). That tick must not free the sockets closed here, which the walks below still read. */ + us_loop_t *loop = httpContext->getSocketGroup()->loop; + loop->data.tick_depth++; + if constexpr (SSL) { + /* The group's close sends a close_notify ahead of the reset. Not over a response that isn't finished or isn't sent. */ + std::vector sockets; + for (us_socket_t *s = thread_ends ? nullptr : httpContext->getSocketGroup()->head_sockets; s; s = s->next) { + sockets.push_back(s); + } + for (us_socket_t *s : sockets) { + auto *data = (HttpResponseData *) ((AsyncSocket *) s)->getAsyncSocketData(); + if (!us_socket_is_closed(s) && ((data->state & HttpResponseData::HTTP_RESPONSE_PENDING) || !((AsyncSocket *) s)->hasFullyDrained())) { + us_socket_close(s, LIBUS_SOCKET_CLOSE_CODE_CONNECTION_RESET, nullptr); + } + } + } /* close_all() walks head_listen_sockets first, so listeners are closed * here without us holding raw pointers to them across loop ticks. */ - us_socket_group_close_all(httpContext->getSocketGroup()); + us_socket_group_close_all_ex(httpContext->getSocketGroup(), 1, thread_ends); for (us_socket_group_t *g : webSocketGroups) { /* Snapshot the list first: end() runs the close handler, which can close other sockets in the group */ std::vector sockets; @@ -372,11 +283,12 @@ struct TemplatedApp { ((WebSocket *) s)->end(1001, "Server closed"); } } - us_socket_group_close_all(g); + us_socket_group_close_all_ex(g, 1, thread_ends); } if (Http2Context *h2 = httpContext->getSocketContextData()->http2Context) { - h2->closeAll(); + h2->closeAll(thread_ends); } + loop->data.tick_depth--; return std::move(*this); } @@ -387,18 +299,15 @@ struct TemplatedApp { void attachHttp2(Http2Context *ctx, bool allowHttp1) { ctx->attach(httpContext->getSocketContextData(), allowHttp1); if constexpr (SSL) { - us_ssl_ctx_enable_http2_alpn(sslCtx, allowHttp1); - /* Bun attaches before addServerName(), which enables ALPN itself; - * this covers embedders that add names first. */ - for (auto &p : pendingServerNames) { - us_ssl_ctx_enable_http2_alpn(p.ctx, allowHttp1); - } + forEachListenSocket([&](us_listen_socket_t *ls) { + offerHttp2(ls); + }); } } /** Closes all connections connected to this server which are not sending a request or waiting for a response. Does not close the listen socket. * With closeWhenIdle set, connections that are busy right now are marked to close as soon as their in-flight work completes (graceful shutdown); - * upgraded WebSockets and CONNECT/Upgrade tunnels never become idle, so they are left alone either way. + * upgraded WebSockets are in another group, so they are left alone either way. * Returns the number of connections closed. */ size_t closeIdle(bool closeWhenIdle = false) { auto *group = httpContext->getSocketGroup(); @@ -543,7 +452,6 @@ struct TemplatedApp { webSocketContext->getExt()->closeOnBackpressureLimit = behavior.closeOnBackpressureLimit; webSocketContext->getExt()->resetIdleTimeoutOnSend = behavior.resetIdleTimeoutOnSend; webSocketContext->getExt()->sendPingsAutomatically = behavior.sendPingsAutomatically; - webSocketContext->getExt()->maxLifetime = behavior.maxLifetime; webSocketContext->getExt()->compression = behavior.compression; webSocketContext->getExt()->wsPackage = behavior.wsPackage; webSocketContext->getExt()->autoPong = behavior.autoPong; @@ -555,7 +463,7 @@ struct TemplatedApp { public: /* Returns the `ws` package's context for these options (maxPayloadLength, compression, autoPong, skipUTF8Validation), - * creating it on first use. It isn't bound to a route; pass it to HttpResponse::upgradeRaw(). + * creating it on first use. It isn't bound to a route: it takes sockets with adoptSocket(). * Every call must pass the same handlers. */ template WebSocketContext *wsPackageContext(WebSocketBehavior &&behavior) { @@ -575,7 +483,6 @@ struct TemplatedApp { behavior.resetIdleTimeoutOnSend = false; behavior.maxBackpressure = 0; behavior.closeOnBackpressureLimit = false; - behavior.maxLifetime = 0; auto *webSocketContext = createWebSocketContext(nullptr, behavior); applyWebSocketSettings(webSocketContext, behavior); @@ -590,21 +497,6 @@ struct TemplatedApp { return std::move(*this); } - /* Terminate on misleading idleTimeout values */ - if (behavior.idleTimeout && behavior.idleTimeout < 8) { - std::terminate(); - } - - /* Maximum idleTimeout is 16 minutes */ - if (behavior.idleTimeout > 240 * 4) { - std::terminate(); - } - - /* Maximum maxLifetime is 4 hours */ - if (behavior.maxLifetime > 240) { - std::terminate(); - } - WebSocketContext *webSocketContext = nullptr; for (auto &[contextKey, context] : webSocketContexts) { if (contextKey == key) { @@ -659,80 +551,58 @@ struct TemplatedApp { } TemplatedApp &&get(std::string_view pattern, MoveOnlyFunction *, HttpRequest *)> &&handler) { - if (httpContext) { - httpContext->onHttp("GET", pattern, std::move(handler)); - } + httpContext->onHttp("GET", pattern, std::move(handler)); return std::move(*this); } TemplatedApp &&post(std::string_view pattern, MoveOnlyFunction *, HttpRequest *)> &&handler) { - if (httpContext) { - httpContext->onHttp("POST", pattern, std::move(handler)); - } + httpContext->onHttp("POST", pattern, std::move(handler)); return std::move(*this); } TemplatedApp &&options(std::string_view pattern, MoveOnlyFunction *, HttpRequest *)> &&handler) { - if (httpContext) { - httpContext->onHttp("OPTIONS", pattern, std::move(handler)); - } + httpContext->onHttp("OPTIONS", pattern, std::move(handler)); return std::move(*this); } TemplatedApp &&del(std::string_view pattern, MoveOnlyFunction *, HttpRequest *)> &&handler) { - if (httpContext) { - httpContext->onHttp("DELETE", pattern, std::move(handler)); - } + httpContext->onHttp("DELETE", pattern, std::move(handler)); return std::move(*this); } TemplatedApp &&patch(std::string_view pattern, MoveOnlyFunction *, HttpRequest *)> &&handler) { - if (httpContext) { - httpContext->onHttp("PATCH", pattern, std::move(handler)); - } + httpContext->onHttp("PATCH", pattern, std::move(handler)); return std::move(*this); } TemplatedApp &&put(std::string_view pattern, MoveOnlyFunction *, HttpRequest *)> &&handler) { - if (httpContext) { - httpContext->onHttp("PUT", pattern, std::move(handler)); - } + httpContext->onHttp("PUT", pattern, std::move(handler)); return std::move(*this); } void clearRoutes() { - if (httpContext) { - httpContext->getSocketContextData()->clearRoutes(); - } + httpContext->getSocketContextData()->clearRoutes(); } TemplatedApp &&head(std::string_view pattern, MoveOnlyFunction *, HttpRequest *)> &&handler) { - if (httpContext) { - httpContext->onHttp("HEAD", pattern, std::move(handler)); - } + httpContext->onHttp("HEAD", pattern, std::move(handler)); return std::move(*this); } TemplatedApp &&connect(std::string_view pattern, MoveOnlyFunction *, HttpRequest *)> &&handler) { - if (httpContext) { - httpContext->onHttp("CONNECT", pattern, std::move(handler)); - } + httpContext->onHttp("CONNECT", pattern, std::move(handler)); return std::move(*this); } TemplatedApp &&trace(std::string_view pattern, MoveOnlyFunction *, HttpRequest *)> &&handler) { - if (httpContext) { - httpContext->onHttp("TRACE", pattern, std::move(handler)); - } + httpContext->onHttp("TRACE", pattern, std::move(handler)); return std::move(*this); } /* This one catches any method */ TemplatedApp &&any(std::string_view pattern, MoveOnlyFunction *, HttpRequest *)> &&handler) { - if (httpContext) { - httpContext->onHttp("*", pattern, std::move(handler)); - } + httpContext->onHttp("*", pattern, std::move(handler)); return std::move(*this); } @@ -744,71 +614,45 @@ struct TemplatedApp { if (!ls) return nullptr; if constexpr (SSL) { for (auto &p : pendingServerNames) { - us_listen_socket_add_server_name(ls, p.hostname.c_str(), p.ctx); - } - if (httpContext->getSocketContextData()->missingServerNameHandler) { - us_listen_socket_on_server_name(ls, &onMissingServerName); + us_listen_socket_add_server_name(ls, p.hostname.c_str(), p.ctx, p.ownClientAuth); } + offerHttp2(ls); } return ls; } - 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)); + /* With an HTTP/2 context attached, TLS connections prefer "h2", then "http/1.1" if HTTP/1 is allowed. */ + void offerHttp2(us_listen_socket_t *ls) { + auto *data = httpContext->getSocketContextData(); + if (data->http2Context) { + us_listen_socket_set_alpn(ls, (const unsigned char *) "\x02h2\x08http/1.1", data->allowHttp1 ? 12 : 3, !data->allowHttp1); } - 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); - } + struct ssl_ctx_st *sslCtxOrNull() { return SSL ? sslCtx : 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); +public: + /* 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; } - /* 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); + 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; } - void setOnSocketClosed(HttpContextData::OnSocketClosedCallback onClose) { - httpContext->getSocketContextData()->onSocketClosed = onClose; - } - void setOnSocketDrain(HttpContextData::OnSocketDrainCallback onDrain) { - httpContext->getSocketContextData()->onSocketDrain = onDrain; - } - void setOnSocketData(HttpContextData::OnSocketDataCallback onData) { - httpContext->getSocketContextData()->onSocketData = onData; + /* already-bound descriptor, backlog, options, errno out */ + us_listen_socket_t *listen_fd(LIBUS_SOCKET_DESCRIPTOR fd, int options, int *error) { + return httpContext ? trackListenSocket(httpContext->listen_fd(sslCtxOrNull(), fd, options, error)) : nullptr; } void setOnClientError(HttpContextData::OnClientErrorCallback onClientError) { httpContext->getSocketContextData()->onClientError = std::move(onClientError); } - void setOnSocketUpgraded(HttpContextData::OnSocketUpgradedCallback onUpgraded) { - httpContext->getSocketContextData()->onSocketUpgraded = onUpgraded; - } - /* Switch this app into node:http compat mode (see HttpContext::enableNodeHttpCompat). */ - void enableNodeHttpCompat() { - httpContext->enableNodeHttpCompat(); - } - - TemplatedApp &&run() { - uWS::run(); - return std::move(*this); + void enableNodeHttpCompat(const NodeHttpSocketHooks *hooks) { + httpContext->enableNodeHttpCompat(hooks); } TemplatedApp &&setUsingCustomExpectHandler(bool value) { @@ -818,20 +662,28 @@ struct TemplatedApp { /* lenientHttpFlags: bit 0 = lenient header values (llhttp LENIENT_HEADERS), * bit 1 = lenient transfer-encoding (llhttp LENIENT_TRANSFER_ENCODING). */ - TemplatedApp &&setFlags(bool requireHostHeader, bool useStrictMethodValidation, uint8_t lenientHttpFlags, bool httpAllowHalfOpen) { - httpContext->getSocketContextData()->flags.requireHostHeader = requireHostHeader; - httpContext->getSocketContextData()->flags.useStrictMethodValidation = useStrictMethodValidation; + TemplatedApp &&setFlags(uint8_t lenientHttpFlags, bool httpAllowHalfOpen) { httpContext->getSocketContextData()->flags.useInsecureHTTPParser = (lenientHttpFlags & 1) != 0; httpContext->getSocketContextData()->flags.useLenientTransferEncoding = (lenientHttpFlags & 2) != 0; httpContext->getSocketContextData()->flags.httpAllowHalfOpen = httpAllowHalfOpen; return std::move(*this); } + TemplatedApp &&setIdleTimeout(uint8_t seconds) { + httpContext->getSocketContextData()->idleTimeout = seconds; + return std::move(*this); + } + TemplatedApp &&setMaxHTTPHeaderSize(uint64_t maxHeaderSize) { httpContext->getSocketContextData()->maxHeaderSize = maxHeaderSize; 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/AsyncSocket.h b/packages/bun-uws/src/AsyncSocket.h index 8aede4bbd93d..11af74215028 100644 --- a/packages/bun-uws/src/AsyncSocket.h +++ b/packages/bun-uws/src/AsyncSocket.h @@ -213,28 +213,6 @@ struct AsyncSocket { return true; } - /* Returns the text representation of an IPv4 or IPv6 address */ - std::string_view addressAsText(std::string_view binary) { - static thread_local char buf[64]; - int ipLength = 0; - - if (!binary.length()) { - return {}; - } - - unsigned char *b = (unsigned char *) binary.data(); - - if (binary.length() == 4) { - ipLength = snprintf(buf, sizeof(buf), "%u.%u.%u.%u", b[0], b[1], b[2], b[3]); - } else { - ipLength = snprintf(buf, sizeof(buf), "%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x", - b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], - b[12], b[13], b[14], b[15]); - } - - return {buf, (unsigned int) ipLength}; - } - /* Returns the remote IP address or empty string on failure */ std::string_view getRemoteAddress() { static thread_local char buf[16]; @@ -243,11 +221,6 @@ struct AsyncSocket { return std::string_view(buf, (unsigned int) ipLength); } - /* Returns the text representation of IP */ - std::string_view getRemoteAddressAsText() { - return addressAsText(getRemoteAddress()); - } - /** * Flushes the socket buffer by writing as much data as possible to the underlying socket. * @@ -305,6 +278,7 @@ struct AsyncSocket { return total_written; } + /* Room for `length` bytes in this socket's cork buffer, which the caller fills. nullptr if write() has to take them. */ char *reserveInCorkBuffer(size_t length) { if (!length || length > LoopData::CORK_COPY_MAX || us_socket_is_closed((us_socket_t *) this) || getAsyncSocketData()->buffer.length()) { return nullptr; diff --git a/packages/bun-uws/src/AsyncSocketData.h b/packages/bun-uws/src/AsyncSocketData.h index 9df9166ff664..27c9b6568fa8 100644 --- a/packages/bun-uws/src/AsyncSocketData.h +++ b/packages/bun-uws/src/AsyncSocketData.h @@ -162,10 +162,7 @@ struct AsyncSocketData { /* Or empty */ AsyncSocketData() = default; - const char *peerCertVerifyErrorCode = nullptr; bool isIdle = false; - bool isAuthorized = false; // per-socket TLS authorization status - bool peerCertVerified = false; /* Whether this socket has fired the context filter with +1. onClose and * upgrade only fire -1 for sockets that did, so the filter stays balanced * across every open/close path (TLS sockets that RST before the handshake, @@ -176,9 +173,8 @@ struct AsyncSocketData { * same paths that balance +1. A filter that must account for every socket * 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. */ - bool filteredIdleTunnel = false; + /* Whether it has fired the filter with -3 (HttpResponse::setParked). +3 undoes it, and a parked socket fires -4 in place of -2. */ + bool filteredParked = false; }; } diff --git a/packages/bun-uws/src/ChunkedEncoding.h b/packages/bun-uws/src/ChunkedEncoding.h index 567fc941f678..e7327b1b454b 100644 --- a/packages/bun-uws/src/ChunkedEncoding.h +++ b/packages/bun-uws/src/ChunkedEncoding.h @@ -185,7 +185,7 @@ namespace uWS { state == STATE_IS_CHUNK_TERMINATOR_ERROR; } - /* node:http compat: parser state for "consuming the trailer section after the + /* Parser state for "consuming the trailer section after the * final 0-size chunk" (RFC 9112 7.1.2). The flag combinations cannot occur in * any other state: STATE_WAITING_FOR_LF is only ever set while the chunk-size * line is incomplete, i.e. before STATE_HAS_SIZE is set. _DONE means the @@ -214,37 +214,38 @@ 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, std::string *trailerSection, 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]] { + /* Consume the trailer section (RFC 9112 7.1.2) that follows the final + * 0-size chunk, capturing its raw bytes so the caller can validate them + * (and node:http can populate req.trailers). The terminating empty chunk + * (fin) is only emitted once the whole section (including its final CRLF) + * has been consumed. */ + if (state == STATE_IS_TRAILERS) [[unlikely]] { while (data.length()) { char c = data[0]; trailerSection->push_back(c); data.remove_prefix(1); - if (trailerSection->size() > maxTrailerSectionSize) [[unlikely]] { + size_t n = trailerSection->size(); + if (n > maxTrailerSectionSize) [[unlikely]] { state = STATE_IS_TRAILER_OVERFLOW; return std::nullopt; } + const bool afterCR = n >= 2 && (*trailerSection)[n - 2] == '\r'; 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 (!afterCR) { state = STATE_IS_ERROR; return std::nullopt; } @@ -254,39 +255,13 @@ namespace uWS { state = STATE_IS_TRAILERS_DONE; return std::string_view(nullptr, 0); } - } - } - 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; - } - data.remove_prefix(1); - decChunkSize(state, 1); - - if (chunkSize(state) == 0) { - /* Terminator fully consumed — parsing is complete. */ - state = 0; + } else if (afterCR) { + /* A CR without LF fails here too, or "0\r\n\rX" waits for bytes forever. */ + state = STATE_IS_CHUNK_TERMINATOR_ERROR; return std::nullopt; } } - continue; + return std::nullopt; } if (!hasChunkSize(state)) { @@ -295,25 +270,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 = STATE_IS_TRAILERS; + continue; } if (!hasChunkSize(state)) [[unlikely]] { /* Incomplete chunk-size line — need more data from the network. */ @@ -419,16 +378,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, std::string *trailerSection, 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 +410,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/Http2App.h b/packages/bun-uws/src/Http2App.h index 40ab5dda54e1..c83470e60b76 100644 --- a/packages/bun-uws/src/Http2App.h +++ b/packages/bun-uws/src/Http2App.h @@ -14,9 +14,8 @@ struct H2App { Http2Context *http2Context; template - static H2App *create(TemplatedApp *parent, bool allowHttp1, unsigned idleTimeoutSecs = 0) { - if (!parent || parent->constructorFailed()) return nullptr; - Http2Context *ctx = Http2Context::create(Loop::get(), idleTimeoutSecs); + static H2App *create(TemplatedApp *parent, bool allowHttp1, unsigned idleTimeoutSecs, uint64_t maxRequestBodySize) { + Http2Context *ctx = Http2Context::create(Loop::get(), idleTimeoutSecs, maxRequestBodySize); parent->attachHttp2(ctx, allowHttp1); return new H2App{ctx}; } @@ -51,8 +50,6 @@ struct H2App { void clearRoutes() { http2Context->clearRoutes(); } /* GOAWAY + close every connection. */ - void close() { http2Context->closeAll(); } - void *getNativeHandle() { return http2Context; } }; } diff --git a/packages/bun-uws/src/Http2Context.h b/packages/bun-uws/src/Http2Context.h index 06c3a55b4a62..25765b9ab1c1 100644 --- a/packages/bun-uws/src/Http2Context.h +++ b/packages/bun-uws/src/Http2Context.h @@ -157,70 +157,6 @@ static inline unsigned char *hpackEncodeInteger(unsigned char *dst, uint8_t pref return dst; } -/* RFC 9113 §8.2.1 field validity, beyond what HPACK decoding guarantees: a - * name is an RFC 9110 token with no uppercase letter, optionally behind the - * single ':' of a pseudo-header. The token set is the one the HTTP/1 parser - * (isFieldNameByte) and the HTTP/3 header-set interface accept. */ -static inline bool validFieldName(const char *p, unsigned n) { - unsigned i = n && p[0] == ':'; - if (i == n) return false; - for (; i < n; i++) { - unsigned char c = (unsigned char) p[i]; - if (!isTokenByte(c) || (c >= 'A' && c <= 'Z')) return false; - } - return true; -} - -/* The methods Bun.serve can represent: the HTTP/1 parser's strict set, in - * their RFC 9110 case-sensitive wire form. */ -static inline bool isKnownMethod(std::string_view method) { - for (unsigned char c : method) if (!((c >= 'A' && c <= 'Z') || c == '-')) return false; - return Bun__HTTPMethod__from(method.data(), method.size()) != -1; -} - -/* The request fields validation cares about. lshpack reports the HPACK - * static-table index when the peer used one for the name (nearly always); - * literal names fall back to a compare. */ -enum class Field : uint8_t { Other, Method, Scheme, Path, Authority, Host, ContentLength, Te, Expect, ConnectionSpecific }; -static inline Field classify(int hpackIndex, std::string_view name) { - switch (hpackIndex) { - case LSHPACK_HDR_AUTHORITY: return Field::Authority; - case LSHPACK_HDR_METHOD_GET: case LSHPACK_HDR_METHOD_POST: return Field::Method; - case LSHPACK_HDR_PATH: case LSHPACK_HDR_PATH_INDEX_HTML: return Field::Path; - case LSHPACK_HDR_SCHEME_HTTP: case LSHPACK_HDR_SCHEME_HTTPS: return Field::Scheme; - case LSHPACK_HDR_HOST: return Field::Host; - case LSHPACK_HDR_CONTENT_LENGTH: return Field::ContentLength; - case LSHPACK_HDR_EXPECT: return Field::Expect; - case LSHPACK_HDR_TRANSFER_ENCODING: return Field::ConnectionSpecific; - case LSHPACK_HDR_UNKNOWN: break; - default: return Field::Other; - } - switch (name.size()) { - case 2: if (name == "te") return Field::Te; break; - case 4: if (name == "host") return Field::Host; break; - case 5: if (name == ":path") return Field::Path; break; - case 6: if (name == "expect") return Field::Expect; break; - case 7: if (name == ":method") return Field::Method; if (name == ":scheme") return Field::Scheme; if (name == "upgrade") return Field::ConnectionSpecific; break; - case 10: if (name == ":authority") return Field::Authority; if (name == "connection") return Field::ConnectionSpecific; if (name == "keep-alive") return Field::ConnectionSpecific; break; - case 14: if (name == "content-length") return Field::ContentLength; break; - case 16: if (name == "proxy-connection") return Field::ConnectionSpecific; break; - case 17: if (name == "transfer-encoding") return Field::ConnectionSpecific; break; - } - return Field::Other; -} - -/* RFC 9113 §8.2.1: field-content (RFC 9110 §5.5) with no leading or trailing - * whitespace. HTAB is the only control byte allowed; DEL and obs-text pass, - * as they do through the HTTP/1 parser. */ -static inline bool validFieldValue(const char *p, unsigned n) { - if (n && (p[0] == ' ' || p[0] == '\t' || p[n - 1] == ' ' || p[n - 1] == '\t')) return false; - for (unsigned i = 0; i < n; i++) { - unsigned char c = (unsigned char) p[i]; - if (c < 0x20 && c != '\t') return false; - } - return true; -} - } struct Http2Connection; @@ -235,7 +171,7 @@ struct Http2Response { uint32_t unackedReceive = 0; uint32_t receiveWindow = http2::LOCAL_INITIAL_WINDOW_SIZE; bool wide = false; - int64_t declaredContentLength = -1; + uint64_t declaredContentLength = NO_CONTENT_LENGTH; uint64_t receivedBodyBytes = 0; /* END_STREAM received / sent. A reset (either direction) closes both. */ @@ -273,16 +209,24 @@ struct Http2Response { Http2Response *writeHeader(std::string_view key, std::string_view value) { writeStatus("200"); - /* §8.2.2: connection-specific fields must not appear in an HTTP/2 message. */ - if (isConnectionSpecificResponseField(key, value)) return this; + switch (classifyResponseField(key, value)) { + case ResponseField::Other: break; + case ResponseField::Date: data.state |= Http2ResponseData::HTTP_WROTE_DATE_HEADER; break; + /* The body that is sent decides the length, and §8.2.2 forbids the connection-specific fields. */ + default: return this; + } data.appendHeader(key.data(), (unsigned) key.size(), value.data(), (unsigned) value.size()); return this; } + /* As content-length it declares the length of the body. */ Http2Response *writeHeader(std::string_view key, uint64_t value) { + writeStatus("200"); + if (key.size() == 14 && asciiIEquals(key, "content-length")) data.state |= Http2ResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER; char buf[utils::U64_MAX_DIGITS]; auto r = std::to_chars(buf, buf + sizeof(buf), value); - return writeHeader(key, std::string_view{buf, (size_t)(r.ptr - buf)}); + data.appendHeader(key.data(), (unsigned) key.size(), buf, (unsigned) (r.ptr - buf)); + return this; } inline void writeMark(); @@ -296,7 +240,10 @@ struct Http2Response { bool ok = internalEnd(body, total, true, closeConnection); return {ok, ok || hasResponded()}; } - inline void endWithoutBody(std::optional reportedContentLength = std::nullopt, bool closeConnection = false); + inline void endWithoutBody(); + /* See HttpResponse. A refusal ends this stream and leaves the connection's other streams alone. */ + inline bool acceptRequestBody(); + inline void refuseRequestBody(); inline bool sendTerminatingChunk(bool closeConnection = false); bool hasResponded() { return !(data.state & Http2ResponseData::HTTP_RESPONSE_PENDING); } @@ -424,6 +371,9 @@ struct Http2Connection { /* Seconds of silence before onTimeout; recomputed when a stream sets a * timeout or retires (see Http2Response::timeoutS). */ unsigned idleTimeoutS = 0; + /* An open stream has a timeout of its own, so idleTimeoutS depends on which streams are open. */ + bool customTimeouts = false; + IdleTimeout idleTimer; int drainDepth = 0; Http2Connection(us_socket_t *socket, Http2Context *context) : s(socket), ctx(context) { @@ -588,7 +538,14 @@ struct Http2Connection { inline void scheduleFlush(); bool drainedAfterGoaway() { - return !closed && streams.empty() && (goawaySent || goawayReceived) && out->length() == 0; + return !closed && !us_socket_is_shut_down(s) && 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. No frame is read or written after it. */ + /* The connection counts as open until the peer has answered: a program that exits once stop() resolves would reset what the kernel hasn't delivered. */ + void finish() { + us_socket_close_lingering(s); } bool takeResetToken() { @@ -621,7 +578,6 @@ struct Http2Connection { /* false: the connection was closed (re-entrancy, or the peer ran out of * reset tokens — a stream it forced us to reset counts like one it reset). */ inline bool streamError(uint32_t streamId, Http2Response *stream, http2::ErrorCode code); - inline bool dispatchRequest(Http2Response *stream, const us_quic_header_t *headers, unsigned count); }; struct Http2Context { @@ -646,7 +602,11 @@ struct Http2Context { void (*detachFromParent)(void *parent, Http2Context *ctx) = nullptr; /* Seconds without traffic in either direction before a connection is * dropped (streams in flight are aborted); 0 disables. */ - unsigned idleTimeoutS = 10; + unsigned idleTimeoutS = 0; + uint64_t maxRequestBodySize = UINT64_MAX; + /* 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. */ @@ -672,16 +632,17 @@ struct Http2Context { return v > 0xffffff ? 0xffffff : (uint32_t) v; } - static Http2Context *create(Loop *loop, unsigned idleTimeoutS) { + static Http2Context *create(Loop *loop, unsigned idleTimeoutS, uint64_t maxRequestBodySize) { Http2Context *ctx = new Http2Context; ctx->loop = loop; ctx->idleTimeoutS = idleTimeoutS; + ctx->maxRequestBodySize = maxRequestBodySize; us_socket_group_init(&ctx->group, (us_loop_t *) loop, &vtable, ctx); return ctx; } void free() { - closeAll(); + closeAll(0); if (parent && detachFromParent) detachFromParent(parent, this); for (Http2Connection *conn : sweepList) { conn->inSweepList = false; if (--conn->busy == 0 && conn->closed) delete conn; } sweepList.clear(); @@ -752,8 +713,7 @@ struct Http2Context { void onHttp(std::string_view method, std::string_view pattern, MoveOnlyFunction &&handler) { - std::vector methods = - method == "*" ? std::vector{"*"} : std::vector{method}; + std::span methods = method == "*" ? std::span(router.ANY_METHOD_TREES) : std::span(&method, 1); router.add(methods, pattern, [handler = std::move(handler)](auto *r) mutable { /* Copy out: the handler may reload routes, replacing `router` * (and its user data) while we're inside it. */ @@ -786,26 +746,28 @@ struct Http2Context { /* GOAWAY every connection and close it. Streams still in flight get * onAborted, as TemplatedApp::close() does for HTTP/1 sockets. */ - void closeAll() { - forEachConnection([](Http2Connection *conn) { + void closeAll(int thread_ends) { + forEachConnection([thread_ends](Http2Connection *conn) { if (!conn->goawaySent) conn->writeGoaway(http2::ERR_NO_ERROR); conn->flush(); - us_socket_close(conn->s, 0, nullptr); + thread_ends ? us_socket_close(conn->s, LIBUS_SOCKET_CLOSE_CODE_FAST_SHUTDOWN, 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 (us_socket_is_shut_down(conn->s) || (!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 +829,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 +866,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 (us_socket_is_shut_down(s)) return s; conn->borrowSharedOut(); conn->onData(data, (size_t) length); return s; @@ -956,6 +918,7 @@ 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->idleTimer.rearmIfPeerStillReads(s, conn->idleTimeoutS)) return s; conn->busy++; std::vector open = conn->streams; for (Http2Response *stream : open) { @@ -968,7 +931,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 +943,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 +955,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; } @@ -1001,7 +966,6 @@ struct Http2Context { /* on_writable */ &onWritable, /* on_close */ &onClose, /* on_timeout */ &onTimeout, - /* on_long_timeout */ nullptr, /* on_end */ &onEnd, /* on_connect_error */ nullptr, /* on_connecting_error */ nullptr, @@ -1030,7 +994,8 @@ inline void Http2Connection::writeSettings() { } inline void Http2Connection::touch() { - us_socket_timeout(s, idleTimeoutS); + if (us_socket_is_shut_down(s)) return; + idleTimer.arm(s, idleTimeoutS); } inline void Http2Connection::recomputeIdleTimeout() { @@ -1038,9 +1003,11 @@ inline void Http2Connection::recomputeIdleTimeout() { * asked counts as the context default, as does a connection with none. */ unsigned t = streams.empty() ? ctx->idleTimeoutS : 0; bool never = streams.empty() && ctx->idleTimeoutS == 0; + customTimeouts = false; for (Http2Response *stream : streams) { + customTimeouts |= stream->timeoutS != 255; unsigned s = stream->timeoutS == 255 ? ctx->idleTimeoutS : stream->timeoutS; - if (s == 0) { never = true; break; } + never |= s == 0; t = std::max(t, s); } idleTimeoutS = never ? 0 : t; @@ -1222,7 +1189,8 @@ inline void Http2Connection::retireStream(Http2Response *stream, bool abortNow) * socket event, or from the deferred pass if we're outside one; never * under the API call that got us here. */ if (busy == 0) ctx->scheduleSweep(this); - if (stream->timeoutS != 255) recomputeIdleTimeout(); + if (customTimeouts) recomputeIdleTimeout(); + else touch(); } inline void Http2Connection::streamMaybeClosed(Http2Response *stream) { @@ -1299,7 +1267,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 || us_socket_is_shut_down(s)) { busy--; Http2Context::epilogue(this, s); return; @@ -1313,8 +1281,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); @@ -1533,15 +1502,20 @@ inline bool Http2Connection::handleData(Http2Response *stream, bool fin, uint32_ /* §6.9.1: the peer may not send beyond what we advertised for this stream. */ if (length > stream->receiveWindow - stream->unackedReceive) return streamError(stream->id, stream, http2::ERR_FLOW_CONTROL_ERROR); stream->receivedBodyBytes += bodyLength; - if (stream->declaredContentLength >= 0 && - (stream->receivedBodyBytes > (uint64_t) stream->declaredContentLength || - (fin && stream->receivedBodyBytes != (uint64_t) stream->declaredContentLength))) { + if (stream->declaredContentLength != NO_CONTENT_LENGTH && + (stream->receivedBodyBytes > stream->declaredContentLength || + (fin && stream->receivedBodyBytes != stream->declaredContentLength))) { return streamError(stream->id, stream, http2::ERR_PROTOCOL_ERROR); } if (fin) stream->remoteClosed = true; else if (length) replenishStreamWindow(stream, length); if (stream->data.inStream) { + if (bodyLength > stream->data.requestBodyBudget) { + stream->refuseRequestBody(); + return !closed; + } + stream->data.requestBodyBudget -= bodyLength; if (fin) stream->finDelivered = true; stream->data.inStream(stream, (const char *) body, bodyLength, fin, stream->data.userData); if (closed) return false; @@ -1608,14 +1582,11 @@ inline bool Http2Connection::handleHeaderBlock(uint32_t streamId, uint8_t flags, /* A later HEADERS on an open stream is the trailer section: it must * end the stream, and trailers aren't surfaced. */ if (existing->remoteClosed) return streamError(streamId, existing, http2::ERR_STREAM_CLOSED); - bool badTrailer = !endStream || tooLarge; - for (size_t i = 0; !badTrailer && i < list.size(); i++) { - const us_quic_header_t &h = list[i]; - badTrailer = h.name[0] == ':' || !http2::validFieldName(h.name, h.name_len) || !http2::validFieldValue(h.value, h.value_len); + if (!endStream || tooLarge || !Http2Request::validTrailers(list.data(), list.size())) { + return streamError(streamId, existing, http2::ERR_PROTOCOL_ERROR); } - if (badTrailer) return streamError(streamId, existing, http2::ERR_PROTOCOL_ERROR); existing->remoteClosed = true; - if (existing->declaredContentLength >= 0 && existing->receivedBodyBytes != (uint64_t) existing->declaredContentLength) { + if (existing->declaredContentLength != NO_CONTENT_LENGTH && existing->receivedBodyBytes != existing->declaredContentLength) { return streamError(streamId, existing, http2::ERR_PROTOCOL_ERROR); } if (existing->data.inStream && !existing->finDelivered) { @@ -1648,6 +1619,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; @@ -1656,54 +1628,9 @@ inline bool Http2Connection::handleHeaderBlock(uint32_t streamId, uint8_t flags, return !closed; } - /* Request validation (§8.3): pseudo-headers first, exactly the request - * set, each once; connection-specific fields are malformed. */ - bool sawRegular = false, malformed = false, isConnect = false; - unsigned seen = 0; /* 1 :method, 2 :scheme, 4 :path, 8 :authority */ - int64_t contentLength = -1; - std::string_view authority, host, path, method; - for (const us_quic_header_t &h : list) { - std::string_view name{h.name, h.name_len}, value{h.value, h.value_len}; - http2::Field f = http2::classify(h.qpack_index, name); - if (f == http2::Field::Other && !http2::validFieldName(h.name, h.name_len)) { malformed = true; break; } - if (!http2::validFieldValue(h.value, h.value_len)) { malformed = true; break; } - if (name[0] == ':') { - if (sawRegular) { malformed = true; break; } - unsigned bit; - switch (f) { - case http2::Field::Method: bit = 1; method = value; isConnect = value == "CONNECT"; break; - case http2::Field::Scheme: bit = 2; break; - case http2::Field::Path: bit = 4; path = value; break; - case http2::Field::Authority: bit = 8; authority = value; break; - default: bit = 0; break; - } - if (!bit || (seen & bit) || value.empty()) { malformed = true; break; } - seen |= bit; - } else { - sawRegular = true; - switch (f) { - case http2::Field::ConnectionSpecific: malformed = true; break; - case http2::Field::Te: malformed = !(value.size() == 8 && asciiIEquals(value, "trailers")); break; - case http2::Field::Host: malformed = !host.empty(); host = value; break; - case http2::Field::ContentLength: { - uint64_t v = 0; - auto r = std::from_chars(value.data(), value.data() + value.size(), v); - malformed = r.ec != std::errc() || r.ptr != value.data() + value.size() || contentLength >= 0 || v > (uint64_t) INT64_MAX; - contentLength = (int64_t) v; - break; - } - default: break; - } - if (malformed) break; - } - } - if (!malformed) { - malformed = isConnect ? seen != (1 | 8) : (seen & 7) != 7; - } - if (!malformed && endStream && contentLength > 0) malformed = true; - if (selfDependent) malformed = true; - if (!malformed && !validPseudoHeaderTarget(method, path, authority, host)) malformed = true; - if (malformed) { + Http2Request req(list.data(), (unsigned) list.size()); + const uint64_t contentLength = req.getContentLength(); + if (req.isMalformed() || selfDependent || (endStream && contentLength != 0 && contentLength != NO_CONTENT_LENGTH)) { return streamError(streamId, nullptr, http2::ERR_PROTOCOL_ERROR); } @@ -1712,18 +1639,15 @@ inline bool Http2Connection::handleHeaderBlock(uint32_t streamId, uint8_t flags, stream->remoteClosed = endStream; streams.push_back(stream); progressed = true; - if (!http2::isKnownMethod(method)) { + if (!isKnownMethod(req.getCaseSensitiveMethod())) { /* RFC 9110 §15.6.2. The router would dispatch it to the "any" handler, * which cannot represent the method and would report GET. */ stream->writeStatus("501 Not Implemented")->end(); return !closed; } - return dispatchRequest(stream, list.data(), (unsigned) list.size()); -} - -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 (declaresBodyOverLimit(contentLength, ctx->maxRequestBodySize)) { + stream->data.state |= Http2ResponseData::HTTP_REQUEST_BODY_TOO_LARGE; + } else if (req.getExpectsContinue() && !(ctx->parentFlags && ctx->parentFlags->usingCustomExpectHandler)) { stream->writeContinue(); } ctx->dispatchDepth++; @@ -1752,7 +1676,6 @@ inline bool Http2Connection::dispatchRequest(Http2Response *stream, const us_qui inline void Http2Response::writeMark() { if (data.state & Http2ResponseData::HTTP_WROTE_DATE_HEADER) return; - data.state |= Http2ResponseData::HTTP_WROTE_DATE_HEADER; LoopData *ld = (LoopData *) us_loop_ext(us_socket_group_loop(us_socket_group(conn->s))); writeHeader("date", std::string_view{ld->date, 29}); } @@ -1796,12 +1719,8 @@ inline bool Http2Response::write(std::string_view chunk, size_t *writtenPtr) { return w == chunk.length(); } -inline void Http2Response::endWithoutBody(std::optional reportedContentLength, bool) { +inline void Http2Response::endWithoutBody() { if (dead) return; - if (reportedContentLength.has_value() && !(data.state & Http2ResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER)) { - writeHeader("content-length", (uint64_t) *reportedContentLength); - data.state |= Http2ResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER; - } if (data.state & Http2ResponseData::HTTP_WRITE_CALLED) { if (data.backpressure.length() != 0) { /* Body bytes still parked behind the window: END_STREAM follows them. */ @@ -1822,6 +1741,29 @@ inline void Http2Response::endWithoutBody(std::optional reportedContentL c->scheduleFlush(); } +inline bool Http2Response::acceptRequestBody() { + if (data.state & Http2ResponseData::HTTP_REQUEST_BODY_TOO_LARGE) { + writeStatus("413"); + endWithoutBody(); + return false; + } + data.requestBodyBudget = conn->ctx->maxRequestBodySize; + return true; +} + +inline void Http2Response::refuseRequestBody() { + data.state |= Http2ResponseData::HTTP_REQUEST_BODY_TOO_LARGE; + data.inStream = nullptr; + if (auto onAborted = std::exchange(data.onAborted, nullptr)) onAborted(this, data.userData); + if (dead || conn->closed) return; + if (data.state & Http2ResponseData::HTTP_STATUS_CALLED) { + close(http2::ERR_INTERNAL_ERROR); + return; + } + writeStatus("413"); + endWithoutBody(); +} + inline bool Http2Response::sendTerminatingChunk(bool) { if (dead) return true; flushHeaders(); @@ -1847,7 +1789,6 @@ inline bool Http2Response::internalEnd(std::string_view body, uint64_t totalSize writeMark(); if (!(data.state & Http2ResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER)) { writeHeader("content-length", totalSize); - data.state |= Http2ResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER; } if (body.empty() && data.offset >= totalSize) { c->writeHeaderBlock(this, true); @@ -1933,7 +1874,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/Http2ResponseData.h b/packages/bun-uws/src/Http2ResponseData.h index 005b417afca8..b014f87300e8 100644 --- a/packages/bun-uws/src/Http2ResponseData.h +++ b/packages/bun-uws/src/Http2ResponseData.h @@ -28,6 +28,7 @@ struct Http2ResponseData { HTTP_CONNECTION_CLOSE = 16, HTTP_WROTE_CONTENT_LENGTH_HEADER = 32, HTTP_WROTE_DATE_HEADER = 64, + HTTP_REQUEST_BODY_TOO_LARGE = 128, }; void *userData = nullptr; @@ -53,6 +54,8 @@ struct Http2ResponseData { bool endAfterDrain = false; uint64_t offset = 0; + /* See HttpResponseData::requestBodyBudget. */ + uint64_t requestBodyBudget = UINT64_MAX; uint8_t state = HTTP_RESPONSE_PENDING; void appendHeader(const char *name, unsigned nlen, const char *value, unsigned vlen) { diff --git a/packages/bun-uws/src/Http3App.h b/packages/bun-uws/src/Http3App.h index bde4a4828e01..8afe147e3701 100644 --- a/packages/bun-uws/src/Http3App.h +++ b/packages/bun-uws/src/Http3App.h @@ -12,16 +12,12 @@ namespace uWS { struct H3App { Http3Context *http3Context; - static H3App *create(SocketContextOptions options, unsigned idleTimeoutSecs = 0) { - us_bun_socket_context_options_t raw; - memcpy(&raw, &options, sizeof(raw)); - Http3Context *ctx = Http3Context::create(Loop::get(), raw, idleTimeoutSecs); + static H3App *create(us_bun_socket_context_options_t options, unsigned idleTimeoutSecs, uint64_t maxRequestBodySize) { + Http3Context *ctx = Http3Context::create(Loop::get(), options, idleTimeoutSecs, maxRequestBodySize); if (!ctx) return nullptr; return new H3App{ctx}; } - bool constructorFailed() { return http3Context == nullptr; } - ~H3App() { if (http3Context) http3Context->free(); } @@ -45,7 +41,7 @@ struct H3App { #undef H3_METHOD H3App &&listen(const std::string &host, int port, int options, - MoveOnlyFunction &&cb) { + MoveOnlyFunction &&cb) { cb(http3Context->listen(host.empty() ? nullptr : host.c_str(), port, options)); return std::move(*this); } @@ -55,12 +51,15 @@ struct H3App { } /* GOAWAY + drain. The engine itself is torn down in the destructor. */ void close() { http3Context->shutdown(); } - bool addServerNameWithOptions(const char *hostname, SocketContextOptions options) { - us_bun_socket_context_options_t raw; - memcpy(&raw, &options, sizeof(raw)); - return http3Context->addServerName(hostname, raw); + /* 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, us_bun_socket_context_options_t options) { + return http3Context->addServerName(hostname, options); } - void *getNativeHandle() { return http3Context; } }; } diff --git a/packages/bun-uws/src/Http3Context.h b/packages/bun-uws/src/Http3Context.h index 5dfa7c4af8bb..96bdfb2d6b30 100644 --- a/packages/bun-uws/src/Http3Context.h +++ b/packages/bun-uws/src/Http3Context.h @@ -14,11 +14,11 @@ static_assert(US_QUIC_MAX_REQUEST_FIELDS == UWS_HTTP_MAX_HEADERS_COUNT, "HTTP/3 struct Http3Context { - static Http3Context *create(Loop *loop, us_bun_socket_context_options_t options, unsigned idleTimeoutSecs = 0) { + static Http3Context *create(Loop *loop, us_bun_socket_context_options_t options, unsigned idleTimeoutSecs, uint64_t maxRequestBodySize) { us_quic_socket_context_t *ctx = us_create_quic_socket_context( (us_loop_t *) loop, options, sizeof(Http3ContextData), idleTimeoutSecs); if (!ctx) return nullptr; - new (us_quic_socket_context_ext(ctx)) Http3ContextData(); + (new (us_quic_socket_context_ext(ctx)) Http3ContextData())->maxRequestBodySize = maxRequestBodySize; us_quic_socket_context_on_stream_open(ctx, [](us_quic_stream_t *s, int) { new (us_quic_stream_ext(s)) Http3ResponseData(); @@ -33,7 +33,13 @@ struct Http3Context { * reset()/route() for trailers would wipe the live response's * onAborted/userData and dispatch the handler a second time. * state is 0 only before the first reset() on this stream. */ - if (rd->state != 0) return; + if (rd->state != 0) { + if (!Http3Request::validTrailers(us_quic_stream_header(s, 0), us_quic_stream_header_count(s))) { + rd->inStream = nullptr; + us_quic_stream_reset(s, US_QUIC_H3_MESSAGE_ERROR); + } + return; + } rd->reset(); if (us_quic_stream_headers_too_large(s)) { @@ -42,7 +48,21 @@ struct Http3Context { } Http3Request req(s); - if (req.getHeader("expect") == "100-continue") res->writeContinue(); + if (req.isMalformed()) { + res->writeStatus("400 Bad Request")->end(); + return; + } + if (!isKnownMethod(req.getCaseSensitiveMethod())) { + /* 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 (declaresBodyOverLimit(req.getContentLength(), cd->maxRequestBodySize)) { + rd->state |= Http3ResponseData::HTTP_REQUEST_BODY_TOO_LARGE; + } else if (req.getExpectsContinue()) { + res->writeContinue(); + } cd->router.getUserData() = {res, &req}; if (!cd->router.route(req.getMethod(), req.getUrl())) { res->writeStatus("404 Not Found")->end(); @@ -52,12 +72,15 @@ struct Http3Context { us_quic_socket_context_on_stream_data(ctx, [](us_quic_stream_t *s, const char *data, unsigned len, int fin) { Http3Response *res = (Http3Response *) s; Http3ResponseData *rd = res->getHttpResponseData(); - if (rd->inStream) rd->inStream(res, data, len, fin != 0, rd->userData); + if (!rd->inStream) return; + if (len > rd->requestBodyBudget) return res->refuseRequestBody(); + rd->requestBodyBudget -= len; + rd->inStream(res, data, len, fin != 0, rd->userData); }); us_quic_socket_context_on_stream_writable(ctx, [](us_quic_stream_t *s) { Http3Response *res = (Http3Response *) s; - if (!res->drain()) us_quic_stream_want_write(s, 1); + if (!res->drain()) us_quic_stream_want_write(s); }); us_quic_socket_context_on_stream_close(ctx, [](us_quic_stream_t *s) { @@ -74,6 +97,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; } @@ -89,8 +117,7 @@ struct Http3Context { void onHttp(std::string_view method, std::string_view pattern, MoveOnlyFunction &&handler) { Http3ContextData *cd = getContextData(); - std::vector methods = - method == "*" ? std::vector{"*"} : std::vector{method}; + std::span methods = method == "*" ? std::span(cd->router.ANY_METHOD_TREES) : std::span(&method, 1); cd->router.add(methods, pattern, [handler = std::move(handler)](auto *router) mutable { /* Copy out: the handler may reload routes, replacing `router` * (and its user data) while we're inside it. */ @@ -103,7 +130,7 @@ struct Http3Context { }, method == "*" ? cd->router.LOW_PRIORITY : cd->router.MEDIUM_PRIORITY); } - us_quic_listen_socket_t *listen(const char *host, int port, int flags) { + us_quic_udp_t *listen(const char *host, int port, int flags) { return us_quic_socket_context_listen((us_quic_socket_context_t *) this, host, port, flags, sizeof(Http3ResponseData)); } diff --git a/packages/bun-uws/src/Http3ContextData.h b/packages/bun-uws/src/Http3ContextData.h index 77a555e09038..88d8312030c2 100644 --- a/packages/bun-uws/src/Http3ContextData.h +++ b/packages/bun-uws/src/Http3ContextData.h @@ -14,6 +14,10 @@ struct Http3ContextData { Http3Request *httpRequest; }; HttpRouter router; + uint64_t maxRequestBodySize = UINT64_MAX; + /* 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..5a79c597dcfb 100644 --- a/packages/bun-uws/src/Http3Request.h +++ b/packages/bun-uws/src/Http3Request.h @@ -2,9 +2,10 @@ #define UWS_H3REQUEST_H #include "quic.h" -#include "QueryParser.h" +#include "Utilities.h" + +#include -#include #include #include @@ -19,42 +20,88 @@ struct Http3Request { Http3Request(us_quic_stream_t *s) : Http3Request(us_quic_stream_header(s, 0), us_quic_stream_header_count(s)) {} + /* Reads the list once and checks it against RFC 9113 §8.2 and §8.3, which RFC 9114 §4.2 and §4.3 repeat. */ Http3Request(const us_quic_header_t *list, unsigned int count) : headers(list), headerCount(count) { + bool sawRegular = false; + unsigned int seen = 0; + std::string_view host; for (unsigned int i = 0; i < headerCount; i++) { const us_quic_header_t *h = &headers[i]; std::string_view name{h->name, h->name_len}; std::string_view value{h->value, h->value_len}; - if (name == ":method") { - method = value; - } else if (name == ":path") { + Field field = classify(h->qpack_index, name); + if ((field == Field::Other && !validFieldName(name)) || !validFieldValue(value)) return; + if (name[0] == ':') { + /* Pseudo-headers come first, each at most once and never empty. */ + if (field > Field::Authority || sawRegular || (seen & bit(field)) || value.empty()) return; + seen |= bit(field); + } else { + sawRegular = true; + } + switch (field) { + case Field::Method: method = value; break; + case Field::Path: { fullUrl = value; size_t q = value.find('?'); url = q == std::string_view::npos ? value : value.substr(0, q); - /* Keep the leading '?' — getDecodedQueryValue expects it and - * unconditionally drops the first byte. */ - query = q == std::string_view::npos ? std::string_view{} : value.substr(q); - } else if (name == ":authority") { - authority = value; - } else if (authority.empty() && name.size() == 4 && equalsIgnoreCase(name, "host")) { - /* RFC 9114 §4.3.1: a request must contain :authority OR a - * Host field. Promote the literal Host so getHeader("host"), - * req.url, and the forEachHeader synthesis all agree. QPACK - * delivers pseudo-headers first, so :authority (if any) - * always wins. */ - authority = value; + break; + } + case Field::Authority: authority = value; break; + case Field::Host: + if (!host.empty()) return; + host = value; + break; + case Field::ContentLength: + if (contentLength != NO_CONTENT_LENGTH || (contentLength = parseContentLength(value)) == NO_CONTENT_LENGTH) return; + break; + case Field::Te: + if (!(value.size() == 8 && asciiIEquals(value, "trailers"))) return; + break; + case Field::Expect: expectsContinue = isExpect100Continue(value); break; + case Field::ConnectionSpecific: return; + case Field::Scheme: + case Field::Other: break; } } + + /* CONNECT has :method and :authority alone. Every other request has :method, :scheme and :path. */ + const unsigned int required = bit(Field::Method) | bit(Field::Scheme) | bit(Field::Path); + if (method == "CONNECT" ? seen != (bit(Field::Method) | bit(Field::Authority)) : (seen & required) != required) return; + if (!isToken(method)) return; + /* :path is origin-form, or "*" for OPTIONS, with no byte an HTTP/1 request line couldn't carry. HttpRouter skips its first byte unseen. */ + if (seen & bit(Field::Path)) { + if (fullUrl[0] != '/' && !(fullUrl == "*" && method == "OPTIONS")) return; + for (unsigned char c : fullUrl) if (c <= 0x20) return; + } + /* :authority has no userinfo, and a Host field either repeats it or stands in for it. */ + if (authority.find('@') != std::string_view::npos) return; + if (authority.empty()) authority = host; + else if (!host.empty() && host != authority) return; + if (authority.empty()) return; + malformed = false; } + /* A trailer section has no pseudo-header (RFC 9113 §8.1, RFC 9114 §4.3). */ + static bool validTrailers(const us_quic_header_t *list, size_t count) { + for (size_t i = 0; i < count; i++) { + std::string_view name{list[i].name, list[i].name_len}; + if (!validFieldName(name) || name[0] == ':' || !validFieldValue({list[i].value, list[i].value_len})) return false; + } + return true; + } + + /* Nothing but this is meaningful on a malformed request. */ + bool isMalformed() { return malformed; } + bool getExpectsContinue() { return expectsContinue; } + uint64_t getContentLength() { return contentLength; } + bool getYield() { return yield; } void setYield(bool y) { yield = y; } std::string_view getUrl() { return url; } std::string_view getFullUrl() { return fullUrl; } - std::string_view getQuery() { return query.empty() ? query : query.substr(1); } - std::string_view getQuery(std::string_view key) { - 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. */ @@ -71,27 +118,22 @@ struct Http3Request { if (lowerCasedHeader == "host") return authority; for (unsigned int i = 0; i < headerCount; i++) { const us_quic_header_t *h = &headers[i]; - if (h->name_len == lowerCasedHeader.size() && - equalsIgnoreCase({h->name, h->name_len}, lowerCasedHeader)) { + if (std::string_view{h->name, h->name_len} == lowerCasedHeader) { return {h->value, h->value_len}; } } return {}; } + /* :authority, or the Host field that stands in for it, comes last as "host". */ template void forEachHeader(Fn &&fn) { for (unsigned int i = 0; i < headerCount; i++) { const us_quic_header_t *h = &headers[i]; std::string_view name{h->name, h->name_len}; - if (!name.empty() && name[0] == ':') continue; - /* RFC 9114 §4.3.1: a request MAY include both :authority and a - * literal Host. :authority is synthesized as host below; drop - * the literal so req.headers.get('host') matches req.url and - * isn't comma-joined. */ - if (!authority.empty() && name.size() == 4 && equalsIgnoreCase(name, "host")) continue; + if (name[0] == ':' || name == "host") continue; fn(name, std::string_view{h->value, h->value_len}); } - if (!authority.empty()) fn(std::string_view{"host"}, authority); + fn(std::string_view{"host"}, authority); } void setParameters(std::pair p) { params = p; } @@ -102,20 +144,65 @@ struct Http3Request { } private: - static bool equalsIgnoreCase(std::string_view a, std::string_view b) { - if (a.size() != b.size()) return false; - for (size_t i = 0; i < a.size(); i++) { - if ((a[i] | 0x20) != (b[i] | 0x20)) return false; + /* The fields the checks care about. The four request pseudo-headers come first. */ + enum class Field : uint8_t { Method, Scheme, Path, Authority, Host, ContentLength, Te, Expect, ConnectionSpecific, Other }; + static constexpr unsigned int bit(Field field) { return 1u << (unsigned int) field; } + + /* lshpack reports the HPACK static-table index when the peer used one for the name, which is nearly always. HTTP/3 passes -1. */ + static Field classify(int hpackIndex, std::string_view name) { + switch (hpackIndex > 0 ? hpackIndex : LSHPACK_HDR_UNKNOWN) { + case LSHPACK_HDR_AUTHORITY: return Field::Authority; + case LSHPACK_HDR_METHOD_GET: case LSHPACK_HDR_METHOD_POST: return Field::Method; + case LSHPACK_HDR_PATH: case LSHPACK_HDR_PATH_INDEX_HTML: return Field::Path; + case LSHPACK_HDR_SCHEME_HTTP: case LSHPACK_HDR_SCHEME_HTTPS: return Field::Scheme; + case LSHPACK_HDR_HOST: return Field::Host; + case LSHPACK_HDR_CONTENT_LENGTH: return Field::ContentLength; + case LSHPACK_HDR_EXPECT: return Field::Expect; + case LSHPACK_HDR_TRANSFER_ENCODING: return Field::ConnectionSpecific; + case LSHPACK_HDR_UNKNOWN: break; + default: return Field::Other; + } + switch (name.size()) { + case 2: if (name == "te") return Field::Te; break; + case 4: if (name == "host") return Field::Host; break; + case 5: if (name == ":path") return Field::Path; break; + case 6: if (name == "expect") return Field::Expect; break; + case 7: if (name == ":method") return Field::Method; if (name == ":scheme") return Field::Scheme; if (name == "upgrade") return Field::ConnectionSpecific; break; + case 10: if (name == ":authority") return Field::Authority; if (name == "connection") return Field::ConnectionSpecific; if (name == "keep-alive") return Field::ConnectionSpecific; break; + case 14: if (name == "content-length") return Field::ContentLength; break; + case 16: if (name == "proxy-connection") return Field::ConnectionSpecific; break; + case 17: if (name == "transfer-encoding") return Field::ConnectionSpecific; break; } + return Field::Other; + } + + /* A token with no uppercase letter, optionally behind the single ':' of a pseudo-header. */ + static bool validFieldName(std::string_view name) { + size_t i = !name.empty() && name[0] == ':'; + if (i == name.size()) return false; + for (; i < name.size(); i++) { + unsigned char c = (unsigned char) name[i]; + if (!isTokenByte(c) || (c >= 'A' && c <= 'Z')) return false; + } + return true; + } + + /* No whitespace at either end, and HTAB is the only control byte. DEL and obs-text pass, as they do through the HTTP/1 parser. */ + static bool validFieldValue(std::string_view value) { + if (!value.empty() && (value.front() == ' ' || value.front() == '\t' || value.back() == ' ' || value.back() == '\t')) return false; + for (unsigned char c : value) if (c < 0x20 && c != '\t') return false; return true; } const us_quic_header_t *headers; unsigned int headerCount; - std::string_view method, url, fullUrl, query, authority; + std::string_view method, url, fullUrl, authority; + uint64_t contentLength = NO_CONTENT_LENGTH; std::pair params{-1, nullptr}; char methodLower[32]; bool yield = false; + bool malformed = true; + bool expectsContinue = false; }; } diff --git a/packages/bun-uws/src/Http3Response.h b/packages/bun-uws/src/Http3Response.h index a0e6eca1b561..813b3caf7a33 100644 --- a/packages/bun-uws/src/Http3Response.h +++ b/packages/bun-uws/src/Http3Response.h @@ -2,6 +2,7 @@ #define UWS_H3RESPONSE_H #include "quic.h" +#include "Http3ContextData.h" #include "Http3ResponseData.h" #include "HttpResponseData.h" #include "Utilities.h" @@ -32,22 +33,32 @@ struct Http3Response { } Http3Response *writeHeader(std::string_view key, std::string_view value) { - if (isConnectionSpecificResponseField(key, value)) return this; + Http3ResponseData *d = getHttpResponseData(); + switch (classifyResponseField(key, value)) { + case ResponseField::Other: break; + case ResponseField::Date: d->state |= Http3ResponseData::HTTP_WROTE_DATE_HEADER; break; + /* The body that is sent decides the length, and RFC 9114 §4.2 forbids the connection-specific fields. */ + default: return this; + } writeStatus("200 OK"); - appendHeader(getHttpResponseData(), key, value); + appendHeader(d, key, value); return this; } + /* As content-length it declares the length of the body. */ Http3Response *writeHeader(std::string_view key, uint64_t value) { + Http3ResponseData *d = getHttpResponseData(); + if (key.size() == 14 && asciiIEquals(key, "content-length")) d->state |= Http3ResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER; char buf[utils::U64_MAX_DIGITS]; auto r = std::to_chars(buf, buf + sizeof(buf), value); - return writeHeader(key, std::string_view{buf, (size_t)(r.ptr - buf)}); + writeStatus("200 OK"); + appendHeader(d, key, std::string_view{buf, (size_t)(r.ptr - buf)}); + return this; } void writeMark() { Http3ResponseData *d = getHttpResponseData(); if (d->state & Http3ResponseData::HTTP_WROTE_DATE_HEADER) return; - d->state |= Http3ResponseData::HTTP_WROTE_DATE_HEADER; LoopData *ld = (LoopData *) us_loop_ext( (us_loop_t *) us_quic_socket_context_loop(us_quic_stream_context((us_quic_stream_t *) this))); writeHeader("date", std::string_view{ld->date, 29}); @@ -73,40 +84,42 @@ 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; } - void end(std::string_view data = {}, bool closeConnection = false) { - internalEnd(data, data.length(), false, true, closeConnection); + void end(std::string_view data = {}, bool /*closeConnection*/ = false) { + internalEnd(data, data.length(), false); } - std::pair tryEnd(std::string_view data, uint64_t totalSize = 0, bool closeConnection = false) { - bool ok = internalEnd(data, totalSize, true, true, closeConnection); + std::pair tryEnd(std::string_view data, uint64_t totalSize = 0, bool /*closeConnection*/ = false) { + bool ok = internalEnd(data, totalSize, true); return {ok, ok || hasResponded()}; } - void endWithoutBody(std::optional reportedContentLength = std::nullopt, bool /*closeConnection*/ = false) { + void endWithoutBody() { Http3ResponseData *d = getHttpResponseData(); - if (reportedContentLength.has_value() && - !(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"); @@ -116,12 +129,37 @@ struct Http3Response { markDone(d); } + /* See HttpResponse. A refusal ends this stream and leaves the connection's other streams alone. */ + bool acceptRequestBody() { + Http3ResponseData *d = getHttpResponseData(); + if (d->state & Http3ResponseData::HTTP_REQUEST_BODY_TOO_LARGE) { + writeStatus("413"); + endWithoutBody(); + return false; + } + d->requestBodyBudget = ((Http3ContextData *) us_quic_socket_context_ext(us_quic_stream_context((us_quic_stream_t *) this)))->maxRequestBodySize; + return true; + } + + void refuseRequestBody() { + Http3ResponseData *d = getHttpResponseData(); + d->state |= Http3ResponseData::HTTP_REQUEST_BODY_TOO_LARGE; + d->inStream = nullptr; + if (auto onAborted = std::exchange(d->onAborted, nullptr)) onAborted(this, d->userData); + if (d->state & Http3ResponseData::HTTP_STATUS_CALLED) { + us_quic_stream_reset((us_quic_stream_t *) this, US_QUIC_H3_INTERNAL_ERROR); + return; + } + writeStatus("413"); + endWithoutBody(); + } + 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); @@ -133,15 +171,12 @@ struct Http3Response { return !(getHttpResponseData()->state & Http3ResponseData::HTTP_RESPONSE_PENDING); } - uint64_t getWriteOffset() { return getHttpResponseData()->offset; } - 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; } Http3Response *onWritable(void *userData, Http3ResponseData::OnWritableCallback h) { Http3ResponseData *d = getHttpResponseData(); @@ -179,10 +214,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,17 +253,41 @@ 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(); } - bool internalEnd(std::string_view data, uint64_t totalSize, bool optional, - bool /*allowContentLength*/, bool /*closeConnection*/) { + /* 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((us_quic_stream_t *) this, US_QUIC_H3_INTERNAL_ERROR); + } + + /* 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) < 0) failStream(); + } + + bool internalEnd(std::string_view data, uint64_t totalSize, bool optional) { Http3ResponseData *d = getHttpResponseData(); if (!(d->state & Http3ResponseData::HTTP_WRITE_CALLED)) { @@ -227,7 +295,6 @@ struct Http3Response { writeMark(); if (!(d->state & Http3ResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER) && totalSize) { writeHeader("content-length", totalSize); - d->state |= Http3ResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER; } if (data.empty() && d->offset == totalSize) { sendBufferedHeaders(d, true); @@ -238,26 +305,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 +338,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..cb418b8cfb7f 100644 --- a/packages/bun-uws/src/Http3ResponseData.h +++ b/packages/bun-uws/src/Http3ResponseData.h @@ -29,6 +29,7 @@ struct Http3ResponseData { HTTP_CONNECTION_CLOSE = 16, HTTP_WROTE_CONTENT_LENGTH_HEADER = 32, HTTP_WROTE_DATE_HEADER = 64, + HTTP_REQUEST_BODY_TOO_LARGE = 128, }; void *userData = nullptr; @@ -40,7 +41,6 @@ struct Http3ResponseData { * peer stalls or aborts mid-stream. Keep the onWritable context * pointer in its own slot, same as HttpResponseData. */ void *writableUserData = nullptr; - void *socketData = nullptr; OnWritableCallback onWritable = nullptr; OnAbortedCallback onAborted = nullptr; OnDataCallback inStream = nullptr; @@ -58,8 +58,13 @@ 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; + /* See HttpResponseData::requestBodyBudget. */ + uint64_t requestBodyBudget = UINT64_MAX; uint8_t state = 0; void appendHeader(const char *name, unsigned nlen, const char *value, unsigned vlen) { @@ -86,6 +91,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..13ff601f3c41 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -31,15 +31,14 @@ #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); - namespace uWS { namespace detail { @@ -110,15 +109,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: + /* Seconds a node:http connection stays open after a parse error whose 'clientError' listener kept the socket. */ + static constexpr uint8_t NODE_PARSE_ERROR_TIMEOUT_S = 10; + us_socket_group_t *getSocketGroup() { return &group; } @@ -136,6 +135,9 @@ struct HttpContext { } private: + /* From accept until the TLS handshake has to be done. */ + static constexpr unsigned int HANDSHAKE_TIMEOUT_SECONDS = 10; + /* ── vtable handlers ─────────────────────────────────────────────────── */ static void onHandshake(us_socket_t *s, int success, struct us_bun_verify_error_t verify_error, void * /*custom_data*/) { @@ -144,33 +146,26 @@ struct HttpContext { HttpContextData *httpContextData = getSocketContextDataS(s); // Set per-socket authorization status auto *httpResponseData = reinterpret_cast *>(us_socket_ext(s)); + auto tellNodeHttp = [&]() { + if (httpResponseData->socketData) { + httpContextData->nodeHttpHooks->onHandshake(httpResponseData->socketData, success, verify_error); + } + }; /* Opened already: this reports the failure of the established session, and usockets closes behind it. */ if (httpResponseData->filteredOpen) { + tellNodeHttp(); return; } - /* The app-level flag reflects the default entry; a per-serverName - * entry adds its own client-certificate policy for its name. */ - bool rejectUnauthorized = httpContextData->flags.rejectUnauthorized || - us_socket_server_name_reject_unauthorized(s); - if(rejectUnauthorized) { + if (us_socket_rejects_unauthorized(s)) { if(!success || verify_error.error != 0) { + tellNodeHttp(); // we failed to handshake, close the socket - us_socket_close(s, 0, nullptr); + if (!us_socket_is_closed(s)) { + us_socket_close_now(s); + } return; } } - /* This bit backs node's `socket._secureEstablished` (via - * JSNodeHTTPServerSocket::isAuthorized → handle.secureEstablished), - * i.e. "TLS handshake completed", not "peer cert verified". A - * server that doesn't requestCert will always see - * verify_error.error != 0 (no client cert), so do NOT fold the - * verify result in here — that would make every HTTPS request - * report _secureEstablished = false. Peer-cert authorization is - * surfaced separately (rejectUnauthorized above / tls.authorized). */ - httpResponseData->isAuthorized = success; - 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(); @@ -181,6 +176,11 @@ struct HttpContext { f((HttpResponse *) s, 1); } + /* Last: a 'secureConnection' listener can close the server, which closes the connections that count as idle. */ + if (!us_socket_is_closed(s)) { + tellNodeHttp(); + } + if (httpContextData->onHttp2 && !us_socket_is_closed(s) && us_socket_alpn_is_h2(s)) { httpContextData->onHttp2(httpContextData->http2Context, s, nullptr, 0, 0); } @@ -198,11 +198,11 @@ struct HttpContext { } else { new (us_socket_ext(s)) HttpResponseData; } - /* Any connected socket should timeout until it has a request */ - ((HttpResponse *) s)->resetTimeout(); - HttpContextData *httpContextData = getSocketContextDataS(s); + /* Any connected socket should timeout until it has a request */ + ((HttpResponse *) s)->setTimeout(httpContextData->idleTimeout); + /* node:http compat: the headers/request timeout window opens at accept * (mirrors the parser-initialize timestamp in Node's ConnectionsList), * so a client that connects and never sends anything still expires. */ @@ -225,6 +225,11 @@ struct HttpContext { * for both transports. */ s->flags.allow_half_open = 1; + /* onHandshake arms the idle timeout. node:https has the handshakeTimeout of its socket. */ + if constexpr (SSL) { + us_socket_timeout(s, IsNodeHttp ? 0 : HANDSHAKE_TIMEOUT_SECONDS); + } + /* Call filter: accepted (both transports)... */ ((AsyncSocketData *) us_socket_ext(s))->filteredAccept = true; for (auto &f : httpContextData->filterHandlers) { @@ -243,6 +248,18 @@ struct HttpContext { return s; } + /* The application still owes the response, or the rest of a body that it streams. */ + static bool isResponseUndetermined(uint32_t state) { + return (state & HttpResponseData::HTTP_RESPONSE_PENDING) + && !(state & (HttpResponseData::HTTP_END_CALLED | HttpResponseData::HTTP_FIXED_LENGTH_FILE_BODY)); + } + + /* No close_notify, which says that the stream is whole, over a response that isn't finished or isn't sent. The second close drops what TLS still holds. */ + static us_socket_t *closeOverUnfinishedResponse(us_socket_t *s) { + us_socket_close(s, LIBUS_SOCKET_CLOSE_CODE_FAST_SHUTDOWN, nullptr); + return us_socket_close(s, LIBUS_SOCKET_CLOSE_CODE_FAST_SHUTDOWN, nullptr); + } + /* Closes a socket that JavaScript destroyed during its parse. The bit goes first: a TLS socket with spilled ciphertext defers its own close, and a second close would drop the spill. */ static void closeDestroyedNodeHttpSocket(us_socket_t *s, HttpResponseData *httpResponseData) { httpResponseData->state &= ~HttpResponseData::HTTP_NODE_CLOSE_AFTER_MESSAGE; @@ -261,46 +278,22 @@ struct HttpContext { /* Call filter */ HttpContextData *httpContextData = getSocketContextDataS(s); - bool nodeHttpTunnelAfterBody = false; - if constexpr (IsNodeHttp) nodeHttpTunnelAfterBody = (httpResponseData->state & HttpResponseData::HTTP_NODE_TUNNEL_AFTER_BODY) != 0; - if(httpResponseData->isConnectRequest || nodeHttpTunnelAfterBody) { - if (httpResponseData->socketData && httpContextData->onSocketData) { - httpContextData->onSocketData(httpResponseData->socketData, SSL, s, "", 0, true); - } - /* node:http: the body of a tunnel that started has had its last chunk from the parser. This close can come from inside that call. */ - if(httpResponseData->inStream && (!IsNodeHttp || nodeHttpTunnelAfterBody)) { - httpResponseData->inStream(reinterpret_cast *>(s), "", 0, true, httpResponseData->userData); - httpResponseData->inStream = nullptr; - } + /* No end of its own is left for the body of a Bun.serve CONNECT, or of a node:http Upgrade request that waits to leave HTTP. */ + bool endBody = httpResponseData->isConnectRequest; + if constexpr (IsNodeHttp) endBody = (httpResponseData->state & HttpResponseData::HTTP_NODE_LEAVE_AFTER_BODY) != 0; + if (endBody && httpResponseData->inStream) { + httpResponseData->inStream(reinterpret_cast *>(s), "", 0, true, httpResponseData->userData); + httpResponseData->inStream = nullptr; } + ((HttpResponse *) s)->reportGone(); - if (httpResponseData->filteredOpen) { - for (auto &f : httpContextData->filterHandlers) { - f((HttpResponse *) s, -1); - } - } - if (httpResponseData->filteredAccept) { - for (auto &f : httpContextData->filterHandlers) { - f((HttpResponse *) s, httpResponseData->filteredIdleTunnel ? -4 : -2); - } - } - - if (httpResponseData->socketData && httpContextData->onSocketClosed) { - int readError = 0; - bool peerEnded = false; - /* A tunnel reports its EOF through onSocketData above. */ - if (!httpResponseData->isConnectRequest && !nodeHttpTunnelAfterBody) { - /* Above FAST_SHUTDOWN the code is the error of the failed read (a peer RST). */ - readError = code > LIBUS_SOCKET_CLOSE_CODE_FAST_SHUTDOWN ? code : 0; - peerEnded = (httpResponseData->state & HttpResponseData::HTTP_NODE_PEER_ENDED) != 0; - } - httpContextData->onSocketClosed(httpResponseData->socketData, SSL, s, readError, peerEnded); - } - /* Signal broken HTTP request only if we have a pending request */ - if (httpResponseData->onAborted != nullptr && httpResponseData->userData != nullptr) { - httpResponseData->onAborted((HttpResponse *)s, httpResponseData->userData); + if (httpResponseData->socketData) { + /* Above FAST_SHUTDOWN the code is the error of the failed read (a peer RST). */ + int readError = code > LIBUS_SOCKET_CLOSE_CODE_FAST_SHUTDOWN ? code : 0; + httpContextData->nodeHttpHooks->onClosed(httpResponseData->socketData, readError, (httpResponseData->state & HttpResponseData::HTTP_NODE_PEER_ENDED) != 0); } + ((HttpResponse *) s)->abortRequest(); /* Destruct the type onOpen constructed */ @@ -326,16 +319,16 @@ struct HttpContext { template static bool isShutDownByOwner(us_socket_t *s) { if constexpr (SSL && IsNodeHttp) { - if (us_socket_ssl_send_rejected(s) && !(SSL_get_shutdown((const ssl_st *) us_socket_get_native_handle(s)) & SSL_SENT_SHUTDOWN)) { + if (us_socket_rejected_send_error(s) && !(SSL_get_shutdown((const ssl_st *) us_socket_get_native_handle(s)) & SSL_SENT_SHUTDOWN)) { return false; } } return us_socket_is_shut_down(s); } - /* Not for the body of an accepted Upgrade request: its last chunk starts a tunnel. */ + /* Not for the body of an accepted Upgrade request: a socket that is shut down can't leave HTTP behind it. */ static bool readsBodyBehindOwnFin(HttpResponseData *httpResponseData) { - return (httpResponseData->state & (HttpResponseData::HTTP_NODE_HALF_CLOSED | HttpResponseData::HTTP_NODE_TUNNEL_AFTER_BODY)) == HttpResponseData::HTTP_NODE_HALF_CLOSED + return (httpResponseData->state & (HttpResponseData::HTTP_NODE_HALF_CLOSED | HttpResponseData::HTTP_NODE_LEAVE_AFTER_BODY)) == HttpResponseData::HTTP_NODE_HALF_CLOSED && httpResponseData->inStream != nullptr; } @@ -353,10 +346,12 @@ struct HttpContext { HttpContextData *httpContextData = getSocketContextDataS(s); HttpResponseData *httpResponseData = (HttpResponseData *) us_socket_ext(s); - /* 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; - if (us_socket_is_shut_down((us_socket_t *) s) && !isHalfOpenTunnel && !readsBodyBehindOwnFin(httpResponseData)) { + if constexpr (IsNodeHttp) { + ((HttpResponseData *) httpResponseData)->bytesRead += (unsigned int) length; + } + + /* Do not accept any data while in shutdown state. node:http is half-open: the socket of 'connect' or 'upgrade' and the body of the request in flight still read after socket.end(). */ + if (us_socket_is_shut_down((us_socket_t *) s) && !(IsNodeHttp && httpResponseData->isParserDetached()) && !readsBodyBehindOwnFin(httpResponseData)) { /* Balance the us_socket_ref above — every other return path * reaches the unref via returnedData. */ us_socket_unref(s); @@ -411,22 +406,42 @@ struct HttpContext { } } + if constexpr (IsNodeHttp) { + if ((httpResponseData->state & HttpResponseData::HTTP_NODE_RAW_READS) && httpResponseData->socketData) [[unlikely]] { + httpContextData->nodeHttpHooks->onRawRead(httpResponseData->socketData, data, length); + /* That ran JavaScript: a closed or upgraded socket no longer has an HttpResponseData. */ + if (us_socket_is_closed(s)) { + return s; + } + if (us_socket_kind(s) != socketKind()) { + us_socket_unref(s); + 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 - * parsed as HTTP and keep flowing below. */ + * parser); ignore further request bytes. */ if constexpr (IsNodeHttp) { - if ((httpResponseData->state & HttpResponseData::HTTP_NODE_PARSING_STOPPED) && !httpResponseData->isConnectRequest) { - us_socket_unref(s); - return s; - } - /* Same for bytes that arrive behind a finished response that closes the - * connection while its body is still draining (onWritable closes then). */ - if (httpResponseData->isDrainingBeforeClose() && !httpResponseData->isConnectRequest) [[unlikely]] { + if (httpResponseData->state & HttpResponseData::HTTP_NODE_PARSING_STOPPED) { + /* Like llhttp, a failed parser reports its error again for every read a 'clientError' listener lets through. */ + HttpParserError parseError = ((HttpResponseData *) httpResponseData)->parseError; + if (parseError != HTTP_PARSER_ERROR_NONE && httpContextData->onClientError) { + httpContextData->onClientError(SSL, s, parseError, data, length); + if (us_socket_is_closed(s)) { + return s; + } + } us_socket_unref(s); return s; } } + /* Nor is anything parsed behind a complete response that closes the connection, while that response drains (onWritable closes then). */ + if (httpResponseData->isDrainingBeforeClose() && !httpResponseData->isConnectRequest) [[unlikely]] { + us_socket_unref(s); + return s; + } /* Cork this socket */ ((AsyncSocket *) s)->cork(); @@ -438,9 +453,14 @@ 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; + constexpr bool trackNodeHttpTimings = IsNodeHttp; /* The return value is entirely up to us to interpret. The HttpParser cares only for whether the returned value is DIFFERENT from passed user */ @@ -453,31 +473,20 @@ struct HttpContext { nodeHttpRequestTrailers = &nodeHttpResponseData->nodeHttpRequestTrailers; } - auto result = httpResponseData->template consumePostPadded(httpContextData->maxHeaderSize, httpContextData->maxHeadersCount, httpResponseData->isConnectRequest, httpContextData->flags.requireHostHeader,httpContextData->flags.useStrictMethodValidation, httpContextData->flags.useInsecureHTTPParser, httpContextData->flags.useLenientTransferEncoding, nodeHttpRequestTrailers, &httpResponseData->chunkedExtensionsByteCount, data, (unsigned int) length, s, [httpContextData](void *s, HttpRequest *httpRequest) -> void * { + auto result = httpResponseData->template consumePostPadded(httpContextData->maxHeaderSize, httpContextData->maxHeadersCount, httpResponseData->isConnectRequest, httpContextData->flags.useInsecureHTTPParser, httpContextData->flags.useLenientTransferEncoding, nodeHttpRequestTrailers, &httpResponseData->chunkedExtensionsByteCount, data, (unsigned int) length, s, [httpContextData](void *s, HttpRequest *httpRequest) -> void * { HttpResponseData *httpResponseData = (HttpResponseData *) us_socket_ext((us_socket_t *) s); - /* RFC 9112 9.6: a complete response marked this connection close - * and sawConnectionClose did not see it (a Connection: close response - * header, or a close-delimited node:http body). Run onData's tail now: - * resetResponseState() below drops the mark. Latch first: a socket that - * has not drained stays open, and the parser holds this request's - * framing, so later bytes must not reach it. Before the timeout reset, - * so that socket still times out. */ - if (httpResponseData->isDrainingBeforeClose()) [[unlikely]] { - /* node:http stops without the latch: llhttp does not see the response, so a later read is no parse error. */ - if constexpr (!IsNodeHttp) { - httpResponseData->sawConnectionClose = true; + /* node:http parses on behind a complete response that closes the connection, like llhttp, and stops at the next request. */ + if constexpr (IsNodeHttp) { + if (httpResponseData->isDrainingBeforeClose()) [[unlikely]] { + return stopBehindClosingResponse((us_socket_t *) s, httpResponseData); } - us_socket_unref((us_socket_t *) s); - ((AsyncSocket *) s)->uncork(); - ((HttpResponse *) s)->closeIfDoneAndMarked(httpResponseData); - return nullptr; } - /* For every request we reset the timeout and hang until user makes action */ + /* Every request starts from the server's idle timeout. */ /* Warning: if we are in shutdown state, resetting the timer is a security issue! */ - us_socket_timeout((us_socket_t *) s, 0); + ((HttpResponse *) s)->setTimeout(httpContextData->idleTimeout); /* node:http compat: the JS layer stopped HTTP processing on this * connection (the user emitted 'close' on the socket - Node frees @@ -500,9 +509,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 +527,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 { @@ -531,14 +540,13 @@ struct HttpContext { * queues its response (res.socket === null until it becomes the * connection's current response). The per-response state reset is * skipped here - it still belongs to the in-flight response - and - * is applied by JSNodeHTTPServerSocket::startPipelinedResponse() + * is applied by JSNodeHTTPServerSocket::startRawResponse() * when the queued response is activated. Node keeps reading and * dispatching pipelined requests while responses are queued (its * parserOnIncoming only pauses the socket once the outgoing data * backs up), so reads are paused only when this connection already - * has unsent outgoing backpressure; they resume once the pipeline - * drains and the backpressure flushes (startPipelinedResponse / - * onWritable). That pause is also Node's flood prevention for sync + * has unsent outgoing backpressure; they resume once the + * backpressure flushes (onWritable). That pause is also Node's flood prevention for sync * write()+end() handlers that back up the socket: the request * behind the backed-up response lands here. */ httpResponseData->state |= HttpResponseData::HTTP_NODE_PIPELINED_DISPATCH; @@ -547,8 +555,7 @@ struct HttpContext { httpResponseData->isIdle = false; if (((AsyncSocket *) s)->getBufferedAmount() > 0) { /* Like Node, the rest of this read is still parsed: the pause holds from the next read. */ - httpResponseData->state |= HttpResponseData::HTTP_NODE_READS_PAUSED; - ((HttpResponse *) s)->pause(); + ((HttpResponse *) s)->pauseReads(HttpResponseData::READ_PAUSE_FLOOD); } } } else { @@ -570,6 +577,18 @@ struct HttpContext { httpResponseData->state |= HttpResponseData::HTTP_CONNECTION_CLOSE; } + httpResponseData->requestBodyBudget = UINT64_MAX; + if (declaresBodyOverLimit(httpRequest->getContentLength(), httpContextData->maxRequestBodySize)) [[unlikely]] { + httpResponseData->state |= HttpResponseData::HTTP_REQUEST_BODY_TOO_LARGE; + } + + /* 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 +609,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() || isKnownMethod(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,59 +649,64 @@ struct HttpContext { ((HttpResponse *) s)->resetTimeout(); } + if constexpr (!IsNodeHttp) { + if (httpResponseData->isDrainingBeforeClose()) [[unlikely]] { + return stopBehindClosingResponse((us_socket_t *) s, httpResponseData); + } + } + /* Continue parsing */ return s; }, [httpResponseData, httpContextData](void *user, std::string_view data, bool fin) -> void * { - /* node:http compat: an accepted Upgrade request's body just completed - - * after this fin chunk has been delivered to the request body stream - * below, the connection switches into tunnel mode so every byte after - * the end of the message reaches the 'upgrade' listener's socket as - * opaque data. Deferred so the fin itself is not routed to the - * raw-socket data path. */ - bool switchToTunnelAfterThisChunk = false; + /* node:http: the body of an accepted Upgrade request is complete. Once the request has this chunk, the connection leaves HTTP. */ + bool leaveAfterThisChunk = false; if constexpr (IsNodeHttp) { - switchToTunnelAfterThisChunk = fin && !httpResponseData->isConnectRequest && (httpResponseData->state & HttpResponseData::HTTP_NODE_TUNNEL_AFTER_BODY); + leaveAfterThisChunk = fin && (httpResponseData->state & HttpResponseData::HTTP_NODE_LEAVE_AFTER_BODY); } /* node:http compat: the request message (head + body) has been fully * received - the connection is idle for the headers/request timeout * sweeps until the next message starts. */ if constexpr (IsNodeHttp) { - if (fin && !httpResponseData->isConnectRequest) { + if (fin) { auto *nodeHttpResponseData = (HttpResponseData *) httpResponseData; nodeHttpResponseData->lastMessageStartMs = 0; nodeHttpResponseData->headersCompleted = false; nodeHttpResponseData->requestTimeoutReported = false; /* The response ended before this body did: markDone() marked the connection idle, and the read of the rest of the body cleared that. */ - if (!switchToTunnelAfterThisChunk && !(httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) + if (!leaveAfterThisChunk && !(httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) && httpResponseData->nodeHttpQueuedPipelinedCount == 0) { httpResponseData->isIdle = true; } } } - if (httpResponseData->isConnectRequest && httpResponseData->socketData && httpContextData->onSocketData) { - httpContextData->onSocketData(httpResponseData->socketData, SSL, (struct us_socket_t *) user, data.data(), data.length(), fin); - } - - if (switchToTunnelAfterThisChunk) { - httpResponseData->state &= ~HttpResponseData::HTTP_NODE_TUNNEL_AFTER_BODY; - httpResponseData->isConnectRequest = true; + if (leaveAfterThisChunk) { + httpResponseData->state &= ~HttpResponseData::HTTP_NODE_LEAVE_AFTER_BODY; } /* We always get an empty chunk even if there is no data */ if (httpResponseData->inStream) { + if (data.length() > httpResponseData->requestBodyBudget) [[unlikely]] { + ((HttpResponse *) user)->refuseRequestBody(); + return us_socket_is_closed((us_socket_t *) user) ? nullptr : stopBehindClosingResponse((us_socket_t *) user, httpResponseData); + } + httpResponseData->requestBodyBudget -= data.length(); /* 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. */ + 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 */ httpResponseData->received_bytes_per_timeout += (unsigned int) data.length(); - if (httpResponseData->received_bytes_per_timeout >= HTTP_RECEIVE_THROUGHPUT_BYTES * httpResponseData->idleTimeout) { + if (httpResponseData->received_bytes_per_timeout >= HTTP_MIN_THROUGHPUT_BYTES_PER_SECOND * httpResponseData->idleTimeout) { ((HttpResponse *) user)->resetTimeout(); httpResponseData->received_bytes_per_timeout = 0; } @@ -702,7 +727,6 @@ struct HttpContext { /* We absolutely have to terminate parsing if shutdown */ if (isShutDownByOwner((us_socket_t *) user) && (fin || !readsBodyBehindOwnFin(httpResponseData))) { - /* node:http: this socket can be a tunnel that still reads. Its bytes are not for a body that ended. */ if constexpr (IsNodeHttp) { if (fin) { httpResponseData->inStream = nullptr; @@ -717,15 +741,6 @@ struct HttpContext { httpResponseData->inStream = nullptr; } } - if constexpr (IsNodeHttp) { - if (switchToTunnelAfterThisChunk) { - /* The response cannot resume reads in tunnel mode: lift the pause the body left. */ - Bun__NodeHTTP__onReadsResumable(SSL, (struct us_socket_t *) user); - if (us_socket_is_closed((struct us_socket_t *) user)) { - return nullptr; - } - } - } /* JavaScript destroyed this socket earlier in this read: its message is delivered, so close before the next request is parsed. */ if constexpr (IsNodeHttp) { if (fin && !us_socket_is_closed((us_socket_t *) user) @@ -734,6 +749,27 @@ struct HttpContext { return nullptr; } } + if constexpr (IsNodeHttp) { + if (leaveAfterThisChunk && httpResponseData->socketData) { + httpContextData->nodeHttpHooks->onUpgradeBodyEnded(httpResponseData->socketData); + if (httpContextData->upgradedWebSocket || us_socket_is_closed((struct us_socket_t *) user)) { + return nullptr; + } + /* A socket whose write side is shut down stays, with its parser detached. The rest of this read isn't HTTP either. */ + us_socket_unref((us_socket_t *) user); + ((AsyncSocket *) user)->uncork(); + return nullptr; + } + } + /* Every request gets a fin, with or without a body. The handler can complete the response from this callback. */ + if constexpr (!IsNodeHttp) { + if (httpResponseData->isDrainingBeforeClose()) [[unlikely]] { + return stopBehindClosingResponse((us_socket_t *) user, httpResponseData); + } + if (fin) { + httpResponseData->parkAtNextBoundary = (httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) != 0; + } + } return user; }); @@ -751,6 +787,7 @@ struct HttpContext { * parsing further requests on this connection. */ if (IsNodeHttp && httpContextData->onClientError) { httpResponseData->state |= HttpResponseData::HTTP_NODE_PARSING_STOPPED; + ((HttpResponseData *) httpResponseData)->parseError = result.parserError; httpContextData->onClientError(SSL, s, result.parserError, data, length); if (!us_socket_is_closed(s)) { /* Balance the parsing ref taken at the top of onData (the @@ -761,6 +798,10 @@ struct HttpContext { closeDestroyedNodeHttpSocket(s, httpResponseData); return s; } + /* The listener kept the socket. No request can follow, so unless a response still owns the connection only this timer closes it. */ + if (!(httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) && httpResponseData->nodeHttpQueuedPipelinedCount == 0) { + ((HttpResponse *) s)->setTimeout(NODE_PARSE_ERROR_TIMEOUT_S); + } } /* Flush anything the 'clientError' handler wrote (uncorking a * closed socket is a no-op). */ @@ -780,9 +821,8 @@ struct HttpContext { ((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()); - us_socket_shutdown(s); /* Close any socket on HTTP errors */ - us_socket_close(s, 0, nullptr); + ((HttpResponse *) s)->closeAfterLastByte(); } auto returnedData = result.returnedData; @@ -804,7 +844,7 @@ struct HttpContext { if constexpr (IsNodeHttp) { if (httpResponseData->state & HttpResponseData::HTTP_NODE_NOTIFY_READ_PARSED) { httpResponseData->state &= ~HttpResponseData::HTTP_NODE_NOTIFY_READ_PARSED; - Bun__NodeHTTP__onReadParsed(SSL, (us_socket_t *) returnedData); + httpContextData->nodeHttpHooks->onReadParsed(httpResponseData->socketData); /* That ran JavaScript: a closed or upgraded socket no longer has an HttpResponseData. */ if (us_socket_is_closed((us_socket_t *) returnedData) || us_socket_kind((us_socket_t *) returnedData) != socketKind()) { ((AsyncSocket *) returnedData)->uncork(); @@ -839,17 +879,26 @@ struct HttpContext { ((HttpResponse *) s)->resetTimeout(); } - /* We need to check if we should close this socket here now */ - if (httpResponseData->shouldCloseConnection()) { - if ((httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) == 0) { - if (((AsyncSocket *) s)->hasFullyDrained()) { - ((AsyncSocket *) s)->shutdown(); - /* We need to force close after sending FIN since we want to hinder - * clients from keeping to send their huge data */ - ((AsyncSocket *) s)->close(); + /* markDone() requests the replay, unless the response completed before the park. */ + if constexpr (!IsNodeHttp) { + if (!httpResponseData->parkedRequestBytes.isEmpty()) [[unlikely]] { + /* Keep reading until a read's worth is parked, so the peer's FIN still reaches onEnd and aborts the pending response. */ + if (httpResponseData->parkedRequestBytes.size() - httpResponseData->parkedRequestBytesStart >= LIBUS_RECV_BUFFER_LENGTH) { + /* Nothing would end a pause behind a response that may never come. */ + if (isResponseUndetermined(httpResponseData->state)) { + return closeOverUnfinishedResponse(s); + } + /* This pause lifts when the response ahead has drained, and after a hangup it never does. */ + s->hangup_closes_unsent = 1; + ((HttpResponse *) s)->pauseReads(HttpResponseData::READ_PAUSE_PARKED); + } + if ((httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) == 0) { + us_socket_request_writable(s); } } } + + ((HttpResponse *) s)->closeIfDoneAndMarked(httpResponseData); return (us_socket_t *) returnedData; } @@ -858,6 +907,13 @@ struct HttpContext { /* This path is only for upgraded websockets */ AsyncSocket *asyncSocket = (AsyncSocket *) httpContextData->upgradedWebSocket; + /* The socket of 'connect' or 'upgrade' holds the loop through its own handle, which unref() and the peer's FIN release. */ + if constexpr (IsNodeHttp) { + if (!us_socket_is_closed((us_socket_t *) asyncSocket)) { + us_socket_unref((us_socket_t *) asyncSocket); + } + } + /* Uncork here as well (note: what if we failed to uncork and we then pub/sub before we even upgraded?) */ asyncSocket->uncork(); @@ -873,7 +929,9 @@ struct HttpContext { * so ours is s): the field is per context, and a microtask of this dispatch, or an * earlier upgrade from a request body handler, can set it. */ unsigned int consumed = result.consumedBytes(); - if (consumed < (unsigned int) length && (us_socket_t *) asyncSocket == s + const bool restOfReadTaken = httpContextData->flags.restOfReadTaken; + httpContextData->flags.restOfReadTaken = false; + if (consumed < (unsigned int) length && (us_socket_t *) asyncSocket == s && !restOfReadTaken && !us_socket_is_closed(s) && !us_socket_is_shut_down(s)) { return us_dispatch_data(s, data + consumed, (int) ((unsigned int) length - consumed)); } @@ -889,6 +947,14 @@ struct HttpContext { return s; } + /* RFC 9112 9.6: nothing behind a complete response that closes the connection is a request. Ends the parse of this read and runs onData's tail. onData drops later reads. */ + static void *stopBehindClosingResponse(us_socket_t *s, HttpResponseData *httpResponseData) { + us_socket_unref(s); + ((AsyncSocket *) s)->uncork(); + ((HttpResponse *) s)->closeIfDoneAndMarked(httpResponseData); + return nullptr; + } + template static us_socket_t *onWritable(us_socket_t *s) { auto *asyncSocket = reinterpret_cast *>(s); @@ -927,8 +993,13 @@ struct HttpContext { auto *httpContextData = getSocketContextDataS(s); - if (httpResponseData->isConnectRequest && httpResponseData->socketData && httpContextData->onSocketDrain) { - httpContextData->onSocketDrain(httpResponseData->socketData, SSL, (struct us_socket_t *) s); + if (httpResponseData->socketData) { + /* A write callback can close the connection, or end the response that kept it in HTTP. */ + auto *group = us_socket_group(s); + httpContextData->nodeHttpHooks->onDrain(httpResponseData->socketData); + if (us_socket_is_closed(s) || us_socket_group(s) != group) { + return s; + } } /* Ask the developer to write data and return success (true) or failure (false), OR skip sending anything and return success (true). */ if (httpResponseData->onWritable) { @@ -951,9 +1022,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)) { @@ -973,14 +1046,8 @@ struct HttpContext { /* Drain any socket buffer, this might empty our backpressure and thus finish the request */ asyncSocket->flush(); - /* node:http compat: reads were paused while pipelined responses were - * queued and stayed paused because the socket still had outgoing - * backpressure when the queue drained; now that it has flushed, read - * new requests again. */ if constexpr (IsNodeHttp) { - if (httpResponseData->state & HttpResponseData::HTTP_NODE_READS_PAUSED) { - Bun__NodeHTTP__onReadsResumable(SSL, s); - } + reinterpret_cast *>(s)->endFloodPauseIfDrained(); } /* Should we close this connection after a response - and is this response really done? */ @@ -1003,21 +1070,68 @@ 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()) { + httpContextData->nodeHttpHooks->halfCloseAfterDrain(httpResponseData->socketData); + return s; + } } if (responseDone && asyncSocket->hasFullyDrained()) { - asyncSocket->shutdown(); - /* We need to force close after sending FIN since we want to hinder - * clients from keeping to send their huge data */ - asyncSocket->close(); + reinterpret_cast *>(s)->closeAfterLastByte(); + return s; } } /* 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)->resumeReads(HttpResponseData::READ_PAUSE_PARKED); + /* 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; + const bool peerEnded = (httpResponseData->state & HttpResponseData::HTTP_NODE_RECEIVED_FIN) != 0; + 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; + } + /* The peer's FIN came during the response ahead. These requests, or the WebSocket that one of them became, get it now. */ + if (peerEnded && !us_socket_is_closed(s)) { + return us_dispatch_end(s); + } + return returned; + } + template static us_socket_t *onEnd(us_socket_t *s) { auto *asyncSocket = reinterpret_cast *>(s); @@ -1030,63 +1144,50 @@ struct HttpContext { HttpContextData *httpContextData = getSocketContextDataS(s); HttpResponseData *httpResponseData = (HttpResponseData *) us_socket_ext(s); - /* CONNECT/Upgrade tunnels allow half-open: the peer finishing its - * writable side ends the JS socket's readable side ('end' event) but - * the server can keep writing until it ends the socket itself, like - * Node's http server (allowHalfOpen: true). This includes an accepted - * Upgrade whose body never completed (HTTP_NODE_TUNNEL_AFTER_BODY): the - * EOF ends the upgrade socket, exactly like Node's UpgradeStream. */ - if (httpResponseData->isConnectRequest || (httpResponseData->state & HttpResponseData::HTTP_NODE_TUNNEL_AFTER_BODY)) { - if (httpResponseData->socketData && httpContextData->onSocketData) { - httpContextData->onSocketData(httpResponseData->socketData, SSL, s, "", 0, true); + /* The socket that 'connect' or 'upgrade' got is half-open, like Node's: the FIN ends its readable side, and its owner decides when to end it. */ + const bool leaveAfterBody = (httpResponseData->state & HttpResponseData::HTTP_NODE_LEAVE_AFTER_BODY) != 0; + if (httpResponseData->isParserDetached() || leaveAfterBody) { + httpResponseData->state |= HttpResponseData::HTTP_NODE_PEER_ENDED; + if (httpResponseData->socketData) { + httpContextData->nodeHttpHooks->onPeerEnded(httpResponseData->socketData); } /* No more of that body can come. A request that still waits for it holds the event loop, so end it like onClose does. */ - if (!us_socket_is_closed(s) && (httpResponseData->state & HttpResponseData::HTTP_NODE_TUNNEL_AFTER_BODY)) { - httpResponseData->state &= ~HttpResponseData::HTTP_NODE_TUNNEL_AFTER_BODY; - httpResponseData->isConnectRequest = true; + if (leaveAfterBody && !us_socket_is_closed(s)) { + httpResponseData->state &= ~HttpResponseData::HTTP_NODE_LEAVE_AFTER_BODY; if (httpResponseData->inStream) { httpResponseData->inStream((HttpResponse *) s, "", 0, true, httpResponseData->userData); if (!us_socket_is_closed(s)) { httpResponseData->inStream = nullptr; } } + if (!us_socket_is_closed(s) && httpResponseData->socketData) { + httpContextData->nodeHttpHooks->onUpgradeBodyEnded(httpResponseData->socketData); + } } return s; } + bool finishedInScript = false; /* Before the body fin and onClientError below, whose listeners can destroy the socket. Not once this * side shut down: TLS delivers the peer's answer to our close_notify here as an EOF. */ if (!us_socket_is_shut_down(s)) { httpResponseData->state |= HttpResponseData::HTTP_NODE_PEER_ENDED; - } - - /* A request body with no framing (HttpParser::nodeHttpBodyUntilEof) ends - * here: the FIN completes the message, like Node's parser.finish() on - * socketOnEnd. Deliver the fin the data handler in onData would. */ - if (httpResponseData->nodeHttpBodyUntilEof && !(httpResponseData->state & HttpResponseData::HTTP_NODE_PARSING_STOPPED)) { - httpResponseData->nodeHttpBodyUntilEof = false; - auto *nodeHttpResponseData = (HttpResponseData *) httpResponseData; - nodeHttpResponseData->lastMessageStartMs = 0; - nodeHttpResponseData->headersCompleted = false; - nodeHttpResponseData->requestTimeoutReported = false; - if (httpResponseData->inStream) { - us_socket_timeout(s, 0); - httpResponseData->inStream((HttpResponse *) s, nullptr, 0, true, httpResponseData->userData); + if (httpResponseData->socketData) { + finishedInScript = httpContextData->nodeHttpHooks->onPeerEnded(httpResponseData->socketData); if (us_socket_is_closed(s)) { return s; } - httpResponseData->inStream = nullptr; } } - if (httpContextData->onClientError && !(httpResponseData->state & HttpResponseData::HTTP_NODE_PARSING_STOPPED) - && (httpResponseData->hasBufferedPartialRequestHeaders() - || httpResponseData->hasIncompleteRequestBody())) { - httpResponseData->state |= HttpResponseData::HTTP_NODE_PARSING_STOPPED; + /* A request body with no framing (HttpParser::nodeHttpBodyUntilEof) ends + * here: the FIN completes the message, like Node's parser.finish() on + * socketOnEnd. Deliver the fin the data handler in onData would. */ + if (!finishedInScript && ((HttpResponse *) s)->finishParsing() && httpContextData->onClientError) { httpContextData->onClientError(SSL, s, HTTP_PARSER_ERROR_INVALID_EOF, nullptr, 0); - if (us_socket_is_closed(s)) { - return s; - } + } + if (us_socket_is_closed(s)) { + return s; } /* Node's socketOnEnd: with httpAllowHalfOpen, in-flight and queued @@ -1111,18 +1212,17 @@ 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) - && (state & HttpResponseData::HTTP_RESPONSE_PENDING); + bool determinedTail = (state & HttpResponseData::HTTP_RESPONSE_PENDING) && !isResponseUndetermined(state); bool doneButBuffered = !(state & HttpResponseData::HTTP_RESPONSE_PENDING) && !asyncSocket->hasFullyDrained(); - if (tryEndTail || doneButBuffered) { + if (determinedTail || doneButBuffered) { httpResponseData->state |= HttpResponseData::HTTP_NODE_RECEIVED_FIN; return s; } @@ -1137,6 +1237,9 @@ struct HttpContext { static us_socket_t *onTimeout(us_socket_t *s) { /* Force close rather than gracefully shutdown and risk confusing the client with a complete download */ AsyncSocket *asyncSocket = reinterpret_cast *>(s); + if (HttpResponseData *data = (HttpResponseData *) asyncSocket->getAsyncSocketData(); data->idleTimer.rearmIfPeerStillReads(s, data->idleTimeout)) { + return s; + } // Node.js by default closes the connection but they emit the timeout event before that HttpResponseData *httpResponseData = reinterpret_cast *>(asyncSocket->getAsyncSocketData()); @@ -1144,6 +1247,9 @@ struct HttpContext { httpResponseData->onTimeout((HttpResponse *)s, httpResponseData->userData); } asyncSocket->uncork(); + if ((httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) || !asyncSocket->hasFullyDrained()) { + return closeOverUnfinishedResponse(s); + } return us_socket_close_now(s); } @@ -1159,7 +1265,6 @@ struct HttpContext { /* on_writable */ &onWritable, /* on_close */ &onClose, /* on_timeout */ &onTimeout, - /* on_long_timeout */ nullptr, /* on_end */ &onEnd, /* on_connect_error */nullptr, /* on_connecting_error */ nullptr, @@ -1169,12 +1274,9 @@ struct HttpContext { public: /* Construct a new HttpContext using specified loop. SSL_CTX is built and * owned by TemplatedApp; we only learn about it at listen() time. */ - static HttpContext *create(Loop *loop, bool requestCert = false, bool rejectUnauthorized = false) { + static HttpContext *create(Loop *loop) { HttpContext *httpContext = new HttpContext; us_socket_group_init(&httpContext->group, (us_loop_t *) loop, &httpVTable, httpContext); - if (requestCert && rejectUnauthorized) { - httpContext->data.flags.rejectUnauthorized = true; - } return httpContext; } @@ -1195,8 +1297,7 @@ struct HttpContext { std::span methods; std::string method_buffer; std::string_view method_sv_buffer; - // When it's NOT node:http, allow the uWS default precedence ordering. - if (method == "*" && !httpContextData->flags.useStrictMethodValidation) { + if (method == "*") { methods = detail::supportedHttpMethods; } else { method_buffer = std::string(method); @@ -1219,12 +1320,10 @@ struct HttpContext { user.httpRequest->setYield(false); user.httpRequest->setParameters(r->getParameters()); - 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(); - } + /* Middleware? Automatically respond to expectations. RFC 9110 10.1.1: answer with the final status alone when the head already decides it. */ + if (!httpContextData->flags.usingCustomExpectHandler && isExpect100Continue(user.httpRequest->getHeader("expect")) + && !(user.httpResponse->getHttpResponseData()->state & HttpResponseData::HTTP_REQUEST_BODY_TOO_LARGE)) { + user.httpResponse->writeContinue(); } handler(user.httpResponse, user.httpRequest); @@ -1260,7 +1359,8 @@ struct HttpContext { * sizes the ext block from the vtable that is in place. Called before listen(), * so no socket exists yet. There is no way back: a context whose sockets were * sized and constructed for one instantiation cannot be handed to the other. */ - void enableNodeHttpCompat() { + void enableNodeHttpCompat(const NodeHttpSocketHooks *hooks) { + data.nodeHttpHooks = hooks; /* Idempotent: a reload of an existing node:http server (server.reload(), * `bun --hot`) re-runs set_routes and lands here again on a context that is * already listening - same layout, same vtable, nothing to do. The no-socket @@ -1290,15 +1390,19 @@ 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 options, int *error) { + /* 511 is Node's default backlog: node:http's listen({ fd }) is the one caller. */ + return unrefListenSocket(us_socket_group_listen_fd(&group, socketKind(), sslCtx, fd, 511, tcpListenOptions(options), socketExtSize(), error)); } }; diff --git a/packages/bun-uws/src/HttpContextData.h b/packages/bun-uws/src/HttpContextData.h index 45cb7957a258..ead6d912255b 100644 --- a/packages/bun-uws/src/HttpContextData.h +++ b/packages/bun-uws/src/HttpContextData.h @@ -30,10 +30,7 @@ struct Http2Context; struct HttpFlags { bool isParsingHttp: 1 = false; - bool rejectUnauthorized: 1 = false; bool usingCustomExpectHandler: 1 = false; - bool requireHostHeader: 1 = true; - bool useStrictMethodValidation: 1 = false; /* node:http parser leniency. Two llhttp lenient bits: useInsecureHTTPParser = LENIENT_HEADERS * ("relaxed"+"insecure"); useLenientTransferEncoding = LENIENT_TRANSFER_ENCODING ("insecure" * only). TE+CL conflict, chunked-size/CRLF, version, header-token checks stay enforced. */ @@ -43,6 +40,25 @@ struct HttpFlags { * or queued responses keeps the connection open until they drain (Node's * socketOnEnd); when false (the default), the connection ends right away. */ bool httpAllowHalfOpen: 1 = false; + /* The new owner of upgradedWebSocket already has what follows the request head in this read. */ + bool restOfReadTaken: 1 = false; +}; + +/* What node:http hears about a connection. socketData is HttpResponseData::socketData. */ +struct NodeHttpSocketHooks { + /* readError: 0, or the error of the failed read (a peer RST). peerEnded: the peer sent its FIN first. */ + void (*onClosed)(void *socketData, int readError, bool peerEnded); + void (*onDrain)(void *socketData); + void (*onRawRead)(void *socketData, const char *data, int length); + void (*onReadParsed)(void *socketData); + /* The body of an accepted Upgrade request has ended. The socket can have left this context when this returns. */ + void (*onUpgradeBodyEnded)(void *socketData); + /* A TLS handshake ended, or an established session failed. The socket can be closed when this returns. */ + void (*onHandshake)(void *socketData, int success, struct us_bun_verify_error_t verifyError); + /* The peer sent its FIN. The socket can be closed when this returns. Returns whether the socket's 'end' listener calls HttpResponse::finishParsing(). */ + bool (*onPeerEnded)(void *socketData); + /* The bytes a socket.end() waited for are out. The socket can be closed when this returns. */ + void (*halfCloseAfterDrain)(void *socketData); }; template @@ -53,14 +69,7 @@ struct alignas(16) HttpContextData { friend struct Http2Context; private: std::vector *, int)>> filterHandlers; - using OnSocketDataCallback = void (*)(void* userData, int is_ssl, struct us_socket_t *rawSocket, const char *data, int length, bool last); - using OnSocketDrainCallback = void (*)(void* userData, int is_ssl, struct us_socket_t *rawSocket); - using OnSocketUpgradedCallback = void (*)(void* userData, int is_ssl, struct us_socket_t *rawSocket); using OnClientErrorCallback = MoveOnlyFunction; - /* readError: 0, or the error of the failed read (a peer RST). peerEnded: the peer sent its FIN first. */ - using OnSocketClosedCallback = void (*)(void* userData, int is_ssl, struct us_socket_t *rawSocket, int readError, bool peerEnded); - - MoveOnlyFunction missingServerNameHandler; struct RouterData { HttpResponse *httpResponse; @@ -77,14 +86,15 @@ struct alignas(16) HttpContextData { HttpRouter router; void *upgradedWebSocket = nullptr; - /* Used to simulate Node.js socket events. */ - OnSocketClosedCallback onSocketClosed = nullptr; - OnSocketDrainCallback onSocketDrain = nullptr; - OnSocketDataCallback onSocketData = nullptr; - OnSocketUpgradedCallback onSocketUpgraded = nullptr; + /* Set by enableNodeHttpCompat(). A socket only has socketData in such a context. */ + const NodeHttpSocketHooks *nodeHttpHooks = nullptr; OnClientErrorCallback onClientError = nullptr; + /* Seconds a connection may stay silent before it's closed, 0 for never. node:http runs its own timers and leaves it at 0. */ + uint8_t idleTimeout = 0; 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 @@ -110,6 +120,14 @@ struct alignas(16) HttpContextData { HttpFlags flags; bool isParsing(struct us_socket_t *s) const { return flags.isParsingHttp && parsingSocket == s; } + + /* An owner outside uWS adopted s after HttpResponse::leaveHttp(). If s is being parsed, the parser stops here. */ + void socketLeft(struct us_socket_t *s, bool restOfReadTaken) { + if (isParsing(s)) { + upgradedWebSocket = s; + flags.restOfReadTaken = restOfReadTaken; + } + } }; } diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index e19c2b07aa55..9c46ee9709d4 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -30,23 +30,24 @@ #include #include #include +#include #include #include #include +#include #include #include "MoveOnlyFunction.h" #include "ChunkedEncoding.h" #include "BloomFilter.h" -#include "QueryParser.h" #include "HttpErrors.h" +#include "Utilities.h" #if defined(_WIN32) #define strncasecmp _strnicmp #endif extern "C" size_t BUN_DEFAULT_MAX_HTTP_HEADER_SIZE; -extern "C" int16_t Bun__HTTPMethod__from(const char *str, size_t len); namespace uWS { @@ -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, }; @@ -201,6 +206,10 @@ struct HttpResponseData; bool didYield; /* Written right before the request handler runs; see getHasTransferEncoding(). */ bool hasTransferEncoding; + /* Written right before the request handler runs. */ + uint64_t contentLength; + /* Written once the head is complete. */ + bool connectionClose; unsigned int querySeparator; BloomFilter bf; std::pair currentParameters; @@ -235,6 +244,18 @@ struct HttpResponseData; return hasTransferEncoding; } + /* The number the parser frames the body by, or NO_CONTENT_LENGTH. */ + uint64_t getContentLength() + { + return contentLength; + } + + /* RFC 9112 9.6: "close" is a case-insensitive token in the Connection list. Bun.serve does not read it from Proxy-Connection. */ + bool getConnectionClose() + { + return connectionClose; + } + /* Iteration over headers (key, value) */ struct HeaderIterator { @@ -294,12 +315,6 @@ struct HttpResponseData; return std::string_view(nullptr, 0); } - /* RFC 9112 9.6: "close" is a case-insensitive token in the Connection list. Bun.serve does not read it from Proxy-Connection. */ - bool hasConnectionClose(bool orProxyConnection) - { - return hasConnectionToken("close", orProxyConnection); - } - /* llhttp 9.4.2's Connection grammar for one field (Node 26): `lowerToken` is a whole item of the list, SP and HTAB around an item are skipped, and a control byte ends the list. */ static bool fieldHasToken(const Header &field, std::string_view lowerToken) { @@ -490,6 +505,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++; } } @@ -548,13 +567,6 @@ struct HttpResponseData; return headers->key; } - /* Finds and decodes the URI component. */ - std::string_view getQuery(std::string_view key) - { - /* Raw querystring including initial '?' sign */ - return getDecodedQueryValue(key, headers->value.substr(querySeparator)); - } - void setParameters(std::pair parameters) { currentParameters = parameters; @@ -614,9 +626,11 @@ struct HttpResponseData; * and no trailer is surfaced, where Node's llhttp rejects the message. * Consumes the section: it is post-padded in place and the returned * string_views point into it, so it must outlive their use. + * Fields past MAX_TRAILER_FIELDS are validated but not written. framingField gets the + * error for the first Content-Length or Transfer-Encoding field, written or not. * KEEP IN LOCKSTEP with getHeaders' field-line loop below (same * consumeFieldName → tryConsumeFieldValue → CRLF/OWS sequence). */ - static unsigned parseTrailerFields(std::string §ion, std::pair *out, bool useInsecureHTTPParser = false, unsigned outCapacity = MAX_TRAILER_FIELDS) { + static unsigned parseTrailerFields(std::string §ion, std::pair *out, bool useInsecureHTTPParser = false, HttpParserError *framingField = nullptr) { if (section.size() < 4) { return 0; } @@ -628,7 +642,7 @@ struct HttpResponseData; char *p = section.data(); char *end = p + length; unsigned count = 0; - while (count < outCapacity) { + while (true) { /* Empty line (the section's terminating CRLF) - done. */ if (p[0] == '\r') { return (p + 1 < end && p[1] == '\n') ? count : 0; @@ -660,14 +674,22 @@ struct HttpResponseData; while (value.length() && isHTTPHeaderValueWhitespace(value.front())) { value.remove_prefix(1); } - out[count] = { key, value }; - count++; + if (framingField && *framingField == HTTP_PARSER_ERROR_NONE) { + if (key.length() == 14 && !strncasecmp(key.data(), "content-length", 14)) { + *framingField = HTTP_PARSER_ERROR_TRAILER_CONTENT_LENGTH; + } else if (key.length() == 17 && !strncasecmp(key.data(), "transfer-encoding", 17)) { + *framingField = HTTP_PARSER_ERROR_INVALID_TRANSFER_ENCODING; + } + } + if (count < MAX_TRAILER_FIELDS) { + out[count] = { key, value }; + count++; + } } - return count; } - /* node:http compat: validate a captured, complete trailer section before the - * message is completed. Node's llhttp fails the message on a malformed trailer + /* Validate a captured, complete trailer section before the message is + * completed. Node's llhttp fails the message on a malformed trailer * field line (clientError HPE_INVALID_HEADER_TOKEN) instead of completing it * with req.trailers silently dropped; a CTL byte in a value is only accepted * under insecureHTTPParser, exactly like a header value. A Content-Length or @@ -676,38 +698,27 @@ struct HttpResponseData; * the already-set F_CHUNKED collides), unless insecureHTTPParser is set * (llhttp's LENIENT_CHUNKED_LENGTH / LENIENT_TRANSFER_ENCODING, which "relaxed" does not set). An empty * section (bare CRLF, no trailers) is valid. Node counts trailer fields from - * zero against server.maxHeadersCount (maxHeadersCount here, 0 = not set). - * - * Known bound: parseTrailerFields stops at MAX_TRAILER_FIELDS, so a section with - * more valid fields than that followed by a malformed line is accepted where node - * still errors; reaching it requires a deliberately padded (but size-capped) - * section, and rejecting it would need a second scanning mode. */ - static HttpParserError validateNodeTrailerSection(const std::string *section, bool useInsecureHTTPParser, bool useLenientTransferEncoding, uint32_t maxHeadersCount) { - if (!section || section->size() <= 2) { + * zero against server.maxHeadersCount (maxHeadersCount here, 0 = not set). */ + template + static HttpParserError validateTrailerSection(std::string *section, bool useInsecureHTTPParser, bool useLenientTransferEncoding, uint32_t maxHeadersCount) { + if (section->size() <= 2) { return HTTP_PARSER_ERROR_NONE; } - /* parseTrailerFields consumes (post-pads) its input, so validate a copy. */ - std::string copy(*section); + /* parseTrailerFields consumes (post-pads) its input. node:http reads the section again for req.trailers, so it validates a copy. */ + std::string copy; + if constexpr (IsNodeHttp) { + copy = *section; + } std::pair scratch[MAX_TRAILER_FIELDS]; - unsigned count = parseTrailerFields(copy, scratch, useInsecureHTTPParser); + HttpParserError framingField = HTTP_PARSER_ERROR_NONE; + unsigned count = parseTrailerFields(IsNodeHttp ? copy : *section, scratch, useInsecureHTTPParser, &framingField); if (count == 0) { return HTTP_PARSER_ERROR_INVALID_HEADER_TOKEN; } if (maxHeadersCount && count > maxHeadersCount) { return HTTP_PARSER_ERROR_TRAILER_FIELDS_TOO_LARGE; } - if (!useLenientTransferEncoding) { - for (unsigned i = 0; i < count; i++) { - std::string_view name = scratch[i].first; - if (name.length() == 14 && !strncasecmp(name.data(), "content-length", 14)) { - return HTTP_PARSER_ERROR_TRAILER_CONTENT_LENGTH; - } - if (name.length() == 17 && !strncasecmp(name.data(), "transfer-encoding", 17)) { - return HTTP_PARSER_ERROR_INVALID_TRANSFER_ENCODING; - } - } - } - return HTTP_PARSER_ERROR_NONE; + return useLenientTransferEncoding ? HTTP_PARSER_ERROR_NONE : framingField; } private: @@ -718,9 +729,33 @@ struct HttpResponseData; * framing. Every byte until the peer's FIN is body (llhttp's * body_identity_eof); onEnd delivers the fin and clears this. */ 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). */ + /* A request on this connection had Connection: close or was HTTP/1.0. */ 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; + } + } + /* Drops what a replay has consumed, or the vector would grow for as long as a client keeps the queue from emptying. */ + parkedRequestBytes.removeAt(0, std::exchange(parkedRequestBytesStart, 0)); + 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,24 +774,7 @@ struct HttpResponseData; * personality so a client cannot stream unbounded extension bytes. */ static const uint64_t MAX_CHUNK_EXTENSION_SIZE = 16 * 1024; - /* 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) */ - if (str.length() > 18) { - return UINT64_MAX; - } - - uint64_t unsignedIntegerValue = 0; - for (char c : str) { - /* As long as the letter is 0-9 we cannot overflow. */ - if (c < '0' || c > '9') { - return UINT64_MAX; - } - unsignedIntegerValue = unsignedIntegerValue * 10ull + ((unsigned int) c - (unsigned int) '0'); - } - return unsignedIntegerValue; - } - + private: static inline uint64_t hasLess(uint64_t x, uint64_t n) { return (((x)-~0ULL/255*(n))&~(x)&~0ULL/255*128); } @@ -766,31 +784,6 @@ struct HttpResponseData; return ((x + ~0ULL/255) | x) & (~0ULL/255*128); } - /* RFC 9110 5.6.2. Tokens */ - /* Hyphen is not checked here as it is very common */ - static inline bool isUnlikelyFieldNameByte(unsigned char c) - { - /* Digits and 14 of the 15 non-alphanum characters (lacking hyphen) */ - return ((c == '~') | (c == '|') | (c == '`') | (c == '_') | (c == '^') | (c == '.') | (c == '+') - | (c == '*') | (c == '!')) || ((c >= 48) & (c <= 57)) || ((c <= 39) & (c >= 35)); - } - - /* Header names keep their original wire casing; lookups go through - * the case-insensitive getHeader(). */ - static inline bool isFieldNameByte(unsigned char in) { - /* Most common is lowercase alpha and hyphen */ - if (((in >= 97) & (in <= 122)) | (in == '-')) [[likely]] { - return true; - /* Second is upper case alpha */ - } else if ((in >= 65) & (in <= 90)) [[unlikely]] { - return true; - /* These are rarely used but still valid */ - } else if (isUnlikelyFieldNameByte(in)) [[unlikely]] { - return true; - } - return false; - } - static inline char *consumeFieldName(char *p) { /* Best case fast path (particularly useful with clang) */ while (true) { @@ -812,26 +805,12 @@ struct HttpResponseData; } /* Generic */ - while (isFieldNameByte(*(unsigned char *)p)) { + while (isTokenByte(*(unsigned char *)p)) { p++; } return p; } - static bool isValidMethod(std::string_view str, bool useStrictMethodValidation) { - if (str.empty()) return false; - - if (useStrictMethodValidation) { - return Bun__HTTPMethod__from(str.data(), str.length()) != -1; - } - - for (char c : str) { - if (!isValidMethodChar(c)) - return false; - } - return true; - } - static inline bool isValidMethodChar(char c) { return ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) || c == '-'; } @@ -899,7 +878,7 @@ struct HttpResponseData; /* Puts method as key, target as value and returns non-null (or nullptr on error). */ template - static inline ConsumeRequestLineResult consumeRequestLine(char *data, char *end, HttpRequest::Header &header, bool useStrictMethodValidation, uint64_t maxHeaderSize) { + static inline ConsumeRequestLineResult consumeRequestLine(char *data, char *end, HttpRequest::Header &header, uint64_t maxHeaderSize) { /* Scan until single SP, assume next is / (origin request) */ char *start = data; /* This catches the post padded CR and fails */ @@ -909,7 +888,7 @@ struct HttpResponseData; } /* Strict mode fails fast on bytes no llhttp method contains (e.g. * lowercase letters), even if the request line is incomplete. */ - if (useStrictMethodValidation && !isStrictMethodChar(data[0])) { + if (IsNodeHttp && !isStrictMethodChar(data[0])) { return ConsumeRequestLineResult::error(HTTP_HEADER_PARSER_ERROR_INVALID_METHOD); } data++; @@ -945,7 +924,7 @@ struct HttpResponseData; if (isHTTPMethod || isConnect || isProxyStyleURL) [[likely]] { header.key = {start, (size_t) (data - start)}; data++; - if(!isValidMethod(header.key, useStrictMethodValidation)) { + if (IsNodeHttp && !isKnownMethod(header.key)) { return ConsumeRequestLineResult::error(HTTP_HEADER_PARSER_ERROR_INVALID_METHOD); } /* Scan for less than 33 (catches post padded CR and fails) */ @@ -1065,14 +1044,14 @@ struct HttpResponseData; /* The HTTP parser recognizes "\ra" as invalid "\r\n" scan and breaks. maxHeaderFields must not exceed MAX_HEADER_FIELDS: it bounds the writes to headers. */ template - static HttpParserResult getHeaders(char *postPaddedBuffer, char *end, struct HttpRequest::Header *headers, bool &isAncientHTTP, bool &isConnectRequestLine, bool useStrictMethodValidation, bool useInsecureHTTPParser, uint64_t maxHeaderSize, unsigned int maxHeaderFields) { + static HttpParserResult getHeaders(char *postPaddedBuffer, char *end, struct HttpRequest::Header *headers, bool &isAncientHTTP, bool &isConnectRequestLine, bool useInsecureHTTPParser, uint64_t maxHeaderSize, unsigned int maxHeaderFields) { char *preliminaryKey, *preliminaryValue, *start = postPaddedBuffer; /* It is critical for fallback buffering logic that we only return with success * if we managed to parse a complete HTTP request (minus data). */ /* The request line is different from the field names / field values */ - auto requestLineResult = consumeRequestLine(postPaddedBuffer, end, headers[0], useStrictMethodValidation, maxHeaderSize); + auto requestLineResult = consumeRequestLine(postPaddedBuffer, end, headers[0], maxHeaderSize); if (requestLineResult.isErrorOrShortRead()) { /* Error - invalid request line */ @@ -1244,13 +1223,71 @@ struct HttpResponseData; return HttpParserResult::error(HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE); } + /* Continues the body in progress with data / length and moves both past what it took. Returns a result if this read's parse ends here. `offset`: where data starts in the caller's count of consumed bytes. */ + template + std::optional consumeBody(char *&data, unsigned int &length, unsigned int offset, size_t maxTrailerSize, uint32_t maxHeadersCount, bool useInsecureHTTPParser, bool useLenientTransferEncoding, std::string *trailerSection, uint64_t *chunkedExtensionsByteCount, void *user, MoveOnlyFunction &dataHandler) { + if (!isParsingChunkedEncoding(remainingStreamingBytes)) { + const unsigned int emittable = (unsigned int) std::min(remainingStreamingBytes, length); + const bool fin = emittable == remainingStreamingBytes; + /* The parser's state is updated before the handler runs. */ + remainingStreamingBytes -= emittable; + void *returnedUser = dataHandler(user, std::string_view(data, emittable), fin); + if (returnedUser != user) { + return HttpParserResult::success(fin ? offset + emittable : HttpParserResult::WHOLE_READ, returnedUser); + } + data += emittable; + length -= emittable; + return std::nullopt; + } + + std::string_view dataToConsume(data, length); + for (auto chunk : uWS::ChunkIterator(&dataToConsume, &remainingStreamingBytes, chunkedExtensionsByteCount, trailerSection, maxTrailerSize)) { + /* llhttp errors at the offending extension byte, before any body bytes from that chunk reach the application. */ + if (*chunkedExtensionsByteCount > MAX_CHUNK_EXTENSION_SIZE) [[unlikely]] { + return HttpParserResult::error(HTTP_ERROR_413_PAYLOAD_TOO_LARGE, HTTP_PARSER_ERROR_CHUNK_EXTENSIONS_OVERFLOW); + } + const bool fin = chunk.length() == 0; + /* The fin dispatch completes the message: a malformed or framing-field trailer must fail it first (node: HPE_*). */ + if (fin) { + if (HttpParserError trailerError = validateTrailerSection(trailerSection, useInsecureHTTPParser, useLenientTransferEncoding, maxHeadersCount)) [[unlikely]] { + return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, trailerError); + } + if constexpr (!IsNodeHttp) { + fallback.clear(); + } + } + void *returnedUser = dataHandler(user, chunk, fin); + if (returnedUser != user) { + /* The caller's bytes start behind the body, and a body that has not ended leaves none. */ + return HttpParserResult::success(fin ? offset + (length - (unsigned int) dataToConsume.length()) : HttpParserResult::WHOLE_READ, returnedUser); + } + } + if (*chunkedExtensionsByteCount > MAX_CHUNK_EXTENSION_SIZE) [[unlikely]] { + return HttpParserResult::error(HTTP_ERROR_413_PAYLOAD_TOO_LARGE, HTTP_PARSER_ERROR_CHUNK_EXTENSIONS_OVERFLOW); + } + if (isParsingInvalidChunkedEncoding(remainingStreamingBytes)) [[unlikely]] { + if (isTrailerOverflow(remainingStreamingBytes)) { + return HttpParserResult::error(HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_TRAILER_FIELDS_TOO_LARGE); + } + if (isChunkTerminatorError(remainingStreamingBytes)) { + return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_CHUNK_TERMINATOR_EXPECTED); + } + return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_CHUNKED_ENCODING); + } + data = (char *) dataToConsume.data(); + length = (unsigned int) dataToConsume.length(); + return std::nullopt; + } + /* This is the only caller of getHeaders and is thus the deepest part of the parser. */ template - HttpParserResult fenceAndConsumePostPadded(uint64_t maxHeaderSize, uint32_t maxHeadersCount, bool& isConnectRequest, bool requireHostHeader, bool useStrictMethodValidation, bool useInsecureHTTPParser, bool useLenientTransferEncoding, std::string *nodeHttpRequestTrailers, uint64_t *chunkedExtensionsByteCount, char *data, unsigned int length, void *user, HttpRequest *req, MoveOnlyFunction &requestHandler, MoveOnlyFunction &dataHandler) { + HttpParserResult fenceAndConsumePostPadded(uint64_t maxHeaderSize, uint32_t maxHeadersCount, bool& isConnectRequest, bool useInsecureHTTPParser, bool useLenientTransferEncoding, std::string *nodeHttpRequestTrailers, uint64_t *chunkedExtensionsByteCount, char *data, unsigned int length, void *user, HttpRequest *req, MoveOnlyFunction &requestHandler, MoveOnlyFunction &dataHandler) { /* How much data we CONSUMED (to throw away) */ unsigned int consumedTotal = 0; + std::string *trailerSection = IsNodeHttp ? nodeHttpRequestTrailers : &fallback; + /* maxHeadersCount (node:http server.maxHeadersCount, 0 = not set) can only lower the field limit. */ const unsigned int maxHeaderFields = maxHeadersCount && maxHeadersCount < MAX_HEADER_FIELDS ? maxHeadersCount : MAX_HEADER_FIELDS; @@ -1260,16 +1297,6 @@ struct HttpResponseData; data[length + 1] = 'a'; /* Anything that is not \n, to trigger "invalid request" */ req->ancientHttp = false; for (;length;) { - /* node:http server compat: an accepted Upgrade request whose body just - * finished parsing switched this connection into tunnel mode (the data - * handler set isConnectRequest when it saw the body fin). Everything - * after the end of that message is opaque data for the 'upgrade' - * listener's socket, never a pipelined HTTP request. */ - if (IsNodeHttp && isConnectRequest) [[unlikely]] { - void *returnedUser = dataHandler(user, std::string_view(data, length), false); - consumedTotal += length; - return HttpParserResult::success(consumedTotal, returnedUser); - } /* RFC 9112 2.2: ignore empty lines (CRLF) received prior to the * request-line, like Node/llhttp - e.g. a stray "\r\n" sent on an * idle keep-alive connection must not be treated as a bad request. @@ -1291,15 +1318,23 @@ struct HttpResponseData; } } } - /* Must stay below the tunnel check, the park and the CR/LF skip, like llhttp's closed state. */ + /* Must stay below 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 can pause 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); + auto result = getHeaders(data, data + length, req->headers, req->ancientHttp, isConnectRequestLine, useInsecureHTTPParser, maxHeaderSize, maxHeaderFields); if(result.isError()) { return result; } @@ -1321,10 +1356,13 @@ 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)) { + req->connectionClose = req->hasConnectionToken("close", IsNodeHttp); + if (req->isAncient() || req->connectionClose) { sawConnectionClose = true; } /* RFC 9112 6.3 @@ -1343,12 +1381,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); } } } @@ -1437,32 +1475,31 @@ struct HttpResponseData; return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_TRANSFER_ENCODING); } - /* Break if no host header (but we can have empty string which is different from nullptr). - * node:http only: Upgrade and CONNECT requests are exempt, because Node.js dispatches - * them through the 'upgrade'/'connect' events before its Host requirement is enforced. - * Checked after the Content-Length / Transfer-Encoding smuggling checks: those are - * 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() - && !(IsNodeHttp && (isConnectRequestLine || req->getHeader("upgrade").data()))) { + /* An empty Host counts as one. node:http answers a request without one itself, in its turn, like Node's parserOnIncoming. */ + if (!IsNodeHttp && !req->ancientHttp && hostHeaderCount == 0) { 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()); // lets check if content len is valid before calling requestHandler if(contentLengthStringLen) { - remainingStreamingBytes = toUnsignedInteger(contentLengthString); + remainingStreamingBytes = parseContentLength(contentLengthString); /* remainingStreamingBytes is overloaded: for Content-Length it holds the raw byte * count, for Transfer-Encoding: chunked it holds the ChunkedEncoding state word. * isParsingChunkedEncoding() distinguishes the two by testing the flag bits, so a * 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); } } @@ -1480,9 +1517,10 @@ struct HttpResponseData; /* Same verdict that selects chunked framing below, so the handler's * has-body decision cannot disagree with how the body is consumed. */ req->hasTransferEncoding = transferEncoding.has; + req->contentLength = contentLengthStringLen ? remainingStreamingBytes : NO_CONTENT_LENGTH; /* Read before the handler runs: an upgrade destroys this parser. */ const bool hasBody = transferEncoding.has || (contentLengthStringLen && remainingStreamingBytes); - /* Tunnel mode starts only for a CONNECT that is dispatched: a request line whose headers are still to come must not leave a tunnel with no socket. */ + /* Only for a CONNECT that is dispatched, not for a request line whose headers are still to come. */ if (isConnectRequestLine) { isConnectRequest = true; } @@ -1539,71 +1577,19 @@ struct HttpResponseData; } } } - } else if (transferEncoding.has) { - /* We already validated that chunked is last if present, before calling the handler */ - remainingStreamingBytes = STATE_IS_CHUNKED; - *chunkedExtensionsByteCount = 0; - /* If consume minimally, we do not want to consume anything but we want to mark this as being chunked */ - 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)) { - /* 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]] { - return HttpParserResult::error(HTTP_ERROR_413_PAYLOAD_TOO_LARGE, HTTP_PARSER_ERROR_CHUNK_EXTENSIONS_OVERFLOW); - } - /* The fin dispatch completes the message: a malformed or - * framing-field trailer must fail it first (node: HPE_*). */ - if (IsNodeHttp && chunk.length() == 0) { - if (HttpParserError trailerError = validateNodeTrailerSection(nodeHttpRequestTrailers, useInsecureHTTPParser, useLenientTransferEncoding, maxHeadersCount)) [[unlikely]] { - return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, trailerError); - } - } - const bool fin = chunk.length() == 0; - void *returnedUser = dataHandler(user, chunk, fin); - if (returnedUser != user) { - /* The data handler closed, shut down or upgraded the socket; stop parsing - * so we do not dispatch pipelined requests on a dead socket. The caller's - * bytes start behind the body, and a body that has not ended leaves none. */ - return HttpParserResult::success(fin ? consumedTotal + (length - (unsigned int) dataToConsume.length()) : HttpParserResult::WHOLE_READ, returnedUser); - } - } - if (*chunkedExtensionsByteCount > MAX_CHUNK_EXTENSION_SIZE) [[unlikely]] { - return HttpParserResult::error(HTTP_ERROR_413_PAYLOAD_TOO_LARGE, HTTP_PARSER_ERROR_CHUNK_EXTENSIONS_OVERFLOW); - } - if (isParsingInvalidChunkedEncoding(remainingStreamingBytes)) [[unlikely]] { - // TODO: what happen if we already responded? - if (isTrailerOverflow(remainingStreamingBytes)) { - return HttpParserResult::error(HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_TRAILER_FIELDS_TOO_LARGE); - } - if (isChunkTerminatorError(remainingStreamingBytes)) { - return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_CHUNK_TERMINATOR_EXPECTED); - } - return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_CHUNKED_ENCODING); - } - unsigned int consumed = (length - (unsigned int) dataToConsume.length()); - data = (char *) dataToConsume.data(); - length = (unsigned int) dataToConsume.length(); - consumedTotal += consumed; + } else if (transferEncoding.has || contentLengthStringLen) { + if (transferEncoding.has) { + /* We already validated that chunked is last if present, before calling the handler */ + remainingStreamingBytes = STATE_IS_CHUNKED; + *chunkedExtensionsByteCount = 0; } - } else if (contentLengthStringLen) { - if constexpr (!ConsumeMinimally) { - unsigned int emittable = (unsigned int) std::min(remainingStreamingBytes, length); - bool fin = emittable == remainingStreamingBytes; - /* Account for the chunk before the handler runs, like the chunked - * branch does. An upgrade from the handler destroys the - * HttpResponseData this parser lives in, so nothing of it may be - * touched after the call. */ - remainingStreamingBytes -= emittable; - consumedTotal += emittable; - void *returnedUser = dataHandler(user, std::string_view(data, emittable), fin); - if (returnedUser != user) { - return HttpParserResult::success(consumedTotal, returnedUser); + /* 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) { + const unsigned int before = length; + if (auto ended = consumeBody(data, length, consumedTotal, maxBufferedHeaderSize, maxHeadersCount, useInsecureHTTPParser, useLenientTransferEncoding, trailerSection, chunkedExtensionsByteCount, user, dataHandler)) { + return *ended; } - - data += emittable; - length -= emittable; + consumedTotal += before - length; } } else { /* If we came here without a body; emit an empty data chunk to signal no data */ @@ -1629,8 +1615,10 @@ struct HttpResponseData; * head, or WHOLE_READ when the request declared a body: the body is not parsed * and is not the caller's. The handler may have destroyed this parser. */ template - HttpParserResult consumePostPadded(uint64_t maxHeaderSize, uint32_t maxHeadersCount, bool& isConnectRequest, bool requireHostHeader, bool useStrictMethodValidation, bool useInsecureHTTPParser, bool useLenientTransferEncoding, std::string *nodeHttpRequestTrailers, uint64_t *chunkedExtensionsByteCount, char *data, unsigned int length, void *user, MoveOnlyFunction &&requestHandler, MoveOnlyFunction &&dataHandler) { + HttpParserResult consumePostPadded(uint64_t maxHeaderSize, uint32_t maxHeadersCount, bool& isConnectRequest, bool useInsecureHTTPParser, bool useLenientTransferEncoding, std::string *nodeHttpRequestTrailers, uint64_t *chunkedExtensionsByteCount, char *data, unsigned int length, void *user, MoveOnlyFunction &&requestHandler, MoveOnlyFunction &&dataHandler) { char *const readStart = data; + /* Bun.serve drops the trailer section once it's validated. No head is buffered during a body, so fallback is free to capture it. */ + std::string *trailerSection = IsNodeHttp ? nodeHttpRequestTrailers : &fallback; /* The fallback buffer may not exceed the configured per-request header * limit (per-server maxHeaderSize can raise it above the default). */ const size_t maxFallbackSize = maxHeaderSize ? (size_t) maxRawHeaderSize(maxHeaderSize) : MAX_FALLBACK_SIZE; @@ -1644,65 +1632,10 @@ struct HttpResponseData; if (isConnectRequest) { dataHandler(user, std::string_view(data, length), false); return HttpParserResult::success(0, user); - } 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)) { - if (*chunkedExtensionsByteCount > MAX_CHUNK_EXTENSION_SIZE) [[unlikely]] { - return HttpParserResult::error(HTTP_ERROR_413_PAYLOAD_TOO_LARGE, HTTP_PARSER_ERROR_CHUNK_EXTENSIONS_OVERFLOW); - } - /* The fin dispatch completes the message: a malformed or - * framing-field trailer must fail it first (node: HPE_*). */ - if (IsNodeHttp && chunk.length() == 0) { - if (HttpParserError trailerError = validateNodeTrailerSection(nodeHttpRequestTrailers, useInsecureHTTPParser, useLenientTransferEncoding, maxHeadersCount)) [[unlikely]] { - return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, trailerError); - } - } - const bool fin = chunk.length() == 0; - void *returnedUser = dataHandler(user, chunk, fin); - if (returnedUser != user) { - return HttpParserResult::success(fin ? (unsigned int) (dataToConsume.data() - readStart) : HttpParserResult::WHOLE_READ, returnedUser); - } - } - if (*chunkedExtensionsByteCount > MAX_CHUNK_EXTENSION_SIZE) [[unlikely]] { - return HttpParserResult::error(HTTP_ERROR_413_PAYLOAD_TOO_LARGE, HTTP_PARSER_ERROR_CHUNK_EXTENSIONS_OVERFLOW); - } - if (isParsingInvalidChunkedEncoding(remainingStreamingBytes)) { - if (isTrailerOverflow(remainingStreamingBytes)) { - return HttpParserResult::error(HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_TRAILER_FIELDS_TOO_LARGE); - } - if (isChunkTerminatorError(remainingStreamingBytes)) { - return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_CHUNK_TERMINATOR_EXPECTED); - } - return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_CHUNKED_ENCODING); - } - data = (char *) dataToConsume.data(); - length = (unsigned int) dataToConsume.length(); - } else { - - // this is exactly the same as below! - // todo: refactor this - /* The parser state is updated before the handler runs: an upgrade - * from the handler destroys the HttpResponseData this parser lives - * in, so nothing of it may be touched after the call. */ - if (remainingStreamingBytes >= length) { - bool fin = remainingStreamingBytes == length; - remainingStreamingBytes -= length; - void *returnedUser = dataHandler(user, std::string_view(data, length), fin); - return HttpParserResult::success(returnedUser != user ? HttpParserResult::WHOLE_READ : 0, returnedUser); - } else { - unsigned int emittable = (unsigned int) remainingStreamingBytes; - remainingStreamingBytes = 0; - void *returnedUser = dataHandler(user, std::string_view(data, emittable), true); - if (returnedUser != user) { - return HttpParserResult::success((unsigned int) (data - readStart) + emittable, returnedUser); - } - - data += emittable; - length -= emittable; - } } - + if (auto ended = consumeBody(data, length, 0, maxFallbackSize, maxHeadersCount, useInsecureHTTPParser, useLenientTransferEncoding, trailerSection, chunkedExtensionsByteCount, user, dataHandler)) { + return *ended; + } } else if (fallback.length()) { unsigned int had = (unsigned int) fallback.length(); @@ -1723,7 +1656,7 @@ struct HttpResponseData; } // break here on break - HttpParserResult consumed = fenceAndConsumePostPadded(maxHeaderSize, maxHeadersCount, isConnectRequest, requireHostHeader, useStrictMethodValidation, useInsecureHTTPParser, useLenientTransferEncoding, nodeHttpRequestTrailers, chunkedExtensionsByteCount, reassembled.data(), (unsigned int) reassembled.length(), user, &req, requestHandler, dataHandler); + HttpParserResult consumed = fenceAndConsumePostPadded(maxHeaderSize, maxHeadersCount, isConnectRequest, useInsecureHTTPParser, useLenientTransferEncoding, nodeHttpRequestTrailers, chunkedExtensionsByteCount, reassembled.data(), (unsigned int) reassembled.length(), user, &req, requestHandler, dataHandler); /* Return data will be different than user if we are upgraded to WebSocket or have an error. * The parser can be gone by then: no member is touched before this return. */ if (consumed.returnedData != user) { @@ -1760,58 +1693,9 @@ struct HttpResponseData; dataHandler(user, std::string_view(data, length), false); } return HttpParserResult::success(0, user); - } 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)) { - if (*chunkedExtensionsByteCount > MAX_CHUNK_EXTENSION_SIZE) [[unlikely]] { - return HttpParserResult::error(HTTP_ERROR_413_PAYLOAD_TOO_LARGE, HTTP_PARSER_ERROR_CHUNK_EXTENSIONS_OVERFLOW); - } - /* The fin dispatch completes the message: a malformed or - * framing-field trailer must fail it first (node: HPE_*). */ - if (IsNodeHttp && chunk.length() == 0) { - if (HttpParserError trailerError = validateNodeTrailerSection(nodeHttpRequestTrailers, useInsecureHTTPParser, useLenientTransferEncoding, maxHeadersCount)) [[unlikely]] { - return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, trailerError); - } - } - const bool fin = chunk.length() == 0; - void *returnedUser = dataHandler(user, chunk, fin); - if (returnedUser != user) { - return HttpParserResult::success(fin ? (unsigned int) (dataToConsume.data() - readStart) : HttpParserResult::WHOLE_READ, returnedUser); - } - } - if (*chunkedExtensionsByteCount > MAX_CHUNK_EXTENSION_SIZE) [[unlikely]] { - return HttpParserResult::error(HTTP_ERROR_413_PAYLOAD_TOO_LARGE, HTTP_PARSER_ERROR_CHUNK_EXTENSIONS_OVERFLOW); - } - if (isParsingInvalidChunkedEncoding(remainingStreamingBytes)) { - if (isTrailerOverflow(remainingStreamingBytes)) { - return HttpParserResult::error(HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_TRAILER_FIELDS_TOO_LARGE); - } - if (isChunkTerminatorError(remainingStreamingBytes)) { - return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_CHUNK_TERMINATOR_EXPECTED); - } - return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_CHUNKED_ENCODING); - } - data = (char *) dataToConsume.data(); - length = (unsigned int) dataToConsume.length(); - } else { - // this is exactly the same as above! - if (remainingStreamingBytes >= (unsigned int) length) { - bool fin = remainingStreamingBytes == (unsigned int) length; - remainingStreamingBytes -= length; - void *returnedUser = dataHandler(user, std::string_view(data, length), fin); - return HttpParserResult::success(returnedUser != user ? HttpParserResult::WHOLE_READ : 0, returnedUser); - } else { - unsigned int emittable = (unsigned int) remainingStreamingBytes; - remainingStreamingBytes = 0; - void *returnedUser = dataHandler(user, std::string_view(data, emittable), true); - if (returnedUser != user) { - return HttpParserResult::success((unsigned int) (data - readStart) + emittable, returnedUser); - } - - data += emittable; - length -= emittable; - } + } + if (auto ended = consumeBody(data, length, (unsigned int) (data - readStart), maxFallbackSize, maxHeadersCount, useInsecureHTTPParser, useLenientTransferEncoding, trailerSection, chunkedExtensionsByteCount, user, dataHandler)) { + return *ended; } } @@ -1825,7 +1709,7 @@ struct HttpResponseData; } } - HttpParserResult consumed = fenceAndConsumePostPadded(maxHeaderSize, maxHeadersCount, isConnectRequest, requireHostHeader, useStrictMethodValidation, useInsecureHTTPParser, useLenientTransferEncoding, nodeHttpRequestTrailers, chunkedExtensionsByteCount, data, length, user, &req, requestHandler, dataHandler); + HttpParserResult consumed = fenceAndConsumePostPadded(maxHeaderSize, maxHeadersCount, isConnectRequest, useInsecureHTTPParser, useLenientTransferEncoding, nodeHttpRequestTrailers, chunkedExtensionsByteCount, data, length, user, &req, requestHandler, dataHandler); /* Return data will be different than user if we are upgraded to WebSocket or have an error */ if (consumed.returnedData != user) { /* A body or a fallback head ahead of this request moved data forward. */ diff --git a/packages/bun-uws/src/HttpResponse.h b/packages/bun-uws/src/HttpResponse.h index 2364dc821b60..a8af60182252 100644 --- a/packages/bun-uws/src/HttpResponse.h +++ b/packages/bun-uws/src/HttpResponse.h @@ -61,8 +61,13 @@ struct HttpResponse : public AsyncSocket { void resetTimeout() { auto* data = getHttpResponseData(); + data->idleTimer.arm((us_socket_t *) this, data->idleTimeout); + } - Super::timeout(data->idleTimeout); + /* Calls onWritable once the socket takes more, for a body that bypasses write() (sendfile). The wait counts as a blocked write. */ + void requestWritable() { + us_socket_request_writable((us_socket_t *) this); + resetTimeout(); } /* The chunk-size line of a chunk. Returns its length. */ static constexpr size_t CHUNK_HEAD_MAX = 10; @@ -74,40 +79,162 @@ struct HttpResponse : public AsyncSocket { return (size_t) length; } - /* Write an unsigned 64-bit integer */ - void writeUnsigned64(uint64_t value) { - char buf[utils::U64_MAX_DIGITS]; - int length = utils::u64toa(value, buf); - - /* For now we do this copy */ - Super::write(buf, length); - } - /* Called only once per request */ void writeMark() { if (getHttpResponseData()->state & HttpResponseData::HTTP_WROTE_DATE_HEADER) { return; } /* Date is always written */ - writeHeader("Date", std::string_view(((LoopData *) us_loop_ext(us_socket_group_loop(us_socket_group((us_socket_t *) this))))->date, 29)); + writeHeaderLine("Date", std::string_view(((LoopData *) us_loop_ext(us_socket_group_loop(us_socket_group((us_socket_t *) this))))->date, 29)); getHttpResponseData()->state |= HttpResponseData::HTTP_WROTE_DATE_HEADER; } - /* 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. */ - bool closeIfDoneAndMarked(HttpResponseData *httpResponseData) { - if (httpResponseData->shouldCloseConnection()) { - if ((httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) == 0) { - if (((AsyncSocket *) this)->hasFullyDrained()) { - ((AsyncSocket *) this)->shutdown(); - /* We need to force close after sending FIN since we want to hinder - * clients from keeping to send their huge data */ - ((AsyncSocket *) this)->close(); - return true; - } + void writeHeaderLine(std::string_view key, std::string_view value) { + if (Super::writeAllToCorkBuffer(key, std::string_view(": ", 2), value, std::string_view("\r\n", 2))) [[likely]] { + return; + } + Super::write(key.data(), key.length()); + Super::write(": ", 2); + Super::write(value.data(), value.length()); + Super::write("\r\n", 2); + } + + /* For terminateHeaders(): the body streams, so its length isn't known when the header section ends. */ + static constexpr uint64_t UNKNOWN_LENGTH = UINT64_MAX; + /* For terminateHeaders(): uWS sends no body bytes, and the caller wrote the framing headers it wants (HEAD, 304). */ + static constexpr uint64_t NO_BODY = UINT64_MAX - 1; + + /* Decides how the body is delimited ahead of terminateHeaders(). node:http does, where its JavaScript follows Node's rules. */ + void setFraming(uint32_t mode) { + HttpResponseData *httpResponseData = getHttpResponseData(); + httpResponseData->state = (httpResponseData->state & ~HttpResponseData::HTTP_BODY_MASK) | mode | HttpResponseData::HTTP_FRAMING_DECIDED; + } + + bool isChunked() { + return (getHttpResponseData()->state & HttpResponseData::HTTP_BODY_MASK) == HttpResponseData::HTTP_BODY_CHUNKED; + } + + /* Ends the header section of every HTTP/1 response but one that node:http writes whole (tryWriteHeadAtOnce() in NodeHTTP.cpp). Decides the framing once, and writes the headers uWS owns unless the caller wrote them. */ + void terminateHeaders(uint64_t bodyLength) { + using Data = HttpResponseData; + writeStatus(HTTP_200_OK); + + Data *httpResponseData = getHttpResponseData(); + bool writeContentLength = false, writeTransferEncoding = false; + if (!(httpResponseData->state & Data::HTTP_FRAMING_DECIDED)) { + const uint32_t state = httpResponseData->state; + const bool ancient = state & Data::HTTP_ANCIENT_REQUEST; + /* Chunks carry a body of unknown length, one the caller declared chunked, and one with trailers behind it. HTTP/1.0 has none. */ + const bool wantsChunks = bodyLength == UNKNOWN_LENGTH || (state & Data::HTTP_WROTE_TRANSFER_ENCODING_HEADER) + || (!ancient && (state & Data::HTTP_NODE_HAS_RESPONSE_TRAILERS)); + if (bodyLength == NO_BODY) { + setFraming(Data::HTTP_BODY_NONE); + } else if (state & Data::HTTP_WROTE_CONTENT_LENGTH_HEADER) { + setFraming(Data::HTTP_BODY_LENGTH); + } else if (!wantsChunks) { + writeContentLength = true; + setFraming(Data::HTTP_BODY_LENGTH); + } else if (ancient) { + setFraming(Data::HTTP_BODY_UNTIL_CLOSE); + } else { + writeTransferEncoding = !(state & Data::HTTP_WROTE_TRANSFER_ENCODING_HEADER); + setFraming(Data::HTTP_BODY_CHUNKED); + } + } + if ((httpResponseData->state & Data::HTTP_BODY_MASK) == Data::HTTP_BODY_UNTIL_CLOSE) { + httpResponseData->state |= Data::HTTP_CONNECTION_CLOSE; + } + + writeMark(); + if (writeTransferEncoding) { + writeHeaderLine("Transfer-Encoding", "chunked"); + } + /* 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. */ + if (httpResponseData->closesAfterResponse() && !(httpResponseData->state & Data::HTTP_WROTE_CONNECTION_HEADER)) [[unlikely]] { + writeHeaderLine("Connection", "close"); + /* Nothing may follow a response that says "close", so a graceful stop's mark becomes a hard close. */ + httpResponseData->state |= Data::HTTP_WROTE_CONNECTION_HEADER | Data::HTTP_CONNECTION_CLOSE; + } + if (writeContentLength) { + char digits[utils::U64_MAX_DIGITS]; + std::string_view size(digits, (size_t) utils::u64toa(bodyLength, digits)); + if (!Super::writeAllToCorkBuffer(std::string_view("Content-Length: ", 16), size, std::string_view("\r\n\r\n", 4))) { + Super::write("Content-Length: ", 16); + Super::write(size.data(), size.length()); + Super::write("\r\n\r\n", 4); + } + return; + } + Super::write("\r\n", 2); + } + + /* write() and flushHeaders() end the header section and set the first bit, the end() family sets the second. */ + bool isHeaderSectionOpen() { + return !(getHttpResponseData()->state & (HttpResponseData::HTTP_WRITE_CALLED | HttpResponseData::HTTP_END_CALLED)); + } + + /* 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; + } + } + + /* The connection's last byte is out. Whatever the peer still sends must not reset the connection under it, so Bun.serve lingers. node:http closes, like Node's destroySoon(). */ + void closeAfterLastByte() { + if (HttpContext::fromSocket((us_socket_t *) this)->isNodeHttp()) { + Super::shutdown(); + Super::close(); + } else { + Super::uncork(); + /* How long the peer takes to answer the FIN holds up neither stop(), nor the process, nor a handler that still waits for the request body. */ + reportGone(); + abortRequest(); + if (us_socket_is_closed((us_socket_t *) this)) { + return; + } + /* A TLS socket still delivers what the peer sends ahead of its close_notify. In this state onData drops it. */ + HttpResponseData *httpResponseData = getHttpResponseData(); + httpResponseData->state = (httpResponseData->state | HttpResponseData::HTTP_CONNECTION_CLOSE) & ~HttpResponseData::HTTP_RESPONSE_PENDING; + us_socket_close_lingering((us_socket_t *) this); + } + } + + /* Tells the handler of the request in flight, once, that it lost the connection. This is the last callback it gets. Runs JavaScript, which can close the socket. */ + void abortRequest() { + HttpResponseData *httpResponseData = getHttpResponseData(); + httpResponseData->inStream = nullptr; + httpResponseData->onWritable = nullptr; + httpResponseData->onTimeout = nullptr; + auto onAborted = std::exchange(httpResponseData->onAborted, nullptr); + if (onAborted != nullptr && httpResponseData->userData != nullptr) { + onAborted(this, httpResponseData->userData); + } + } + + /* Tells the context's filters, once, that this connection no longer counts: it closed, left for another owner, or only lingers. */ + void reportGone() { + AsyncSocketData *data = Super::getAsyncSocketData(); + auto &filterHandlers = HttpContext::getSocketContextDataS((us_socket_t *) this)->filterHandlers; + if (std::exchange(data->filteredOpen, false)) { + for (auto &f : filterHandlers) { + f(this, -1); } } + if (std::exchange(data->filteredAccept, false)) { + for (auto &f : filterHandlers) { + f(this, data->filteredParked ? -4 : -2); + } + } + } + + /* Once the connection is marked to close, the response is complete and every byte is out. Returns true if it closed: the socket isn't the caller's any more. */ + bool closeIfDoneAndMarked(HttpResponseData *httpResponseData) { + if (httpResponseData->shouldCloseConnection() && !(httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) && Super::hasFullyDrained()) { + closeAfterLastByte(); + return true; + } return false; } @@ -144,8 +271,8 @@ struct HttpResponse : public AsyncSocket { return false; } - /* node:http socket.destroy() during this socket's parse: onData closes it after the current message. False when the caller must close it now. */ - bool closeAfterMessageIfParsing() { + /* node:http socket.destroy() during this socket's parse: onData closes it after the current message. False when the caller must close it now. dropCorked: what waits in the cork buffer isn't sent. */ + bool closeAfterMessageIfParsing(bool dropCorked = false) { if (!HttpContext::getSocketContextDataS((us_socket_t *) this)->isParsing((us_socket_t *) this)) { return false; } @@ -154,6 +281,38 @@ struct HttpResponse : public AsyncSocket { return false; } httpResponseData->state |= HttpResponseData::HTTP_NODE_CLOSE_AFTER_MESSAGE; + if (dropCorked) { + Super::uncorkWithoutSending(); + } + return true; + } + + /* node:http: Node's parser.finish() at the peer's FIN. Returns whether the stream ended inside a message, which is a parse error. The socket can be closed when this returns. */ + bool finishParsing() { + HttpResponseData *data = getHttpResponseData(); + if (data->state & HttpResponseData::HTTP_NODE_PARSING_STOPPED) { + return false; + } + /* The FIN completes a request body with no framing (HttpParser::nodeHttpBodyUntilEof). */ + if (data->nodeHttpBodyUntilEof) { + data->nodeHttpBodyUntilEof = false; + auto *nodeHttpResponseData = (HttpResponseData *) data; + nodeHttpResponseData->lastMessageStartMs = 0; + nodeHttpResponseData->headersCompleted = false; + nodeHttpResponseData->requestTimeoutReported = false; + if (data->inStream) { + us_socket_timeout((us_socket_t *) this, 0); + data->inStream(this, nullptr, 0, true, data->userData); + if (us_socket_is_closed((us_socket_t *) this)) { + return false; + } + data->inStream = nullptr; + } + } + if (!data->hasBufferedPartialRequestHeaders() && !data->hasIncompleteRequestBody()) { + return false; + } + data->state |= HttpResponseData::HTTP_NODE_PARSING_STOPPED; return true; } @@ -171,6 +330,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 +347,15 @@ 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: the connection of a parked request (NodeHTTPResponse::update_parked) doesn't read and has nothing left to send. Like a libuv handle in that state, it doesn't hold the event loop. The filter hears -3, and +3 when that ends. */ + void setParked(bool parked) { HttpResponseData *httpResponseData = getHttpResponseData(); - if (!httpResponseData->filteredAccept || httpResponseData->filteredIdleTunnel == idle) { + if (!httpResponseData->filteredAccept || httpResponseData->filteredParked == parked) { return; } - httpResponseData->filteredIdleTunnel = idle; + httpResponseData->filteredParked = parked; for (auto &f : HttpContext::getSocketContextDataS((us_socket_t *) this)->filterHandlers) { - f(this, idle ? -3 : 3); + f(this, parked ? -3 : 3); } } @@ -205,10 +367,7 @@ struct HttpResponse : public AsyncSocket { /* Returns true on success, indicating that it might be feasible to write more data. * Will start timeout if stream reaches totalSize or write failure. */ - bool internalEnd(std::string_view data, uint64_t totalSize, bool optional, bool allowContentLength = true, bool closeConnection = false) { - /* Write status if not already done */ - writeStatus(HTTP_200_OK); - + bool internalEnd(std::string_view data, uint64_t totalSize, bool optional, bool closeConnection) { /* If no total size given then assume this chunk is everything */ if (!totalSize) { totalSize = data.length(); @@ -216,52 +375,18 @@ struct HttpResponse : public AsyncSocket { HttpResponseData *httpResponseData = getHttpResponseData(); - /* A no-body status carries no Content-Length (RFC 9110 8.6) and no body - * bytes (RFC 9112 6.3 terminates it at the blank line). end() already - * short-circuits on HTTP_NO_BODY_STATUS; tryEnd() reaches here directly. */ - if (httpResponseData->state & HttpResponseData::HTTP_NO_BODY_STATUS) { - allowContentLength = false; - data = {}; - totalSize = 0; - } - /* 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"); - } + markCloseConnection(closeConnection); - httpResponseData->state |= HttpResponseData::HTTP_CONNECTION_CLOSE; - } + if (isHeaderSectionOpen()) { + terminateHeaders(totalSize); } - /* if write was called and there was previously no Content-Length header set. - * node:http compat: pending response trailers (addTrailers) also force chunked - * framing, since trailer fields can only be sent on a chunked body. */ - if ((httpResponseData->state & (HttpResponseData::HTTP_WRITE_CALLED | HttpResponseData::HTTP_NODE_HAS_RESPONSE_TRAILERS)) - && !(httpResponseData->state & (HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER | HttpResponseData::HTTP_ANCIENT_REQUEST | HttpResponseData::HTTP_CLOSE_DELIMITED | HttpResponseData::HTTP_NO_BODY_STATUS))) { + if (isChunked()) { + httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; /* We do not have tryWrite-like functionalities, so ignore optional in this path */ - /* 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); - httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; - } - /* Write the chunked data if there is any (this will not send zero chunks) */ const bool terminated = !data.empty() && !(httpResponseData->state & HttpResponseData::HTTP_NODE_HAS_RESPONSE_TRAILERS); this->write(data, nullptr, terminated); @@ -303,37 +428,13 @@ struct HttpResponse : public AsyncSocket { this->resetTimeout(); return true; } else { - /* Write content-length on first call */ - if (!(httpResponseData->state & (HttpResponseData::HTTP_END_CALLED))) { - /* Once write() has sent raw body bytes (close-delimited or - * HTTP/1.0 streaming), the header section is already - * terminated: writing a Content-Length here would inject - * 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(); - - /* WebSocket upgrades does not allow content-length */ - if (allowContentLength) { - /* Even zero is a valid content-length */ - char digits[utils::U64_MAX_DIGITS]; - std::string_view size(digits, (size_t) utils::u64toa(totalSize, digits)); - if (!Super::writeAllToCorkBuffer(std::string_view("Content-Length: ", 16), size, std::string_view("\r\n\r\n", 4))) { - Super::write("Content-Length: ", 16); - Super::write(size.data(), size.length()); - Super::write("\r\n\r\n", 4); - } - httpResponseData->state |= HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER; - } else { - Super::write("\r\n", 2); - } - } - - /* Mark end called */ - httpResponseData->state |= HttpResponseData::HTTP_END_CALLED; + if ((httpResponseData->state & HttpResponseData::HTTP_BODY_MASK) == HttpResponseData::HTTP_BODY_NONE) { + data = {}; + totalSize = 0; } + httpResponseData->state |= HttpResponseData::HTTP_END_CALLED; + /* tryEnd() stops at a short write and end() buffers the rest. Skip the call when there's no data, * because write(src, 0) would retry flushing the backpressure buffer. */ auto [written, failed] = data.empty() ? std::pair{0, false} : Super::write(data.data(), data.length(), optional); @@ -445,27 +546,59 @@ struct HttpResponse : public AsyncSocket { } } - writeMark(); - Super::write("\r\n", 2); + terminateHeaders(NO_BODY); return adoptWebSocket(std::forward(userData), perMessageDeflate, compressOptions, webSocketContext); } - /* Like upgrade(), but the caller already negotiated and built the 101 response head, which is written verbatim. - * deflateParams is nullptr if permessage-deflate wasn't negotiated. */ - template - us_socket_t *upgradeRaw(UserData&& userData, std::string_view handshake, const PerMessageDeflateParams *deflateParams, - WebSocketContext *webSocketContext) { + /* What an HTTP connection leaves to the next owner of its socket. */ + struct Leftovers { + /* Bytes that were accepted for sending and that the kernel hasn't taken. */ + BackPressure backpressure; + /* Bytes that were received behind the last request and not parsed. */ + WTF::Vector parkedBytes; + size_t parkedBytesStart; + uint64_t bytesRead; + /* us_socket_adopt() keeps the pause, and every reason for it belonged to the HTTP connection. */ + bool readsPaused; + }; + + /* Ends the socket's time as an HTTP connection and destructs its HttpResponseData. The caller gives the ext its next owner at once. */ + Leftovers leaveHttp() { + /* Not internalEnd(): Connection: close, HTTP/1.0 and close-when-idle describe the HTTP connection, not the socket's next owner. */ + auto* responseData = getHttpResponseData(); - if (us_socket_is_closed((us_socket_t *) this) || us_socket_is_shut_down((us_socket_t *) this)) { - return nullptr; - } + /* Take the parked bytes before markDone() requests a replay. */ + WTF::Vector parkedBytes = std::exchange(responseData->parkedRequestBytes, {}); + size_t parkedBytesStart = std::exchange(responseData->parkedRequestBytesStart, 0); + const bool readsPaused = responseData->readPauseReasons != 0; - Super::cork(); - Super::write(handshake.data(), (int) handshake.length()); + responseData->state |= HttpResponseData::HTTP_END_CALLED; + responseData->markDone(this); + + Leftovers left = { + .backpressure = std::move(((AsyncSocketData *) responseData)->buffer), + .parkedBytes = std::move(parkedBytes), + .parkedBytesStart = parkedBytesStart, + .bytesRead = 0, + .readsPaused = readsPaused, + }; + + HttpContext *httpContext = HttpContext::fromSocket((struct us_socket_t *) this); + + /* The socket is leaving this context, so its onClose never runs. */ + reportGone(); - return adoptWebSocket(std::forward(userData), deflateParams != nullptr, - deflateParams ? compressOptionsFor(*deflateParams) : CompressOptions::DISABLED, webSocketContext); + /* Destroy HttpResponseData (the IsNodeHttp=true type on node:http + * compat contexts; upgrade() is not on a templated handler path, so it + * selects at runtime like socketExtSize()). */ + if (httpContext->isNodeHttp()) { + left.bytesRead = ((HttpResponseData *) responseData)->bytesRead; + ((HttpResponseData *) responseData)->~HttpResponseData(); + } else { + responseData->~HttpResponseData(); + } + return left; } private: @@ -474,55 +607,16 @@ struct HttpResponse : public AsyncSocket { us_socket_t *adoptWebSocket(UserData&& userData, bool perMessageDeflate, CompressOptions compressOptions, WebSocketContext *webSocketContext) { - /* If TLS rejected the 101 write, the socket is now closed or shut down. Leave it as an HTTP socket; - * the next writable event closes it. */ + /* If TLS rejected the 101 write, the socket is closed or shut down by now. It stays an HTTP socket, and the next writable event closes it. */ if (us_socket_is_closed((us_socket_t *) this) || us_socket_is_shut_down((us_socket_t *) this)) { return nullptr; } WebSocketContextData *webSocketContextData = webSocketContext->getExt(); + HttpContextData *httpContextData = HttpContext::fromSocket((struct us_socket_t *) this)->getSocketContextData(); - /* Not internalEnd(), because the socket leaves HTTP right after: the - * connection close gate does not apply (Connection: close, HTTP/1.0 - * and close-when-idle describe the HTTP connection, not the WebSocket - * that takes over the socket), and the cork stays so the handshake - * batches with the first frames written from open(). */ - auto* responseData = getHttpResponseData(); - responseData->state |= HttpResponseData::HTTP_END_CALLED; - responseData->markDone(this); - - /* Grab the httpContext from res */ - HttpContext *httpContext = HttpContext::fromSocket((struct us_socket_t *) this); - - /* Move any backpressure out of HttpResponse */ - BackPressure backpressure(std::move(((AsyncSocketData *) responseData)->buffer)); - - auto* socketData = responseData->socketData; - HttpContextData *httpContextData = httpContext->getSocketContextData(); - - /* The socket is leaving this context; its onClose (and so filter -1) - * will never fire once adopted. Fire it now so a filter that counts - * live HTTP connections stays balanced across every upgrade path. - * Read filteredOpen before responseData is destructed below. */ - if (((AsyncSocketData *) responseData)->filteredOpen) { - for (auto &f : httpContextData->filterHandlers) { - f((HttpResponse *) this, -1); - } - } - if (((AsyncSocketData *) responseData)->filteredAccept) { - for (auto &f : httpContextData->filterHandlers) { - f((HttpResponse *) this, ((AsyncSocketData *) responseData)->filteredIdleTunnel ? -4 : -2); - } - } - - /* Destroy HttpResponseData (the IsNodeHttp=true type on node:http - * compat contexts; upgrade() is not on a templated handler path, so it - * selects at runtime like socketExtSize()). */ - if (httpContext->isNodeHttp()) { - ((HttpResponseData *) responseData)->~HttpResponseData(); - } else { - responseData->~HttpResponseData(); - } + /* The cork stays, so the handshake batches with the first frames written from open(). */ + Leftovers left = leaveHttp(); /* Before we adopt and potentially change socket, check which cork slot * we occupy so we can transfer it to the new WebSocket. */ @@ -550,10 +644,7 @@ struct HttpResponse : public AsyncSocket { } /* Initialize websocket with any moved backpressure intact */ - webSocket->init(perMessageDeflate, compressOptions, std::move(backpressure), socketData, httpContextData->onSocketClosed); - if (httpContextData->onSocketUpgraded) { - httpContextData->onSocketUpgraded(socketData, SSL, usSocket); - } + webSocket->init(perMessageDeflate, compressOptions, std::move(left.backpressure), nullptr, nullptr); /* We should only mark this if inside the parser; if upgrading "async" we cannot set this */ if (upgradingParsedSocket) { @@ -561,9 +652,6 @@ struct HttpResponse : public AsyncSocket { httpContextData->upgradedWebSocket = webSocket; } - /* Arm maxLifetime timeout */ - us_socket_long_timeout((us_socket_t *) webSocket, webSocketContextData->maxLifetime); - /* Arm idleTimeout */ us_socket_timeout((us_socket_t *) webSocket, webSocketContextData->idleTimeoutComponents.first); @@ -575,6 +663,24 @@ struct HttpResponse : public AsyncSocket { webSocketContextData->openHandler(webSocket); } + if (left.readsPaused) [[unlikely]] { + /* Resuming can close the socket, which is an ordinary WebSocket close by now. */ + us_socket_resume(usSocket); + } + + /* Frames from a client that didn't wait for the 101 (RFC 6455 4.1). */ + if (!left.parkedBytes.isEmpty()) [[unlikely]] { + 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 = left.parkedBytes.size() - left.parkedBytesStart; + 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, left.parkedBytes.span().data() + left.parkedBytesStart, 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(); @@ -587,20 +693,41 @@ struct HttpResponse : public AsyncSocket { /* See AsyncSocket */ using Super::getRemoteAddress; - using Super::getRemoteAddressAsText; using Super::getNativeHandle; - /* Throttle reads and writes */ - HttpResponse *pause() { - Super::pause(); - Super::timeout(0); - return this; + /* Stops reading for a READ_PAUSE_* reason. The idle timeout keeps running, because a blocked response write has nothing else to end it. */ + void pauseReads(uint8_t reason) { + HttpResponseData *httpResponseData = getHttpResponseData(); + /* The other reasons hold back the parsing of more HTTP, and none is left. */ + if (reason != HttpResponseData::READ_PAUSE_SOCKET && httpResponseData->isParserDetached()) { + return; + } + if (!httpResponseData->readPauseReasons) { + Super::pause(); + } + httpResponseData->readPauseReasons |= reason; } - HttpResponse *resume() { + /* Reads start again once the last reason is gone. */ + void resumeReads(uint8_t reason) { + HttpResponseData *httpResponseData = getHttpResponseData(); + if (!(httpResponseData->readPauseReasons & reason)) { + return; + } + httpResponseData->readPauseReasons &= ~reason; + if (httpResponseData->readPauseReasons) { + return; + } Super::resume(); this->resetTimeout(); - return this; + setParked(false); + } + + /* node:http: unsent response bytes hold the reads, however many responses wait behind them (Node's socketOnDrain). */ + void endFloodPauseIfDrained() { + if (Super::getBufferedAmount() == 0) { + resumeReads(HttpResponseData::READ_PAUSE_FLOOD); + } } /* Note: Headers are not checked in regards to timeout. @@ -620,6 +747,12 @@ struct HttpResponse : public AsyncSocket { return this; } + /* RFC 9110 8.6: a 1xx or 204 has no body and MUST NOT carry Content-Length. A 304 MAY carry one, so its caller decides. */ + static bool isStatusWithoutBody(std::string_view status) { + return status.length() >= 3 && (status.length() == 3 || status[3] == ' ') + && (status[0] == '1' || (status[0] == '2' && status[1] == '0' && status[2] == '4')); + } + /* Write the HTTP status */ HttpResponse *writeStatus(std::string_view status) { HttpResponseData *httpResponseData = getHttpResponseData(); @@ -629,18 +762,14 @@ struct HttpResponse : public AsyncSocket { return this; } - /* RFC 9110 8.6: a 1xx/204 MUST NOT carry Content-Length and has no - * body; record that at the one point every response passes through. - * 304 MAY carry one, so node:http sets the bit for it itself. */ - if (status.length() >= 3 && (status.length() == 3 || status[3] == ' ') - && (status[0] == '1' || (status[0] == '2' && status[1] == '0' && status[2] == '4'))) { - httpResponseData->state |= HttpResponseData::HTTP_NO_BODY_STATUS; + if (isStatusWithoutBody(status)) { + setFraming(HttpResponseData::HTTP_BODY_NONE); } /* Update status */ httpResponseData->state |= HttpResponseData::HTTP_STATUS_CALLED; - if (Super::writeAllToCorkBuffer(std::string_view("HTTP/1.1 ", 9), status, std::string_view("\r\n", 2))) { + if (Super::writeAllToCorkBuffer(std::string_view("HTTP/1.1 ", 9), status, std::string_view("\r\n", 2))) [[likely]] { return this; } Super::write("HTTP/1.1 ", 9); @@ -651,81 +780,106 @@ struct HttpResponse : public AsyncSocket { /* Write an HTTP header with string value */ HttpResponse *writeHeader(std::string_view key, std::string_view value) { + using Data = HttpResponseData; writeStatus(HTTP_200_OK); - if (Super::writeAllToCorkBuffer(key, std::string_view(": ", 2), value, std::string_view("\r\n", 2))) { - return this; + Data *httpResponseData = getHttpResponseData(); + switch (classifyResponseField(key, value)) { + case ResponseField::Date: + httpResponseData->state |= Data::HTTP_WROTE_DATE_HEADER; + break; + case ResponseField::Connection: + /* node:http sets the bit before its headers: its JavaScript decides when the connection closes. */ + if (!(httpResponseData->state & Data::HTTP_WROTE_CONNECTION_HEADER)) { + httpResponseData->state |= Data::HTTP_WROTE_CONNECTION_HEADER | (fieldValueHasToken(value, "close") ? Data::HTTP_CONNECTION_CLOSE : 0); + } + break; + case ResponseField::ContentLength: + case ResponseField::TransferEncoding: + /* Bun.serve frames the body it sends, whatever the handler's headers say. node:http frames by them, like Node. */ + if (!HttpContext::fromSocket((us_socket_t *) this)->isNodeHttp()) { + return this; + } + httpResponseData->state |= key.length() == 14 ? Data::HTTP_WROTE_CONTENT_LENGTH_HEADER : Data::HTTP_WROTE_TRANSFER_ENCODING_HEADER; + break; + default: + break; } - Super::write(key.data(), key.length()); - Super::write(": ", 2); - Super::write(value.data(), value.length()); - Super::write("\r\n", 2); + + writeHeaderLine(key, value); return this; } - /* Write an HTTP header with unsigned int value */ + /* Write an HTTP header with unsigned int value. As Content-Length it declares the length of the body, which then goes out as it is. */ HttpResponse *writeHeader(std::string_view key, uint64_t value) { writeStatus(HTTP_200_OK); - /* Every integer Content-Length write funnels through this overload; - * a 1xx / 204 response must not carry one (RFC 9110 8.6). */ - if ((getHttpResponseData()->state & HttpResponseData::HTTP_NO_BODY_STATUS) && key.length() == 14 && !strncasecmp(key.data(), "content-length", 14)) { - return this; + if (key.length() == 14 && asciiIEquals(key, "content-length")) { + HttpResponseData *httpResponseData = getHttpResponseData(); + /* A 1xx or 204 must not carry one (RFC 9110 8.6). */ + if ((httpResponseData->state & HttpResponseData::HTTP_BODY_MASK) == HttpResponseData::HTTP_BODY_NONE) { + return this; + } + httpResponseData->state |= HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER; } - Super::write(key.data(), key.length()); - Super::write(": ", 2); - writeUnsigned64(value); - Super::write("\r\n", 2); + char digits[utils::U64_MAX_DIGITS]; + writeHeaderLine(key, {digits, (size_t) utils::u64toa(value, digits)}); return this; } /* End the response with an optional data chunk. Always starts a timeout. */ void end(std::string_view data = {}, bool closeConnection = false) { + internalEnd(data, data.length(), false, closeConnection); + } + + /* Ends a response that sends no body bytes. `bodyLength` is NO_BODY, or what the GET for this HEAD would have passed to terminateHeaders(). */ + void endWithoutBody(uint64_t bodyLength, bool closeConnection) { HttpResponseData *httpResponseData = getHttpResponseData(); + if (closeConnection) { + httpResponseData->state |= HttpResponseData::HTTP_CONNECTION_CLOSE; + } + if (isHeaderSectionOpen()) { + terminateHeaders(bodyLength); + } + httpResponseData->state |= HttpResponseData::HTTP_END_CALLED; + httpResponseData->markDone(this); + this->resetTimeout(); + } - /* 204/304 responses carry no body framing at all: no Content-Length, - * no chunked framing and no terminating chunk, even when an explicit - * Transfer-Encoding header was written (RFC 9110 6.4.1). */ - if (httpResponseData->state & HttpResponseData::HTTP_NO_BODY_STATUS) { - internalEnd({nullptr, 0}, 0, false, false, closeConnection); - return; + /* For a handler that can read the request body: maxRequestBodySize holds from here on. Returns false, with the request answered, if its Content-Length is already over it. */ + bool acceptRequestBody() { + HttpResponseData *httpResponseData = getHttpResponseData(); + if (httpResponseData->state & HttpResponseData::HTTP_REQUEST_BODY_TOO_LARGE) [[unlikely]] { + writeStatus("413 Request Entity Too Large"); + endWithoutBody(NO_BODY, true); + return false; } + httpResponseData->requestBodyBudget = HttpContext::getSocketContextDataS((us_socket_t *) this)->maxRequestBodySize; + return true; + } - /* Close-delimited responses (the user removed the framing headers): - * raw body bytes after the headers, no Content-Length, no chunked - * framing, and the connection closes to delimit the message. The - * CONNECTION_CLOSE state is set directly so internalEnd() closes the - * socket without adding a Connection: close header the user removed. */ - if (httpResponseData->state & HttpResponseData::HTTP_CLOSE_DELIMITED) { - httpResponseData->state |= HttpResponseData::HTTP_CONNECTION_CLOSE; - internalEnd(data, data.length(), false, false, false); + /* The body outgrew maxRequestBodySize. The handler lets go in onAborted, where it finds HTTP_REQUEST_BODY_TOO_LARGE set. */ + void refuseRequestBody() { + HttpResponseData *httpResponseData = getHttpResponseData(); + httpResponseData->state |= HttpResponseData::HTTP_REQUEST_BODY_TOO_LARGE; + abortRequest(); + if (us_socket_is_closed((us_socket_t *) this)) { return; } - - /* When the user wrote an explicit Transfer-Encoding header but never started - * streaming writes (a one-shot end), the body must still be chunk-framed. - * Terminate the headers and enter chunked mode so internalEnd() takes the - * chunked path instead of writing the raw body after the headers. */ - if ((httpResponseData->state & HttpResponseData::HTTP_WROTE_TRANSFER_ENCODING_HEADER) && - !(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); - httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; + if (httpResponseData->state & HttpResponseData::HTTP_STATUS_CALLED) { + /* Never the terminating chunk: it would make a truncated body look complete. */ + us_socket_close((us_socket_t *) this, LIBUS_SOCKET_CLOSE_CODE_CONNECTION_RESET, nullptr); + return; } - - /* Do not auto-write a Content-Length header when the user already wrote - * a Content-Length or an explicit Transfer-Encoding header (a message - * must not carry both, see RFC 9112 §6.2). */ - internalEnd(data, data.length(), false, !(httpResponseData->state & (HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER | HttpResponseData::HTTP_WROTE_TRANSFER_ENCODING_HEADER)), closeConnection); + writeStatus("413 Payload Too Large"); + endWithoutBody(NO_BODY, true); } /* Try and end the response. Returns [true, true] on success. * Starts a timeout in some cases. Returns [ok, hasResponded] */ std::pair tryEnd(std::string_view data, uintmax_t totalSize = 0, bool closeConnection = false) { - bool ok = internalEnd(data, totalSize, true, true, closeConnection); + bool ok = internalEnd(data, totalSize, true, closeConnection); /* internalEnd's close gate may have closed the socket (destructing the * ext hasResponded() reads); that only happens once the response has * completed, so report responded without touching it. */ @@ -735,57 +889,23 @@ struct HttpResponse : public AsyncSocket { return {ok, hasResponded()}; } - /* Write the end of chunked encoded stream */ + /* Ends a body that was streamed with write(). */ bool sendTerminatingChunk(bool closeConnection = false) { - writeStatus(HTTP_200_OK); - HttpResponseData *httpResponseData = getHttpResponseData(); - 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); - httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; - } - - /* This will be sent always when state is HTTP_WRITE_CALLED inside internalEnd, so no need to write the terminating 0 chunk here */ - /* Super::write("\r\n0\r\n\r\n", 7); */ + markCloseConnection(closeConnection); + startBody(); + return internalEnd({nullptr, 0}, 0, false, closeConnection); + } - /* As in end(): the close is what terminates a close-delimited body. */ - if (httpResponseData->state & HttpResponseData::HTTP_CLOSE_DELIMITED) { - httpResponseData->state |= HttpResponseData::HTTP_CONNECTION_CLOSE; + /* Ends the header section ahead of a body of unknown length, if it's still open. */ + void startBody() { + if (isHeaderSectionOpen()) { + terminateHeaders(UNKNOWN_LENGTH); + getHttpResponseData()->state |= HttpResponseData::HTTP_WRITE_CALLED; } - - return internalEnd({nullptr, 0}, 0, false, false, closeConnection); } void flushHeaders(bool flushImmediately = false) { - - writeStatus(HTTP_200_OK); - - HttpResponseData *httpResponseData = getHttpResponseData(); - - /* Close-delimited responses must not re-add the Transfer-Encoding - * 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); - httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; - } - - } else if (!(httpResponseData->state & HttpResponseData::HTTP_WRITE_CALLED)) { - writeMark(); - Super::write("\r\n", 2); - httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; - } + startBody(); if (flushImmediately) { /* Uncork the socket to send data to the client immediately */ this->uncork(); @@ -839,31 +959,14 @@ struct HttpResponse : public AsyncSocket { } - HttpResponseData *httpResponseData = getHttpResponseData(); char chunkHeadBuffer[CHUNK_HEAD_MAX]; size_t chunkHeadLength = 0; std::string_view chunkTail; - /* Close-delimited responses (the user removed the framing headers) - * 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); - httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; - } - + startBody(); + if (isChunked()) { 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); - httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; } auto [written, failed] = Super::writeFramed({chunkHeadBuffer, chunkHeadLength}, data.data(), length, chunkTail); @@ -884,28 +987,15 @@ struct HttpResponse : public AsyncSocket { * isFirst=false skips framing and is used for continuation writes. * Returns the number of body bytes accepted. */ size_t tryWriteBody(std::string_view data, bool isFirst) { - HttpResponseData *httpResponseData = getHttpResponseData(); - bool chunked = !(httpResponseData->state & (HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER | HttpResponseData::HTTP_ANCIENT_REQUEST | HttpResponseData::HTTP_CLOSE_DELIMITED)); char chunkHeadBuffer[CHUNK_HEAD_MAX]; size_t chunkHeadLength = 0; if (isFirst) { - 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); - 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); - httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; - } + startBody(); + } + const bool chunked = isChunked(); + if (isFirst && chunked) { + chunkHeadLength = chunkHead(chunkHeadBuffer, (unsigned int) data.length()); } size_t consumed = Super::writeFramed({chunkHeadBuffer, chunkHeadLength}, data.data(), data.length(), chunked ? "\r\n" : "", true).first; @@ -917,24 +1007,14 @@ struct HttpResponse : public AsyncSocket { * then the trailing chunk CRLF. Used when a new write arrives while a * tryWriteBody() tail is still held externally. */ void spillBodyTail(std::string_view data) { - HttpResponseData *httpResponseData = getHttpResponseData(); - bool chunked = !(httpResponseData->state & (HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER | HttpResponseData::HTTP_ANCIENT_REQUEST | HttpResponseData::HTTP_CLOSE_DELIMITED)); - if (!data.empty()) { Super::write(data.data(), data.length()); } - if (chunked) { + if (isChunked()) { Super::write("\r\n", 2); } } - /* Get the current byte write offset for this Http response */ - uint64_t getWriteOffset() { - HttpResponseData *httpResponseData = getHttpResponseData(); - - return httpResponseData->offset; - } - /* Checking if we have fully responded and are ready for another request */ bool hasResponded() { HttpResponseData *httpResponseData = getHttpResponseData(); @@ -942,16 +1022,6 @@ struct HttpResponse : public AsyncSocket { return !(httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING); } - /* node:http: the response is complete and its request body is still being parsed out of this read. A shutdown now would drop that body; the after-parse gate closes instead. */ - bool isDeliveringBodyAfterResponse() { - HttpResponseData *httpResponseData = getHttpResponseData(); - - return hasResponded() - && !httpResponseData->isConnectRequest - && httpResponseData->inStream != nullptr - && HttpContext::getSocketContextDataS((us_socket_t *) this)->isParsing((us_socket_t *) this); - } - /* Corks the response if possible. Leaves already corked socket be. */ HttpResponse *cork(MoveOnlyFunction &&handler) { if (!Super::isCorked()) { @@ -978,18 +1048,7 @@ struct HttpResponse : public AsyncSocket { this->resetTimeout(); } - /* If we have no backbuffer and we are connection close and we responded fully then close */ - HttpResponseData *httpResponseData = getHttpResponseData(); - if (httpResponseData->shouldCloseConnection()) { - if ((httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) == 0) { - if (((AsyncSocket *) this)->hasFullyDrained()) { - ((AsyncSocket *) this)->shutdown(); - /* We need to force close after sending FIN since we want to hinder - * clients from keeping to send their huge data */ - ((AsyncSocket *) this)->close(); - } - } - } + closeIfDoneAndMarked(getHttpResponseData()); } else { /* We are already corked, or can't cork so let's just call the handler */ handler(); @@ -1083,22 +1142,6 @@ struct HttpResponse : public AsyncSocket { data->received_bytes_per_timeout = 0; } - void* getSocketData() { - HttpResponseData *httpResponseData = getHttpResponseData(); - - return httpResponseData->socketData; - } - bool isConnectRequest() { - HttpResponseData *httpResponseData = getHttpResponseData(); - return httpResponseData->isConnectRequest; - } - - void setWriteOffset(uint64_t offset) { - HttpResponseData *httpResponseData = getHttpResponseData(); - - httpResponseData->offset = offset; - } - }; } diff --git a/packages/bun-uws/src/HttpResponseData.h b/packages/bun-uws/src/HttpResponseData.h index bb1afd7e34ed..da7ea54d87cd 100644 --- a/packages/bun-uws/src/HttpResponseData.h +++ b/packages/bun-uws/src/HttpResponseData.h @@ -30,6 +30,35 @@ namespace uWS { template struct HttpContext; +/* Minimum allowed throughput, either way: a client that uploads, or takes a blocked response, at less than 16 kB per second gets dropped. */ +static constexpr uint64_t HTTP_MIN_THROUGHPUT_BYTES_PER_SECOND = 16 * 1024; + +/* The idle timeout of an HTTP/1 or HTTP/2 connection. It tells a peer that reads a blocked response slowly from one that stopped reading. */ +struct 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. */ + void arm(us_socket_t *s, unsigned int seconds) { + us_socket_timeout(s, seconds); + if (s->flags.last_write_failed) [[unlikely]] { + if (!seconds || us_socket_delivered_bytes(s, &deliveredBytes) != 0) { + deliveredBytes = UINT64_MAX; + } + } + } + + /* For on_timeout. Returns true, armed again, if the peer took enough of a blocked write since arm(). */ + bool rearmIfPeerStillReads(us_socket_t *s, unsigned int seconds) { + if (!s->flags.last_write_failed || !us_socket_delivered_since(s, &deliveredBytes, HTTP_MIN_THROUGHPUT_BYTES_PER_SECOND * seconds)) { + return false; + } + us_socket_timeout(s, seconds); + return true; + } + +private: + /* us_socket_delivered_bytes() at the last arm() with a write blocked. UINT64_MAX, which no count reaches, if there is none. */ + uint64_t deliveredBytes = UINT64_MAX; +}; + /* IsNodeHttp defaults to false; the default lives on the declaration in * HttpParser.h (the common include of this header and HttpContext.h). */ template @@ -61,8 +90,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. */ @@ -98,19 +133,18 @@ struct HttpResponseData : AsyncSocketData, HttpParser { HTTP_CONNECTION_CLOSE = 16, // used HTTP_WROTE_CONTENT_LENGTH_HEADER = 32, // used HTTP_WROTE_DATE_HEADER = 64, // used - HTTP_WROTE_TRANSFER_ENCODING_HEADER = 128, // used + /* The request body is longer than maxRequestBodySize: by its Content-Length, or by what has arrived for a handler that reads it. */ + HTTP_REQUEST_BODY_TOO_LARGE = 128, /* The request was HTTP/1.0 or older: no keep-alive and no chunked * framing, so a body without Content-Length is delimited by close. */ HTTP_ANCIENT_REQUEST = 1 << 8, - /* The response carries no body framing at all: no Content-Length, no - * chunked encoding, no terminating chunk. writeStatus() sets it for 1xx - * and 204 (RFC 9110 8.6); node:http additionally sets it for 304. */ - HTTP_NO_BODY_STATUS = 1 << 9, - /* The response body is delimited by connection close: write it raw with - * no Content-Length and no chunked framing, then close. Used by node:http - * when the user removed the framing headers. */ - HTTP_CLOSE_DELIMITED = 1 << 10, + /* How the response body is delimited (RFC 9112 6.3). Valid once HTTP_FRAMING_DECIDED is set. */ + HTTP_BODY_LENGTH = 0 << 9, // raw bytes, counted by Content-Length + HTTP_BODY_CHUNKED = 1 << 9, + HTTP_BODY_UNTIL_CLOSE = 2 << 9, // raw bytes, ended by closing the connection + HTTP_BODY_NONE = 3 << 9, // no bytes: the response ends at the blank line + HTTP_BODY_MASK = 3 << 9, /* The node:http bits below are only ever set on a node:http compat * connection, but they live in the shared word (rather than in @@ -121,24 +155,17 @@ struct HttpResponseData : AsyncSocketData, HttpParser { * this connection is still in flight (HTTP/1.1 pipelining). NodeHTTP.cpp * queues it on the server socket instead of making it the connection's * current response; the per-response state reset is applied later by - * JSNodeHTTPServerSocket::startPipelinedResponse(). Only meaningful while + * JSNodeHTTPServerSocket::startRawResponse(). Only meaningful while * the request handler dispatch is on the stack. */ HTTP_NODE_PIPELINED_DISPATCH = 1 << 11, /* The JS layer stopped HTTP processing on this connection (Node frees the * parser when 'close' is emitted on the socket); any further request data * in the buffer is not parsed. */ HTTP_NODE_PARSING_STOPPED = 1 << 12, - /* Socket reads were paused because pipelined responses are (or were) - * queued. Reads resume once the queue has drained AND the socket has no - * outgoing backpressure left (Node's flood prevention pauses the socket - * while responses back up). */ - HTTP_NODE_READS_PAUSED = 1 << 13, - /* An accepted Upgrade request with a body. The body is parsed and - * delivered through the request as usual; once it completes, the - * connection switches into CONNECT-style tunnel mode (isConnectRequest) - * and everything after the end of the message is opaque data for the - * 'upgrade' listener's socket. */ - HTTP_NODE_TUNNEL_AFTER_BODY = 1 << 14, + /* HttpResponse::terminateHeaders() decides the framing, unless the status (1xx, 204) or node:http did before it. */ + HTTP_FRAMING_DECIDED = 1 << 13, + /* An accepted Upgrade request with a body. The body is parsed and delivered through the request as usual. Behind its last chunk the connection leaves HTTP (NodeHttpSocketHooks::onUpgradeBodyEnded). */ + HTTP_NODE_LEAVE_AFTER_BODY = 1 << 14, /* The peer half-closed (FIN) while there was still something to flush * before teardown: buffered/pinned response bytes, or (with * httpAllowHalfOpen) an in-flight or queued pipelined response. The @@ -167,27 +194,49 @@ struct HttpResponseData : AsyncSocketData, HttpParser { /* node:http socket.destroySoon() with outgoing bytes still queued: close when they have flushed, whether or not the response in flight has ended. */ HTTP_NODE_CLOSE_AFTER_DRAIN = 1 << 21, /* node:http: the peer sent its FIN first (HTTP_NODE_RECEIVED_FIN only covers a - * deferred close). onSocketClosed reports it so the JS socket emits 'end'. */ + * deferred close). NodeHttpSocketHooks::onClosed reports it so the JS socket emits 'end'. */ 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, + HTTP_WROTE_TRANSFER_ENCODING_HEADER = 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 * keep-alive connection, so starting a new response clears the rest of the * word (resetResponseState) - these have to survive that. */ - /* node:http: call Bun__NodeHTTP__onReadParsed once the read being parsed is consumed. */ + /* node:http: call NodeHttpSocketHooks::onReadParsed once the read being parsed is consumed. */ HTTP_NODE_NOTIFY_READ_PARSED = 1 << 19, - 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_CONNECTION_SCOPED = HTTP_NODE_PARSING_STOPPED + | HTTP_NODE_LEAVE_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, + }; + + /* Why reads are paused. HttpResponse::pauseReads() and resumeReads() keep the socket paused while any reason is set. */ + enum : uint8_t { + /* The request body's reader is full: Bun.serve's high-water mark, node:http's req.pause(). */ + READ_PAUSE_BODY = 1, + /* Bun.serve: a read's worth of pipelined requests is parked behind the pending response. */ + READ_PAUSE_PARKED = 2, + /* node:http: responses are backed up, so no further request is read (Node's flood prevention). */ + READ_PAUSE_FLOOD = 4, + /* node:http: the JS socket stopped reading. */ + READ_PAUSE_SOCKET = 8, }; /* Begin a new response on this connection. Clearing the word in one go is - * what keeps a 204/304 (HTTP_NO_BODY_STATUS), a close-delimited body or a - * previous response's trailers from leaking into the next request on a + * what keeps a previous response's framing or + * trailers from leaking into the next request on a * keep-alive socket; only the connection-scoped bits are carried over. */ void resetResponseState() { state = (state & HTTP_CONNECTION_SCOPED) | HTTP_RESPONSE_PENDING; @@ -196,13 +245,9 @@ struct HttpResponseData : AsyncSocketData, HttpParser { this->isIdle = false; } - /* Set or clear a flag from a runtime bool. */ - void setFlag(uint32_t flag, bool value) { - if (value) { - state |= flag; - } else { - state &= ~flag; - } + /* node:http: 'connect' or 'upgrade' has the socket, and responses ahead of that request are still owed. Nothing is parsed, and the socket's handle gets every read. */ + bool isParserDetached() const { + return (state & (HTTP_NODE_PARSING_STOPPED | HTTP_NODE_RAW_READS)) == (HTTP_NODE_PARSING_STOPPED | HTTP_NODE_RAW_READS); } /* Shared context pointer for onAborted/onTimeout/onData */ @@ -223,13 +268,18 @@ struct HttpResponseData : AsyncSocketData, HttpParser { OnTimeoutCallback onTimeout = nullptr; /* Outgoing offset */ uint64_t offset = 0; + IdleTimeout idleTimer; + /* How much more request body the handler may get. Unlimited until HttpResponse::acceptRequestBody(). */ + uint64_t requestBodyBudget = UINT64_MAX; /* Let's track number of bytes since last timeout reset in data handler */ unsigned int received_bytes_per_timeout = 0; /* Current state (content-length sent, status sent, write called, etc */ uint32_t state = 0; - uint8_t idleTimeout = 10; // default HTTP_TIMEOUT 10 seconds + /* HttpContextData::idleTimeout, until HttpResponse::setTimeout() overrides it. The next request starts from the context's value again. */ + uint8_t idleTimeout = 0; + uint8_t readPauseReasons = 0; /* The parser writes this through a bool& (getHeaders / consumePostPadded), * so it cannot live in `state`. */ bool isConnectRequest = false; @@ -247,15 +297,20 @@ struct HttpResponseData : AsyncSocketData, HttpParser { /* node:http server compat: number of pipelined responses dispatched to JS * that have not yet become this connection's current response. While * non-zero, newly parsed requests keep being queued (preserving response - * order) and socket reads stay paused (bounding memory under a pipeline - * flood). */ + * order). */ 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); } @@ -289,6 +344,8 @@ struct HttpResponseData : HttpResponseData { * Mirrors last_message_start_/headers_completed_ in Node's http parser * ConnectionsList, which back server.headersTimeout/requestTimeout. */ uint64_t lastMessageStartMs = 0; + /* socket.bytesRead: bytes read from the connection, after TLS decryption. */ + uint64_t bytesRead = 0; /* Trailer fields set via response.addTrailers(), pre-rendered as * "name: value\r\n" lines. Written between the terminating 0 chunk and the * final CRLF of a chunked response (RFC 9112 7.1.2); non-empty also forces @@ -304,6 +361,8 @@ struct HttpResponseData : HttpResponseData { bool headersCompleted = false; /* Timeout sweep already reported this message; reset when it completes. */ bool requestTimeoutReported = false; + /* The parse error that set HTTP_NODE_PARSING_STOPPED, if one did. */ + HttpParserError parseError = HTTP_PARSER_ERROR_NONE; }; /* Readable name for the IsNodeHttp=true specialization (used by the node:http diff --git a/packages/bun-uws/src/HttpRouter.h b/packages/bun-uws/src/HttpRouter.h index 8a48cd6b8ea7..60dfa86e0b1e 100644 --- a/packages/bun-uws/src/HttpRouter.h +++ b/packages/bun-uws/src/HttpRouter.h @@ -18,6 +18,7 @@ #ifndef UWS_HTTPROUTER_HPP #define UWS_HTTPROUTER_HPP +#include #include #include #include @@ -35,6 +36,8 @@ namespace uWS { template struct HttpRouter { static constexpr std::string_view ANY_METHOD_TOKEN = "*"; + /* Where HTTP/2 and HTTP/3 add an any-method route: the tree of each method that can have routes of its own, so the more specific pattern wins as over HTTP/1.1, and the any-method tree for the other methods. */ + static constexpr std::array ANY_METHOD_TREES = {"get", "post", "put", "delete", "patch", "head", "options", "connect", "trace", ANY_METHOD_TOKEN}; static constexpr uint32_t HIGH_PRIORITY = 0xd0000000, MEDIUM_PRIORITY = 0xe0000000, LOW_PRIORITY = 0xf0000000; private: @@ -269,8 +272,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/Loop.h b/packages/bun-uws/src/Loop.h index 38312118debc..bd01e1e033d9 100644 --- a/packages/bun-uws/src/Loop.h +++ b/packages/bun-uws/src/Loop.h @@ -210,18 +210,8 @@ struct Loop { us_wakeup_loop((us_loop_t *) this); } - /* Actively block and run this loop */ - void run() { - us_loop_run((us_loop_t *) this); - } - }; -/* Can be called from any thread to run the thread local loop */ -inline void run() { - Loop::get()->run(); -} - } #endif // UWS_LOOP_H diff --git a/packages/bun-uws/src/QueryParser.h b/packages/bun-uws/src/QueryParser.h deleted file mode 100644 index 55e504acc5f1..000000000000 --- a/packages/bun-uws/src/QueryParser.h +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Authored by Alex Hultman, 2018-2020. - * Intellectual property of third-party. - - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - - * http://www.apache.org/licenses/LICENSE-2.0 - - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/* This module implements URI query parsing and retrieval of value given key */ - -#ifndef UWS_QUERYPARSER_H -#define UWS_QUERYPARSER_H - -#include - -namespace uWS { - - /* Takes raw query including initial '?' sign. Will inplace decode, so input will mutate */ - static inline std::string_view getDecodedQueryValue(std::string_view key, std::string_view rawQuery) { - - /* Can't have a value without a key */ - if (!key.length()) { - return {}; - } - - /* Start with the whole querystring including initial '?' */ - std::string_view queryString = rawQuery; - - /* List of key, value could be cached for repeated fetches similar to how headers are, todo! */ - while (queryString.length()) { - /* Find boundaries of this statement */ - std::string_view statement = queryString.substr(1, queryString.find('&', 1) - 1); - - /* Only bother if first char of key match (early exit) */ - if (statement.length() && statement[0] == key[0]) { - /* Equal sign must be present and not in the end of statement */ - auto equality = statement.find('='); - if (equality != std::string_view::npos) { - - std::string_view statementKey = statement.substr(0, equality); - std::string_view statementValue = statement.substr(equality + 1); - - /* String comparison */ - if (key == statementKey) { - - /* Decode value inplace, put null at end if before length of original */ - char *in = (char *) statementValue.data(); - - /* Write offset */ - unsigned int out = 0; - - /* Walk over all chars until end or null char, decoding in place */ - for (unsigned int i = 0; i < statementValue.length() && in[i]; i++) { - /* Only bother with '%' */ - if (in[i] == '%') { - /* Do we have enough data for two bytes hex? */ - if (i + 2 >= statementValue.length()) { - return {}; - } - - /* Two bytes hex */ - int hex1 = in[i + 1] - '0'; - if (hex1 > 9) { - hex1 &= 223; - hex1 -= 7; - } - - int hex2 = in[i + 2] - '0'; - if (hex2 > 9) { - hex2 &= 223; - hex2 -= 7; - } - - *((unsigned char *) &in[out]) = (unsigned char) (hex1 * 16 + hex2); - i += 2; - } else { - /* Is this even a rule? */ - if (in[i] == '+') { - in[out] = ' '; - } else { - in[out] = in[i]; - } - } - - /* We always only write one char */ - out++; - } - - /* If decoded string is shorter than original, put null char to stop next read */ - if (out < statementValue.length()) { - in[out] = 0; - } - - return statementValue.substr(0, out); - } - } else { - /* This querystring is invalid, cannot parse it */ - return {nullptr, 0}; - } - } - - queryString.remove_prefix(statement.length() + 1); - } - - /* Nothing found is given as nullptr, while empty string is given as some pointer to the given buffer */ - return {nullptr, 0}; - } - -} - -#endif diff --git a/packages/bun-uws/src/Utilities.h b/packages/bun-uws/src/Utilities.h index 39798cd21143..0e7e4d90cf90 100644 --- a/packages/bun-uws/src/Utilities.h +++ b/packages/bun-uws/src/Utilities.h @@ -22,57 +22,91 @@ /* Various common utilities */ +#include #include #include #include +extern "C" int16_t Bun__HTTPMethod__from(const char *str, size_t len); + namespace uWS { -/* RFC 9113 §8.2.2 / RFC 9114 §4.2: connection-specific fields are not - * allowed in HTTP/2 or HTTP/3 responses. */ 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. */ +inline constexpr std::array tokenBytes = [] { + std::array table{}; + for (unsigned char c : std::string_view{"!#$%&'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"}) table[c] = true; + return table; +}(); static inline bool isTokenByte(unsigned char c) { - if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) return true; - switch (c) { - case '!': case '#': case '$': case '%': case '&': case '\'': case '*': - case '+': case '-': case '.': case '^': case '_': case '`': case '|': case '~': - return true; - } - return false; + return tokenBytes[c]; } -/* 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 - * 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 (authority.empty() && host.empty()) return false; - if (!authority.empty() && !host.empty() && authority != host) return false; - if (authority.find('@') != std::string_view::npos) return false; +/* 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; } -static inline bool isConnectionSpecificResponseField(std::string_view name, std::string_view value) { +/* Returns true for the methods Bun.serve can represent, in their case-sensitive wire form (RFC 9110 §9.1). The table also has them in lowercase. */ +static inline bool isKnownMethod(std::string_view method) { + for (unsigned char c : method) if (!((c >= 'A' && c <= 'Z') || c == '-')) return false; + return Bun__HTTPMethod__from(method.data(), method.size()) != -1; +} + +/* What a request without a Content-Length has for one. */ +static constexpr uint64_t NO_CONTENT_LENGTH = UINT64_MAX; + +/* Such a request gets no automatic 100 Continue (RFC 9110 §10.1.1), and a handler that could read its body gets 413 sent for it. */ +static inline bool declaresBodyOverLimit(uint64_t contentLength, uint64_t maxRequestBodySize) { + return contentLength != NO_CONTENT_LENGTH && contentLength > maxRequestBodySize; +} + +/* RFC 9110 §8.6: 1*DIGIT. Returns UINT64_MAX for anything else, and for more than 18 digits. */ +static inline uint64_t parseContentLength(std::string_view value) { + if (value.empty() || value.length() > 18) return UINT64_MAX; + uint64_t length = 0; + for (char c : value) { + if (c < '0' || c > '9') return UINT64_MAX; + length = length * 10 + (uint64_t) (c - '0'); + } + return length; +} + +/* The response fields that the layer framing a response has to know. RFC 9113 §8.2.2 and RFC 9114 §4.2 forbid the last three kinds. */ +enum class ResponseField : uint8_t { Other, Date, ContentLength, Connection, TransferEncoding, HopByHop }; +static inline ResponseField classifyResponseField(std::string_view name, std::string_view value) { switch (name.size()) { - case 2: return asciiIEquals(name, "te") && !(value.size() == 8 && asciiIEquals(value, "trailers")); - case 7: return asciiIEquals(name, "upgrade"); - case 10: return asciiIEquals(name, "connection") || asciiIEquals(name, "keep-alive"); - case 16: return asciiIEquals(name, "proxy-connection"); - case 17: return asciiIEquals(name, "transfer-encoding"); + case 2: if (asciiIEquals(name, "te") && !(value.size() == 8 && asciiIEquals(value, "trailers"))) return ResponseField::HopByHop; break; + case 4: if (asciiIEquals(name, "date")) return ResponseField::Date; break; + case 7: if (asciiIEquals(name, "upgrade")) return ResponseField::HopByHop; break; + case 10: if (asciiIEquals(name, "connection")) return ResponseField::Connection; if (asciiIEquals(name, "keep-alive")) return ResponseField::HopByHop; break; + case 14: if (asciiIEquals(name, "content-length")) return ResponseField::ContentLength; break; + case 16: if (asciiIEquals(name, "proxy-connection")) return ResponseField::HopByHop; break; + case 17: if (asciiIEquals(name, "transfer-encoding")) return ResponseField::TransferEncoding; break; + } + return ResponseField::Other; +} + +/* RFC 9110 §5.6.1: whether `lowerToken` is an element of a comma-separated field value. */ +static inline bool fieldValueHasToken(std::string_view value, std::string_view lowerToken) { + while (true) { + size_t comma = value.find(','); + std::string_view element = value.substr(0, comma); + while (!element.empty() && (element.front() == ' ' || element.front() == '\t')) element.remove_prefix(1); + while (!element.empty() && (element.back() == ' ' || element.back() == '\t')) element.remove_suffix(1); + if (element.size() == lowerToken.size() && asciiIEquals(element, lowerToken.data())) return true; + if (comma == std::string_view::npos) return false; + value.remove_prefix(comma + 1); } - return false; } namespace utils { diff --git a/packages/bun-uws/src/WebSocket.h b/packages/bun-uws/src/WebSocket.h index 76dea712770c..aaeda1814b59 100644 --- a/packages/bun-uws/src/WebSocket.h +++ b/packages/bun-uws/src/WebSocket.h @@ -32,6 +32,7 @@ template struct WebSocket : AsyncSocket { template friend struct TemplatedApp; template friend struct HttpResponse; + template friend struct WebSocketContext; private: typedef AsyncSocket Super; @@ -60,7 +61,6 @@ struct WebSocket : AsyncSocket { /* See AsyncSocket */ using Super::getBufferedAmount; using Super::getRemoteAddress; - using Super::getRemoteAddressAsText; using Super::getNativeHandle; /* WebSocket close cannot be an alias to AsyncSocket::close since @@ -123,20 +123,6 @@ struct WebSocket : AsyncSocket { return Super::getAsyncSocketData()->buffer.totalLength() + sizeof(WebSocket); } - /* Sending fragmented messages puts a bit of effort on the user; you must not interleave regular sends - * with fragmented sends and you must sendFirstFragment, [sendFragment], then finally sendLastFragment. */ - SendStatus sendFirstFragment(std::string_view message, OpCode opCode = OpCode::BINARY, bool compress = false) { - return send(message, opCode, compress, false); - } - - SendStatus sendFragment(std::string_view message, bool compress = false) { - return send(message, CONTINUATION, compress, false); - } - - SendStatus sendLastFragment(std::string_view message, bool compress = false) { - return send(message, CONTINUATION, compress, true); - } - /* Send or buffer a WebSocket frame, compressed or not. Returns BACKPRESSURE on increased user space backpressure, * DROPPED on dropped message (due to backpressure) or SUCCCESS if you are free to send even more now. */ SendStatus send(std::string_view message, OpCode opCode = OpCode::BINARY, bool compress = false, bool fin = true) { diff --git a/packages/bun-uws/src/WebSocketContext.h b/packages/bun-uws/src/WebSocketContext.h index 6aed0c165fdf..c898542247cf 100644 --- a/packages/bun-uws/src/WebSocketContext.h +++ b/packages/bun-uws/src/WebSocketContext.h @@ -59,6 +59,27 @@ struct WebSocketContext { return &fromSocket(s)->data; } + /* Makes a WebSocket of an open socket whose ext (extSize bytes) has no owner left. Returns nullptr, with s untouched, if s is closed or shut down. */ + us_socket_t *adoptSocket(us_socket_t *s, int extSize, USERDATA &&userData, std::string_view handshake, bool perMessageDeflate, + CompressOptions compressOptions, void *socketData, WebSocketData::OnSocketClosedCallback onSocketClosed) { + us_socket_t *adopted = us_socket_adopt(s, &group, socketKind(), extSize, sizeof(WebSocketData) + sizeof(USERDATA)); + if (!adopted) { + return nullptr; + } + auto *webSocket = (WebSocket *) adopted; + auto *asyncSocket = (AsyncSocket *) adopted; + webSocket->init(perMessageDeflate, compressOptions, BackPressure(), socketData, onSocketClosed); + asyncSocket->cork(); + asyncSocket->write(handshake.data(), (int) handshake.length()); + us_socket_timeout(adopted, data.idleTimeoutComponents.first); + new (webSocket->getUserData()) USERDATA(std::move(userData)); + if (data.openHandler) { + data.openHandler(webSocket); + } + asyncSocket->uncork(); + return adopted; + } + private: /* If we have negotiated compression, mark the message as compressed. Idempotent: * consume() re-validates the same header when an extended-length header straddles @@ -314,7 +335,7 @@ struct WebSocketContext { /* For whatever reason, if we already have emitted close event, do not emit it again */ WebSocketData *webSocketData = (WebSocketData *) (us_socket_ext(s)); if (webSocketData->socketData && webSocketData->onSocketClosed) { - webSocketData->onSocketClosed(webSocketData->socketData, SSL, (us_socket_t *) s, 0, false); + webSocketData->onSocketClosed(webSocketData->socketData, s, 0, false); } if (!webSocketData->isShuttingDown) { /* Emit close event */ @@ -428,12 +449,6 @@ struct WebSocketContext { return s; } - static us_socket_t *onLongTimeout(us_socket_t *s) { - ((WebSocket *) s)->end(1000, "please reconnect"); - - return s; - } - static us_socket_t *onTimeout(us_socket_t *s) { auto *webSocketData = (WebSocketData *)(us_socket_ext(s)); auto *webSocketContextData = getExtS(s); @@ -459,7 +474,6 @@ struct WebSocketContext { /* on_writable */ &onWritable, /* on_close */ &onClose, /* on_timeout */ &onTimeout, - /* on_long_timeout */ &onLongTimeout, /* on_end */ &onEnd, /* on_connect_error */nullptr, /* on_connecting_error */ nullptr, diff --git a/packages/bun-uws/src/WebSocketContextData.h b/packages/bun-uws/src/WebSocketContextData.h index e88aabd73689..357a5a0d7b99 100644 --- a/packages/bun-uws/src/WebSocketContextData.h +++ b/packages/bun-uws/src/WebSocketContextData.h @@ -93,7 +93,6 @@ struct WebSocketContextData { bool closeOnBackpressureLimit; bool resetIdleTimeoutOnSend; bool sendPingsAutomatically; - unsigned short maxLifetime; /* Server sockets of the `ws` package. end() only sends the close frame. The close handler runs when the peer's * close frame arrives or the connection closes, and gets -WebSocketFailureKind as its code on a protocol error. */ diff --git a/packages/bun-uws/src/WebSocketData.h b/packages/bun-uws/src/WebSocketData.h index 572dfc7009fb..c0f3ccb90eb9 100644 --- a/packages/bun-uws/src/WebSocketData.h +++ b/packages/bun-uws/src/WebSocketData.h @@ -55,7 +55,7 @@ struct WebSocketData : AsyncSocketData, WebSocketState { /* We could be a subscriber */ Subscriber *subscriber = nullptr; public: - using OnSocketClosedCallback = void (*)(void* userData, int is_ssl, struct us_socket_t *rawSocket, int readError, bool peerEnded); + using OnSocketClosedCallback = void (*)(void *socketData, struct us_socket_t *s, int readError, bool peerEnded); void *socketData = nullptr; /* node http compatibility callbacks */ OnSocketClosedCallback onSocketClosed = nullptr; 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/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..954b529fb5a3 --- /dev/null +++ b/patches/lsquic/datagram-ids.patch @@ -0,0 +1,181 @@ +--- a/include/lsquic.h ++++ b/include/lsquic.h +@@ -256,6 +256,15 @@ struct lsquic_stream_if { + void (*on_mini_conn_failed)(void *stream_if_ctx, + const struct sockaddr *peer_sa, + 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 ++ * 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, 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). +@@ -2248,6 +2257,25 @@ lsquic_conn_ack_now (lsquic_conn_t *); + 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 +@@ -527,11 +527,15 @@ struct ietf_full_conn + } ifc_u; + lsquic_time_t ifc_idle_to; + lsquic_time_t ifc_ping_period; ++ /* bun: the id on_dg_write gave the DATAGRAM frame that write_datagram is ++ * generating. The two flags below are one byte each so that this ++ * field doesn't grow the struct. */ ++ uint64_t ifc_dg_id; + /* 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; +@@ -6924,6 +6928,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); +@@ -8455,6 +8473,10 @@ write_datagram (struct ietf_full_conn *conn) + if (!packet_out) + return 0; + ++ /* 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, +@@ -8465,8 +8487,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_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,6 +1198,10 @@ 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); + ++ /* 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 + ++ctl->sc_conn_pub->conn_stats->out.lost_packets; +@@ -1507,6 +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); ++ send_ctl_report_datagrams(ctl, packet_out, LSQ_DG_ACKED); + LSQ_DEBUG("acking via regular record #%"PRIu64, + packet_out->po_packno); + } +@@ -2288,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) + { +@@ -2877,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/goaway-stream-id.patch b/patches/lsquic/goaway-stream-id.patch index 04c21751fb28..c3b18fe521f0 100644 --- a/patches/lsquic/goaway-stream-id.patch +++ b/patches/lsquic/goaway-stream-id.patch @@ -1,9 +1,10 @@ --- a/include/lsquic.h +++ b/include/lsquic.h -@@ -2212,6 +2212,14 @@ uint64_t +@@ -2204,6 +2204,14 @@ lsquic_engine_sreset_stats (const lsquic_engine_t *, uint64_t *sent, + uint64_t lsquic_conn_pings_received (const lsquic_conn_t *); - /** ++/** + * The stream ID in the server's last GOAWAY frame: it did not process the + * requests at or above this ID (RFC 9114, Section 5.2). UINT64_MAX until a + * client connection receives a GOAWAY frame. @@ -11,13 +12,12 @@ +lsquic_stream_id_t +lsquic_conn_goaway_stream_id (const lsquic_conn_t *); + -+/** - * Set this connection's keep-alive PING cadence in microseconds, - * overriding the engine-wide settings. 0 disables keep-alive PINGs. - */ + /** + * Opt this client connection in (or out) of migrating to the server's + * preferred address after the handshake. Off by default; call before --- a/src/liblsquic/lsquic_full_conn_ietf.c +++ b/src/liblsquic/lsquic_full_conn_ietf.c -@@ -9627,6 +9627,8 @@ on_goaway_client (void *ctx, uint64_t st +@@ -9583,6 +9583,8 @@ on_goaway_client (void *ctx, uint64_t stream_id) conn->ifc_u.cli.ifcli_min_goaway_stream_id); return; } @@ -26,7 +26,7 @@ } else { -@@ -9649,6 +9651,22 @@ on_goaway_client (void *ctx, uint64_t st +@@ -9605,6 +9607,22 @@ on_goaway_client (void *ctx, uint64_t stream_id) } 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/headers-after-buffered-body.patch b/patches/lsquic/headers-after-buffered-body.patch new file mode 100644 index 000000000000..1016f01e48a1 --- /dev/null +++ b/patches/lsquic/headers-after-buffered-body.patch @@ -0,0 +1,48 @@ +--- a/src/liblsquic/lsquic_stream.c ++++ b/src/liblsquic/lsquic_stream.c +@@ -2741,12 +2741,23 @@ active_hq_frame_sizes (const struct lsqu + static uint64_t + stream_hq_frame_end (const struct stream_hq_frame *shf) + { ++ const struct stream_hq_frame *next; ++ uint64_t end; ++ + if (shf->shf_flags & SHF_FIXED_SIZE) + return shf->shf_off + shf->shf_frame_size; + else if (shf->shf_flags & SHF_TWO_BYTES) +- return shf->shf_off + ((1 << 14) - 1); ++ end = shf->shf_off + ((1 << 14) - 1); + else +- return shf->shf_off + ((1 << 6) - 1); ++ end = shf->shf_off + ((1 << 6) - 1); ++ ++ /* bun: a HEADERS frame queued behind buffered body bytes ends their DATA ++ * frame. ++ */ ++ next = STAILQ_NEXT(shf, shf_next); ++ if (next && next->shf_off < end) ++ end = next->shf_off; ++ return end; + } + + +@@ -3426,7 +3437,10 @@ maybe_close_varsize_hq_frame (struct lsq + } + + bits = (shf->shf_flags & SHF_TWO_BYTES) > 0; +- size = stream->sm_payload + stream->sm_n_buffered - shf->shf_off; ++ size = stream->sm_payload + stream->sm_n_buffered; ++ if (size > stream_hq_frame_end(shf)) ++ size = stream_hq_frame_end(shf); ++ size -= shf->shf_off; + if (size <= VINT_MAX_B(bits) && shf->shf_frame_ptr) + { + if (0 == stream->sm_n_buffered) +@@ -3613,6 +3627,7 @@ stream_hq_frame_extendable (const struct + unsigned len) + { + return (shf->shf_flags & (SHF_TWO_BYTES|SHF_FIXED_SIZE)) == 0 ++ && !STAILQ_NEXT(shf, shf_next) + && cur_off - shf->shf_off < (1 << 6) + && cur_off - shf->shf_off + len >= (1 << 6) + ; 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/node-quic-accessors.patch b/patches/lsquic/node-quic-accessors.patch index b81feeef1fec..dffed1570ff5 100644 --- a/patches/lsquic/node-quic-accessors.patch +++ b/patches/lsquic/node-quic-accessors.patch @@ -1,6 +1,6 @@ --- a/include/lsquic.h +++ b/include/lsquic.h -@@ -243,6 +243,51 @@ struct lsquic_stream_if { +@@ -243,6 +243,44 @@ struct lsquic_stream_if { * The user may pick it off immediately using lsquic_stream_get_hset(). */ void (*on_hset_in) (lsquic_stream_t *s, lsquic_stream_ctx_t *h); @@ -18,13 +18,6 @@ + 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. -+ */ -+ void (*on_datagram_status)(lsquic_conn_t *c, unsigned count, int acked); -+ /** + * Optional callback fired on the client the moment the server rejects + * its early data (before any of the 0-RTT streams are torn down). + * bun addition. @@ -52,7 +45,7 @@ }; struct ssl_ctx_st; -@@ -880,6 +925,12 @@ struct lsquic_engine_settings { +@@ -880,6 +918,12 @@ struct lsquic_engine_settings { */ unsigned es_ping_period; @@ -65,7 +58,7 @@ /** * Source Connection ID length. Only applicable to the IETF QUIC * versions. Valid values are 0 through 20, inclusive. -@@ -1209,6 +1260,57 @@ struct lsquic_engine_settings { +@@ -1209,6 +1253,57 @@ struct lsquic_engine_settings { * and @ref LSQUIC_DF_MAX_HEADER_SETS_CLIENT in client mode. */ unsigned es_max_header_sets; @@ -123,7 +116,7 @@ }; /* Initialize `settings' to default values */ -@@ -1630,6 +1732,13 @@ lsquic_conn_cancel_pending_streams (lsquic_conn_t *, unsigned n); +@@ -1630,6 +1725,13 @@ lsquic_conn_cancel_pending_streams (lsquic_conn_t *, unsigned n); void lsquic_conn_going_away (lsquic_conn_t *); @@ -137,7 +130,7 @@ /** * This forces connection close. on_conn_closed and on_close callbacks * will be called. Closing an established IETF QUIC connection sends a -@@ -1864,6 +1973,20 @@ lsquic_stream_is_pushed (const lsquic_stream_t *s); +@@ -1864,6 +1966,20 @@ lsquic_stream_is_pushed (const lsquic_stream_t *s); int lsquic_stream_is_rejected (const lsquic_stream_t *s); @@ -158,7 +151,7 @@ /** * Retained for API compatibility with removed server push support. Current * LSQUIC versions do not create pushed streams. -@@ -2061,6 +2184,112 @@ lsquic_logger_lopt (const char *optarg); +@@ -2061,6 +2177,98 @@ lsquic_logger_lopt (const char *optarg); */ unsigned lsquic_engine_quic_versions (const lsquic_engine_t *); @@ -190,13 +183,6 @@ +lsquic_conn_pings_received (const lsquic_conn_t *); + +/** -+ * Set this connection's keep-alive PING cadence in microseconds, -+ * overriding the engine-wide settings. 0 disables keep-alive PINGs. -+ */ -+void -+lsquic_conn_set_ping_period_us (lsquic_conn_t *, uint64_t usec); -+ -+/** + * Opt this client connection in (or out) of migrating to the server's + * preferred address after the handshake. Off by default; call before + * the handshake completes. @@ -211,13 +197,6 @@ +lsquic_conn_ack_now (lsquic_conn_t *); + +/** -+ * Update the millisecond idle timeout applied to connections created -+ * after this call (see @ref es_idle_timeout_ms). -+ */ -+void -+lsquic_engine_set_idle_timeout_ms (lsquic_engine_t *, unsigned ms); -+ -+/** + * Whether the DATAGRAM frame currently being delivered via the on_datagram + * callback arrived in a 0-RTT (early data) packet. Valid only during that + * callback. @@ -757,44 +736,20 @@ void lsquic_engine_cooldown (lsquic_engine_t *engine) { -@@ -3530,6 +3689,21 @@ lsquic_engine_count_attq (lsquic_engine_t *engine, int from_now) - } - - -+/* bun: update the millisecond idle timeout used by conns created after -+ * this call. node:quic reuses one endpoint (and thus one engine) across -+ * connects whose maxIdleTimeout options differ; the engine's private -+ * settings copy must track the current connect (the analog of the fresh -+ * per-connect SSL_CTX). The readers in this patch fall back to the -+ * seconds field when ms is zero, so clear that too so 0 means disabled. */ -+void -+lsquic_engine_set_idle_timeout_ms (lsquic_engine_t *engine, unsigned ms) -+{ -+ engine->pub.enp_settings.es_idle_timeout_ms = ms; -+ if (ms == 0) -+ engine->pub.enp_settings.es_idle_timeout = 0; -+} -+ -+ - int - lsquic_engine_add_cid (struct lsquic_engine_public *enpub, - struct lsquic_conn *conn, unsigned cce_idx) --- a/src/liblsquic/lsquic_full_conn_ietf.c +++ b/src/liblsquic/lsquic_full_conn_ietf.c -@@ -90,6 +90,12 @@ +@@ -90,6 +90,10 @@ #define INITIAL_CHAL_TIMEOUT 250000 #define HSK_PING_TIMEOUT 200000 -+/* bun: whether this conn has an explicit keep-alive cadence (per-conn -+ * override, or the engine-wide es_ping_period_us). */ -+#define IFC_EXPLICIT_PING(conn) ((conn)->ifc_ping_us_override_set \ -+ ? (conn)->ifc_ping_us_override != 0 \ -+ : (conn)->ifc_enpub->enp_settings.es_ping_period_us != 0) ++/* bun: whether this conn has an explicit keep-alive cadence. */ ++#define IFC_EXPLICIT_PING(conn) \ ++ ((conn)->ifc_enpub->enp_settings.es_ping_period_us != 0) + /* Retire original CID after this much time has elapsed: */ #define RET_CID_TIMEOUT 2000000 -@@ -153,6 +159,9 @@ enum more_flags +@@ -153,6 +157,9 @@ enum more_flags MF_WANT_DATAGRAM_WRITE = 1 << 6, MF_DOING_0RTT = 1 << 7, MF_HAVE_HCSI = 1 << 8, /* Have HTTP Control Stream Incoming */ @@ -804,7 +759,7 @@ }; -@@ -424,6 +433,8 @@ struct ietf_full_conn +@@ -424,6 +431,8 @@ struct ietf_full_conn } ifc_send; struct conn_err ifc_error; unsigned ifc_n_delayed_streams; @@ -813,7 +768,7 @@ unsigned ifc_n_cons_unretx; const struct prio_iter_if *ifc_pii; char *ifc_errmsg; -@@ -459,6 +470,8 @@ struct ietf_full_conn +@@ -459,6 +468,8 @@ struct ietf_full_conn struct { uint64_t header_table_size, qpack_blocked_streams; @@ -822,16 +777,10 @@ } ifc_peer_hq_settings; struct dcid_elem *ifc_dces[MAX_IETF_CONN_DCIDS]; TAILQ_HEAD(, dcid_elem) ifc_to_retire; -@@ -513,6 +526,19 @@ struct ietf_full_conn +@@ -513,6 +524,13 @@ struct ietf_full_conn } ifc_u; lsquic_time_t ifc_idle_to; lsquic_time_t ifc_ping_period; -+ /* bun: per-conn keep-alive cadence (lsquic_conn_set_ping_period_us). -+ * `_set` distinguishes "explicitly 0 (disabled)" from "not set" -+ * (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 DATAGRAM frame currently being delivered arrived in a 0-RTT + * packet (valid only during the on_datagram callback). */ + int ifc_datagram_early; @@ -842,7 +791,7 @@ lsquic_time_t ifc_last_tick; struct lsquic_hash *ifc_bpus; uint64_t ifc_last_max_data_off_sent; -@@ -562,6 +588,11 @@ ietf_full_conn_ci_packet_in (struct lsquic_conn *, struct lsquic_packet_in *); +@@ -562,6 +580,11 @@ ietf_full_conn_ci_packet_in (struct lsquic_conn *, struct lsquic_packet_in *); static int handshake_ok (struct lsquic_conn *); @@ -854,7 +803,7 @@ static void ignore_init (struct ietf_full_conn *); -@@ -1277,6 +1308,7 @@ ietf_full_conn_init (struct ietf_full_conn *conn, +@@ -1277,6 +1300,7 @@ ietf_full_conn_init (struct ietf_full_conn *conn, conn->ifc_peer_hq_settings.header_table_size = HQ_DF_QPACK_MAX_TABLE_CAPACITY; conn->ifc_peer_hq_settings.qpack_blocked_streams = HQ_DF_QPACK_BLOCKED_STREAMS; @@ -862,7 +811,7 @@ conn->ifc_flags = flags | IFC_FIRST_TICK; conn->ifc_max_ack_packno[PNS_INIT] = IQUIC_INVALID_PACKNO; -@@ -1377,9 +1409,12 @@ lsquic_ietf_full_conn_client_new (struct lsquic_engine_public *enpub, +@@ -1377,9 +1401,12 @@ lsquic_ietf_full_conn_client_new (struct lsquic_engine_public *enpub, if (conn->ifc_settings->es_handshake_to) lsquic_alarmset_set(&conn->ifc_alset, AL_HANDSHAKE, lsquic_time_now() + conn->ifc_settings->es_handshake_to); @@ -877,7 +826,7 @@ conn->ifc_conn.cn_pf = select_pf_by_ver(ver); conn->ifc_conn.cn_esf_c = &lsquic_enc_session_common_ietf_v1; conn->ifc_conn.cn_esf.i = esfi; -@@ -1646,12 +1681,14 @@ lsquic_ietf_full_conn_server_new (struct lsquic_engine_public *enpub, +@@ -1646,12 +1673,14 @@ lsquic_ietf_full_conn_server_new (struct lsquic_engine_public *enpub, 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); @@ -894,7 +843,7 @@ while ((packet_in = TAILQ_FIRST(&imc->imc_app_packets))) { TAILQ_REMOVE(&imc->imc_app_packets, packet_in, pi_next); -@@ -2505,7 +2542,9 @@ static int +@@ -2505,7 +2534,9 @@ static int generate_stop_sending_frame (struct ietf_full_conn *conn, struct lsquic_stream *stream) { @@ -905,7 +854,7 @@ { lsquic_stream_ss_frame_sent(stream); return 1; -@@ -2736,6 +2775,29 @@ maybe_create_delayed_streams (struct ietf_full_conn *conn) +@@ -2736,6 +2767,29 @@ maybe_create_delayed_streams (struct ietf_full_conn *conn) } @@ -935,7 +884,7 @@ static int have_bidi_streams (const struct ietf_full_conn *conn) { -@@ -2836,6 +2898,7 @@ service_streams (struct ietf_full_conn *conn) +@@ -2836,6 +2890,7 @@ service_streams (struct ietf_full_conn *conn) } else maybe_create_delayed_streams(conn); @@ -943,7 +892,7 @@ } -@@ -2981,6 +3044,46 @@ ietf_full_conn_ci_abort (struct lsquic_conn *lconn) +@@ -2981,6 +3036,46 @@ ietf_full_conn_ci_abort (struct lsquic_conn *lconn) } @@ -990,7 +939,7 @@ static void retire_dcid (struct ietf_full_conn *conn, struct dcid_elem **dce) { -@@ -3374,6 +3477,14 @@ try_to_begin_migration (struct ietf_full_conn *conn, +@@ -3374,6 +3469,14 @@ try_to_begin_migration (struct ietf_full_conn *conn, return BM_NOT_MIGRATING; } @@ -1005,7 +954,7 @@ if (conn->ifc_conn.cn_version <= LSQVER_ID27 /* Starting with ID-29, disable_active_migration TP applies only to the time period during the handshake. Our client does not migrate during the handshake: -@@ -3565,7 +3676,12 @@ apply_trans_params (struct ietf_full_conn *conn, +@@ -3565,7 +3668,12 @@ apply_trans_params (struct ietf_full_conn *conn, = params->tp_init_max_stream_data_bidi_remote; conn->ifc_cfg.ack_exp = params->tp_ack_delay_exponent; @@ -1019,7 +968,7 @@ | !!params->tp_max_idle_timeout) { case (0 << 1) | 0: -@@ -3578,26 +3694,36 @@ apply_trans_params (struct ietf_full_conn *conn, +@@ -3578,26 +3686,34 @@ apply_trans_params (struct ietf_full_conn *conn, break; case (1 << 1) | 0: LSQ_DEBUG("peer did not specify max idle timeout, while ours is " @@ -1047,12 +996,10 @@ } - if (conn->ifc_idle_to >= 2000000 -+ /* bun: an explicit cadence (per-conn override, or es_ping_period_us) -+ * replaces the idle-derived keep-alive cadence (node:quic's keepAlive -+ * option is millisecond-granular and per-session). */ -+ if (conn->ifc_ping_us_override_set) -+ conn->ifc_ping_period = conn->ifc_ping_us_override; -+ else if (conn->ifc_enpub->enp_settings.es_ping_period_us) ++ /* bun: an explicit cadence (es_ping_period_us) replaces the idle-derived ++ * keep-alive cadence (node:quic's keepAlive option is ++ * millisecond-granular). */ ++ if (conn->ifc_enpub->enp_settings.es_ping_period_us) + conn->ifc_ping_period = conn->ifc_enpub->enp_settings.es_ping_period_us; + else if (conn->ifc_idle_to >= 2000000 && conn->ifc_enpub->enp_settings.es_ping_period) @@ -1066,7 +1013,7 @@ LSQ_DEBUG("PING period is set to %"PRIu64" usec", conn->ifc_ping_period); if (conn->ifc_settings->es_delayed_acks -@@ -3726,7 +3852,9 @@ init_http (struct ietf_full_conn *conn) +@@ -3726,7 +3842,9 @@ init_http (struct ietf_full_conn *conn) &max_risked_streams); if (0 != lsquic_hcso_write_settings(&conn->ifc_hcso, conn->ifc_settings->es_max_header_list_size, dyn_table_size, @@ -1077,7 +1024,7 @@ #if LSQUIC_WEBTRANSPORT_SERVER_SUPPORT , conn->ifc_settings->es_webtransport_server, -@@ -3737,6 +3865,17 @@ init_http (struct ietf_full_conn *conn) +@@ -3737,6 +3855,17 @@ init_http (struct ietf_full_conn *conn) ABORT_WARN("cannot write SETTINGS"); return -1; } @@ -1095,7 +1042,7 @@ /* QDH initialization must precede outgoing decoder stream creation: * create_qdec_stream_out() synchronously invokes qdh_out_on_new(). -@@ -3867,6 +4006,7 @@ handshake_ok (struct lsquic_conn *lconn) +@@ -3867,6 +3996,7 @@ handshake_ok (struct lsquic_conn *lconn) conn->ifc_send_flags |= SF_SEND_NEW_CID; maybe_install_noprogress_timer(conn); maybe_create_delayed_streams(conn); @@ -1103,7 +1050,7 @@ if (!(conn->ifc_flags & IFC_SERVER)) lsquic_send_ctl_0rtt_to_1rtt(&conn->ifc_send_ctl); -@@ -4059,8 +4199,9 @@ immediate_close (struct ietf_full_conn *conn) +@@ -4059,8 +4189,9 @@ immediate_close (struct ietf_full_conn *conn) */ lsquic_send_ctl_drop_scheduled(&conn->ifc_send_ctl); @@ -1114,7 +1061,7 @@ return TICK_CLOSE; if ((conn->ifc_flags & (IFC_SERVER|IFC_IGNORE_HSK)) || -@@ -4080,7 +4221,7 @@ immediate_close (struct ietf_full_conn *conn) +@@ -4080,7 +4211,7 @@ immediate_close (struct ietf_full_conn *conn) return TICK_CLOSE; } @@ -1123,7 +1070,7 @@ if (pns == PNS_HSK && conn->ifc_error.app_error) { /* [RFC 9000] Section 10.2.3: clear app code and reason: */ -@@ -4117,7 +4258,11 @@ immediate_close (struct ietf_full_conn *conn) +@@ -4117,7 +4248,11 @@ immediate_close (struct ietf_full_conn *conn) sz = conn->ifc_conn.cn_pf->pf_gen_connect_close_frame( packet_out->po_data + packet_out->po_data_sz, lsquic_packet_out_avail(packet_out), conn_err.app_error, @@ -1136,7 +1083,7 @@ error_reason ? strlen(error_reason) : 0); if (sz < 0) { LSQ_WARN("%s failed", __func__); -@@ -4757,6 +4902,9 @@ handshake_confirmed (struct ietf_full_conn *conn) +@@ -4757,6 +4892,9 @@ handshake_confirmed (struct ietf_full_conn *conn) */ conn->ifc_conn.cn_esf.i->esfi_handshake_confirmed( conn->ifc_conn.cn_enc_session); @@ -1146,7 +1093,7 @@ if (!(conn->ifc_flags & (IFC_SERVER|IFC_MIGRA))) { conn->ifc_flags |= IFC_MIGRA; /* Perform migration just once */ -@@ -5049,6 +5197,18 @@ switch_path_to (struct ietf_full_conn *conn, unsigned char path_id) +@@ -5049,6 +5187,18 @@ switch_path_to (struct ietf_full_conn *conn, unsigned char path_id) &conn->ifc_paths[path_id].cop_path); assert(conn->ifc_cur_path_id != path_id); @@ -1165,7 +1112,7 @@ CUR_CPATH(conn)->cop_flags |= COP_RETIRED; EV_LOG_CONN_EVENT(LSQUIC_LOG_CONN_ID, "switched paths"); -@@ -5733,6 +5893,9 @@ process_stream_frame (struct ietf_full_conn *conn, +@@ -5733,6 +5883,9 @@ process_stream_frame (struct ietf_full_conn *conn, conn->ifc_max_req_id = stream_frame->stream_id; } @@ -1175,7 +1122,7 @@ stream_frame->packet_in = lsquic_packet_in_get(packet_in); if (0 != lsquic_stream_frame_in(stream, stream_frame)) { -@@ -5852,6 +6015,8 @@ process_ping_frame (struct ietf_full_conn *conn, +@@ -5852,6 +6005,8 @@ process_ping_frame (struct ietf_full_conn *conn, * return the length of this frame. */ EV_LOG_PING_FRAME_IN(LSQUIC_LOG_CONN_ID); @@ -1184,7 +1131,7 @@ if (conn->ifc_flags & IFC_SERVER) log_conn_flow_control(conn); -@@ -5859,6 +6024,67 @@ process_ping_frame (struct ietf_full_conn *conn, +@@ -5859,6 +6014,38 @@ process_ping_frame (struct ietf_full_conn *conn, } @@ -1201,35 +1148,6 @@ +} + + -+/* bun: per-conn keep-alive cadence. node:quic's keepAlive is a per-session -+ * option, but engine settings are shared by every conn on the endpoint — -+ * a reused endpoint must not inherit the first session's cadence. Pass 0 -+ * to disable keep-alive PINGs for this conn. No-op for mini conns. -+ */ -+void -+lsquic_conn_set_ping_period_us (struct lsquic_conn *lconn, uint64_t usec) -+{ -+ 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_ping_us_override = usec; -+ conn->ifc_ping_us_override_set = 1; -+ /* Before handshake completion, ifc_ping_period carries the handshake -+ * ping cadence and is re-derived (honoring the override) when the -+ * peer's transport parameters are processed; only apply directly once -+ * the handshake is done. -+ */ -+ if (lconn->cn_flags & LSCONN_HANDSHAKE_DONE) -+ { -+ conn->ifc_ping_period = usec; -+ if (!usec) -+ lsquic_alarmset_unset(&conn->ifc_alset, AL_PING); -+ } -+} -+ -+ +/* bun: opt this client conn in to migrating to the server's preferred + * address (node:quic's preferredAddressPolicy: "use"). Must be called + * before the handshake completes; no-op for mini conns. @@ -1252,7 +1170,7 @@ static int is_benign_transport_error_code (uint64_t error_code) { -@@ -6080,6 +6306,11 @@ process_max_streams_frame (struct ietf_full_conn *conn, +@@ -6080,6 +6267,11 @@ process_max_streams_frame (struct ietf_full_conn *conn, sd == SD_BIDI ? "bidi" : "uni", conn->ifc_max_allowed_stream_id[sit], max_stream_id); conn->ifc_max_allowed_stream_id[sit] = max_stream_id; @@ -1264,7 +1182,7 @@ } else LSQ_DEBUG("ignore old max %s streams value of %"PRIu64, -@@ -6184,6 +6415,16 @@ insert_new_dcid (struct ietf_full_conn *conn, uint64_t seqno, +@@ -6184,6 +6376,16 @@ insert_new_dcid (struct ietf_full_conn *conn, uint64_t seqno, (*dce)->de_cid = *cid; memcpy((*dce)->de_srst, token, sizeof((*dce)->de_srst)); (*dce)->de_flags |= DE_SRST; @@ -1281,7 +1199,7 @@ if (update_cur_dcid) { *CUR_DCID(conn) = *cid; -@@ -6414,9 +6655,12 @@ process_new_token_frame (struct ietf_full_conn *conn, +@@ -6414,9 +6616,12 @@ process_new_token_frame (struct ietf_full_conn *conn, free(token_str); } } @@ -1295,7 +1213,7 @@ return parsed_len; } -@@ -6659,12 +6903,30 @@ process_datagram_frame (struct ietf_full_conn *conn, +@@ -6659,12 +6864,30 @@ process_datagram_frame (struct ietf_full_conn *conn, EV_LOG_CONN_EVENT(LSQUIC_LOG_CONN_ID, "%zd-byte DATAGRAM", data_sz); LSQ_DEBUG("%zd-byte DATAGRAM", data_sz); @@ -1326,7 +1244,7 @@ typedef unsigned (*process_frame_f)( struct ietf_full_conn *, struct lsquic_packet_in *, const unsigned char *p, size_t); -@@ -7702,6 +7964,45 @@ process_incoming_packet_fast (struct ietf_full_conn *conn, +@@ -7702,6 +7925,45 @@ process_incoming_packet_fast (struct ietf_full_conn *conn, } @@ -1372,7 +1290,7 @@ static void ietf_full_conn_ci_packet_in (struct lsquic_conn *lconn, struct lsquic_packet_in *packet_in) -@@ -7711,7 +8012,7 @@ ietf_full_conn_ci_packet_in (struct lsquic_conn *lconn, +@@ -7711,7 +7973,7 @@ ietf_full_conn_ci_packet_in (struct lsquic_conn *lconn, CONN_STATS(in.bytes, packet_in->pi_data_sz); if (conn->ifc_idle_to) lsquic_alarmset_set(&conn->ifc_alset, AL_IDLE, @@ -1381,7 +1299,7 @@ if (0 == (conn->ifc_flags & IFC_IMMEDIATE_CLOSE_FLAGS)) if (0 != conn->ifc_process_incoming_packet(conn, packet_in)) conn->ifc_flags |= IFC_ERROR; -@@ -8094,6 +8395,9 @@ ietf_full_conn_ci_early_data_failed (struct lsquic_conn *lconn) +@@ -8094,6 +8356,9 @@ ietf_full_conn_ci_early_data_failed (struct lsquic_conn *lconn) struct ietf_full_conn *conn = (struct ietf_full_conn *) lconn; LSQ_DEBUG("early data failed"); @@ -1391,19 +1309,7 @@ lsquic_send_ctl_stash_0rtt_packets(&conn->ifc_send_ctl); } -@@ -8153,6 +8457,11 @@ write_datagram (struct ietf_full_conn *conn) - packet_out = get_writeable_packet(conn, need); - if (!packet_out) - return 0; -+ /* bun: pre-handshake datagrams ride in 0-RTT packets (RFC 9221 sec 5: -+ * DATAGRAM frames live in the application packet-number space; the -+ * stream write path stamps the same type in lsquic_stream.c). */ -+ if (!(conn->ifc_conn.cn_flags & LSCONN_HANDSHAKE_DONE)) -+ packet_out->po_header_type = HETY_0RTT; - - w = conn->ifc_conn.cn_pf->pf_gen_datagram_frame( - packet_out->po_data + packet_out->po_data_sz, -@@ -8415,7 +8724,12 @@ ietf_full_conn_ci_tick (struct lsquic_conn *lconn, lsquic_time_t now) +@@ -8415,7 +8680,12 @@ ietf_full_conn_ci_tick (struct lsquic_conn *lconn, lsquic_time_t now) if (!write_is_possible(conn)) goto end_write; @@ -1417,7 +1323,7 @@ if (!write_is_possible(conn)) goto end_write; -@@ -8474,6 +8788,7 @@ ietf_full_conn_ci_tick (struct lsquic_conn *lconn, lsquic_time_t now) +@@ -8474,6 +8744,7 @@ ietf_full_conn_ci_tick (struct lsquic_conn *lconn, lsquic_time_t now) } } else if (conn->ifc_ping_period @@ -1425,7 +1331,7 @@ && (conn->ifc_conn.cn_flags & LSCONN_HANDSHAKE_DONE)) { lsquic_alarmset_unset(&conn->ifc_alset, AL_PING); -@@ -8492,9 +8807,12 @@ ietf_full_conn_ci_tick (struct lsquic_conn *lconn, lsquic_time_t now) +@@ -8492,9 +8763,12 @@ ietf_full_conn_ci_tick (struct lsquic_conn *lconn, lsquic_time_t now) * frames without coordination can produce an excessive number of * packets and poor performance. */ @@ -1440,7 +1346,7 @@ lsquic_alarmset_set(&conn->ifc_alset, AL_PING, now + conn->ifc_ping_period); -@@ -8636,6 +8954,39 @@ ietf_full_conn_ci_make_stream (struct lsquic_conn *lconn) +@@ -8636,6 +8910,39 @@ ietf_full_conn_ci_make_stream (struct lsquic_conn *lconn) } @@ -1480,7 +1386,7 @@ static void ietf_full_conn_ci_internal_error (struct lsquic_conn *lconn, const char *format, ...) -@@ -9137,6 +9488,7 @@ on_settings_frame (void *ctx) +@@ -9137,6 +9444,7 @@ on_settings_frame (void *ctx) LSQ_DEBUG("cannot create QPACK encoder stream due to unidir limit"); } maybe_create_delayed_streams(conn); @@ -1488,7 +1394,7 @@ } -@@ -9159,6 +9511,10 @@ on_setting (void *ctx, uint64_t setting_id, uint64_t value) +@@ -9159,6 +9467,10 @@ on_setting (void *ctx, uint64_t setting_id, uint64_t value) LSQ_DEBUG("Peer's SETTINGS_MAX_HEADER_LIST_SIZE=%"PRIu64"; " "we ignore it", value); break; @@ -1499,7 +1405,7 @@ default: LSQ_DEBUG("received unknown SETTING 0x%"PRIX64"=0x%"PRIX64 "; ignore it", setting_id, value); -@@ -9455,6 +9811,18 @@ on_frame_error (void *ctx, unsigned code, uint64_t frame_type) +@@ -9455,6 +9767,18 @@ on_frame_error (void *ctx, unsigned code, uint64_t frame_type) } @@ -1518,7 +1424,7 @@ static const struct hcsi_callbacks hcsi_callbacks_server_27 = { .on_cancel_push = on_cancel_push_server, -@@ -9472,6 +9840,7 @@ static const struct hcsi_callbacks hcsi_callbacks_client_27 = +@@ -9472,6 +9796,7 @@ static const struct hcsi_callbacks hcsi_callbacks_client_27 = .on_max_push_id = on_max_push_id_client, .on_settings_frame = on_settings_frame, .on_setting = on_setting, @@ -1526,7 +1432,7 @@ .on_goaway = on_goaway_client_27, .on_frame_error = on_frame_error, .on_priority_update = on_priority_update_client, -@@ -9495,6 +9864,7 @@ static const struct hcsi_callbacks hcsi_callbacks_client_29 = +@@ -9495,6 +9820,7 @@ static const struct hcsi_callbacks hcsi_callbacks_client_29 = .on_max_push_id = on_max_push_id_client, .on_settings_frame = on_settings_frame, .on_setting = on_setting, @@ -1534,7 +1440,7 @@ .on_goaway = on_goaway_client, .on_frame_error = on_frame_error, .on_priority_update = on_priority_update_client, -@@ -9966,3 +10336,23 @@ lsquic_ietf_full_conn_test_conn_close (unsigned results[13]) +@@ -9966,3 +10292,23 @@ lsquic_ietf_full_conn_test_conn_close (unsigned results[13]) typedef char dcid_elem_fits_in_128_bytes[sizeof(struct dcid_elem) <= 128 ? 1 : - 1]; @@ -1770,73 +1676,6 @@ else { snprintf(reason_buf, sizeof(reason_buf), "TLS alert %"PRIu8, ---- a/src/liblsquic/lsquic_packet_out.c -+++ b/src/liblsquic/lsquic_packet_out.c -@@ -401,6 +401,20 @@ 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,6 +376,9 @@ 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 -@@ -1174,6 +1174,16 @@ 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); -+ - ++ctl->sc_loss_count; - #if LSQUIC_CONN_STATS - ++ctl->sc_conn_pub->conn_stats->out.lost_packets; -@@ -1507,6 +1517,12 @@ 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); - LSQ_DEBUG("acking via regular record #%"PRIu64, - packet_out->po_packno); - } --- a/src/liblsquic/lsquic_stream.c +++ b/src/liblsquic/lsquic_stream.c @@ -694,6 +694,8 @@ lsquic_stream_destroy (lsquic_stream_t *stream) @@ -1986,7 +1825,7 @@ LSQUIC_TEST_WEAK void lsquic_stream_acked (struct lsquic_stream *stream, -@@ -5338,6 +5416,20 @@ lsquic_stream_is_rejected (const struct lsquic_stream *stream) +@@ -5338,6 +5421,20 @@ lsquic_stream_is_rejected (const struct lsquic_stream *stream) } 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/reset-keeps-window-update.patch b/patches/lsquic/reset-keeps-window-update.patch new file mode 100644 index 000000000000..26123278603a --- /dev/null +++ b/patches/lsquic/reset-keeps-window-update.patch @@ -0,0 +1,25 @@ +--- a/src/liblsquic/lsquic_stream.c ++++ b/src/liblsquic/lsquic_stream.c +@@ -4558,15 +4558,16 @@ lsquic_stream_force_reset_ext (struct lsquic_stream *stream, + return; + /* bun: stream_reset clears every queued sending flag; a STOP_SENDING + * the application queued just before the reset (Node's shutdown_stream +- * emits both frames) must still reach the wire. */ +- const enum stream_q_flags had_ss = +- stream->sm_qflags & SMQF_SEND_STOP_SENDING; ++ * emits both frames) must still reach the wire. So must a ++ * MAX_STREAM_DATA: the read side stays open, sf_recv_off has already ++ * moved, and no later read queues the frame again. */ ++ const enum stream_q_flags kept = stream->sm_qflags ++ & (SMQF_SEND_STOP_SENDING|SMQF_SEND_MAX_STREAM_DATA); + if (!(stream->stream_flags & STREAM_RST_SENT) + && !(stream->sm_qflags & SMQF_SEND_RST)) + stream_reset(stream, error_code, 0); +- if (had_ss) +- stream->sm_qflags |= SMQF_SEND_STOP_SENDING; +- else if (stream->error_code == 0) ++ stream->sm_qflags |= kept; ++ if (!(kept & SMQF_SEND_STOP_SENDING) && stream->error_code == 0) + stream->error_code = error_code; + /* Only end the read side when the peer has already FIN'd or RST'd it — + * Node's resetStream resets the write side only; the read side stays 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/boringssl.ts b/scripts/build/deps/boringssl.ts index ee422006e948..0fd6e065bb72 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/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..bc4df1bfb861 100644 --- a/scripts/build/deps/lsquic.ts +++ b/scripts/build/deps/lsquic.ts @@ -153,6 +153,104 @@ 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() reports each DATAGRAM frame as acknowledged, lost, or + // dropped before the send, under the id that on_dg_write gave it with + // lsquic_conn_set_datagram_id(). 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", + // lsquic_stream_force_reset_ext() resets the send side only, but it dropped a + // MAX_STREAM_DATA that was queued for the read side. The receive limit had + // already moved, so no later read queued the frame again and the peer stayed + // blocked. It takes a reset between lsquic_stream_hold_window(s, 0) and the + // next tick. Applies on top of node-quic-accessors. + "patches/lsquic/reset-keeps-window-update.patch", + // A header block sent while the end of the body was still in the stream's + // buffer went out inside the body's last DATA frame, with no HEADERS frame of + // its own. The peer read trailers as body bytes, or closed the connection + // when the block didn't fit in that frame's packet. + "patches/lsquic/headers-after-buffered-body.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 a4593b3fd8b2..35508c12fbb0 100644 --- a/src/boringssl_sys/boringssl.rs +++ b/src/boringssl_sys/boringssl.rs @@ -5,7 +5,7 @@ //! **not** a full bindgen dump. When the bindgen pipeline lands this module //! is replaced wholesale. -use core::ffi::{c_char, c_int, c_long, c_uint, c_ulong, c_void}; +use core::ffi::{c_char, c_int, c_long, c_uint, c_void}; use core::mem::MaybeUninit; // ═══════════════════════════════════════════════════════════════════════════ @@ -183,6 +183,12 @@ 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 {} +/// The first field of `struct ssl_early_callback_ctx`, valid only inside a `select_certificate_cb`. +#[repr(C)] +pub struct SSL_CLIENT_HELLO { + pub ssl: *mut SSL, +} + /// `struct hmac_ctx_st`. #[repr(C)] #[derive(Copy, Clone)] @@ -826,6 +832,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; @@ -839,11 +846,12 @@ 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; pub const ssl_renegotiate_never: ssl_renegotiate_mode_t = 0; -pub const ssl_renegotiate_explicit: ssl_renegotiate_mode_t = 4; /// `SSL_OP_LEGACY_SERVER_CONNECT` — BoringSSL defines this as 0 (no-op flag); /// kept so callers can mirror the OpenSSL clear/set dance verbatim. @@ -879,10 +887,6 @@ pub const BIO_TYPE_SOURCE_SINK: c_int = 0x0400; // Additional opaque handles // ═══════════════════════════════════════════════════════════════════════════ -opaque!( - /// `struct ssl_method_st` (`typedef ... SSL_METHOD`). - SSL_METHOD -); opaque!( /// `struct x509_store_st` (`typedef ... X509_STORE`). X509_STORE @@ -891,10 +895,6 @@ opaque!( /// `struct x509_store_ctx_st` (`typedef ... X509_STORE_CTX`). X509_STORE_CTX ); -opaque!( - /// `struct X509_crl_st` (`typedef ... X509_CRL`). - X509_CRL -); opaque!( /// `struct rsa_st` (`typedef ... RSA`). RSA @@ -913,7 +913,6 @@ pub type pem_password_cb = unsafe extern "C" fn(*mut c_char, c_int, c_int, *mut unsafe extern "C" { // ── SSL_CTX ────────────────────────────────────────────────────────── - pub fn SSL_CTX_new(method: *const SSL_METHOD) -> *mut SSL_CTX; pub fn SSL_CTX_free(ctx: *mut SSL_CTX); pub fn SSL_CTX_get_verify_mode(ctx: *const SSL_CTX) -> c_int; pub fn SSL_CTX_set_session_id_context( @@ -921,6 +920,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; @@ -931,10 +937,7 @@ unsafe extern "C" { pub fn CRYPTO_BUFFER_POOL_new() -> *mut CRYPTO_BUFFER_POOL; // ── SSL ────────────────────────────────────────────────────────────── - pub fn SSL_new(ctx: *mut SSL_CTX) -> *mut SSL; pub fn SSL_free(ssl: *mut SSL); - pub fn SSL_set_connect_state(ssl: *mut SSL); - pub fn SSL_set_accept_state(ssl: *mut SSL); pub fn SSL_set_bio(ssl: *mut SSL, rbio: *mut BIO, wbio: *mut BIO); pub fn SSL_do_handshake(ssl: *mut SSL) -> c_int; pub fn SSL_read(ssl: *mut SSL, buf: *mut c_void, num: c_int) -> c_int; @@ -944,31 +947,20 @@ unsafe extern "C" { pub fn SSL_get_error(ssl: *const SSL, ret_code: c_int) -> c_int; pub fn SSL_get_shutdown(ssl: *const SSL) -> c_int; pub fn SSL_is_init_finished(ssl: *const SSL) -> c_int; - pub fn SSL_set_verify(ssl: *mut SSL, mode: c_int, callback: SSL_verify_cb); pub fn SSL_set_session_id_context( ssl: *mut SSL, sid_ctx: *const u8, sid_ctx_len: usize, ) -> c_int; - pub fn SSL_set0_verify_cert_store(ssl: *mut SSL, store: *mut X509_STORE) -> c_int; 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; - 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( - bp: *mut BIO, - x: *mut *mut X509, - cb: Option, - u: *mut c_void, - ) -> *mut X509; pub fn OPENSSL_free(ptr: *mut c_void); pub fn SSL_CTX_get0_param(ctx: *mut SSL_CTX) -> *mut c_void; pub fn SSL_get_group_id(ssl: *const SSL) -> u16; @@ -979,14 +971,17 @@ 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; - pub fn SSL_set_ex_data(ssl: *mut SSL, idx: c_int, data: *mut c_void) -> c_int; pub fn SSL_set_tlsext_host_name(ssl: *mut SSL, name: *const c_char) -> c_int; pub fn SSL_set_alpn_protos(ssl: *mut SSL, protos: *const u8, protos_len: usize) -> c_int; pub fn SSL_get0_alpn_selected(ssl: *const SSL, out_data: *mut *const u8, out_len: *mut c_uint); @@ -1139,10 +1134,6 @@ unsafe extern "C" { // Extern functions — TLS context/session setup for QUIC (node:quic) // ═══════════════════════════════════════════════════════════════════════════ -opaque!( - /// `struct evp_pkey_st` (`typedef ... EVP_PKEY`). - EVP_PKEY -); opaque!( /// `struct ssl_session_st` (`typedef ... SSL_SESSION`). SSL_SESSION @@ -1152,13 +1143,9 @@ opaque!( pub const TLS1_3_VERSION: u16 = 0x0304; unsafe extern "C" { - pub safe fn TLS_method() -> *const SSL_METHOD; - pub fn SSL_CTX_set_min_proto_version(ctx: *mut SSL_CTX, version: u16) -> c_int; pub fn SSL_CTX_set_max_proto_version(ctx: *mut SSL_CTX, version: u16) -> c_int; pub fn SSL_CTX_set_verify(ctx: *mut SSL_CTX, mode: c_int, callback: SSL_verify_cb); - pub fn SSL_CTX_use_certificate(ctx: *mut SSL_CTX, x509: *mut X509) -> c_int; - pub fn SSL_CTX_use_PrivateKey(ctx: *mut SSL_CTX, pkey: *mut EVP_PKEY) -> c_int; pub fn SSL_get_verify_result(ssl: *const SSL) -> c_long; @@ -1188,35 +1175,10 @@ unsafe extern "C" { pub fn X509_LAZY_CERT_SET_can_index(der: *const u8, len: usize) -> c_int; pub safe fn X509_get_default_cert_file() -> *const c_char; pub safe fn X509_get_default_cert_dir() -> *const c_char; - pub fn PEM_read_bio_X509( - bp: *mut BIO, - x: *mut *mut X509, - cb: Option, - u: *mut c_void, - ) -> *mut X509; - pub fn PEM_read_bio_PrivateKey( - bp: *mut BIO, - x: *mut *mut EVP_PKEY, - cb: Option, - u: *mut c_void, - ) -> *mut EVP_PKEY; - pub fn EVP_PKEY_free(pkey: *mut EVP_PKEY); /// Returns a static NUL-terminated string, never NULL. pub safe fn X509_verify_cert_error_string(err: c_long) -> *const c_char; - pub fn X509_STORE_add_cert(store: *mut X509_STORE, x509: *mut X509) -> c_int; - pub fn X509_STORE_add_crl(store: *mut X509_STORE, crl: *mut X509_CRL) -> c_int; - pub fn X509_STORE_set_flags(store: *mut X509_STORE, flags: c_ulong) -> c_int; - pub fn X509_VERIFY_PARAM_set_flags(param: *mut c_void, flags: c_ulong) -> c_int; - pub fn X509_CRL_free(crl: *mut X509_CRL); - pub fn PEM_read_bio_X509_CRL( - bp: *mut BIO, - x: *mut *mut X509_CRL, - cb: Option, - u: *mut c_void, - ) -> *mut X509_CRL; - /// Returns a NEW reference (caller frees) or null when the peer sent no /// certificate. pub fn SSL_get_peer_certificate(ssl: *const SSL) -> *mut X509; diff --git a/src/bun_core/Global.rs b/src/bun_core/Global.rs index a04a6be1c9aa..9670512de79a 100644 --- a/src/bun_core/Global.rs +++ b/src/bun_core/Global.rs @@ -733,9 +733,12 @@ pub fn exit(code: u32) -> ! { // kills it. No args, no preconditions: `safe fn`. unsafe extern "C" { safe fn Bun__lockThreadSuspensionForExit(); + safe fn bsd_shutdown_owned_sockets(); } Bun__onExit(); Bun__lockThreadSuspensionForExit(); + // A POSIX kernel ends the connections an exiting process left open. Winsock resets them. + bsd_shutdown_owned_sockets(); // `ExitProcess` is `safe fn` (no preconditions; never returns). crate::windows_sys::kernel32::ExitProcess(code) } @@ -882,40 +885,13 @@ unsafe impl Sync for SyncCStr {} #[unsafe(no_mangle)] static Bun__userAgent: SyncCStr = SyncCStr(concatcp!(user_agent, "\0").as_ptr().cast::()); -/// The certificate authorities that TLS trusts when a connection names none, besides those in -/// `NODE_EXTRA_CA_CERTS`. -#[repr(u8)] -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum CAStore { - /// The bundled ones, and those in OpenSSL's default certificate file and directory. - Bundled = 0, - /// Those of `Bundled`, and the system's. - System = 1, - /// Only those in OpenSSL's default certificate file and directory. - OpenSSL = 2, -} - -/// A `CAStore`. `root_certs.cpp` reads this when it builds the default certificate store. `Relaxed` -/// is enough for it and for `PASSED_CA_STORE_FLAG`: they are written while the arguments are parsed, -/// before there is another thread. -#[unsafe(no_mangle)] -static Bun__Node__CAStore: AtomicU8 = AtomicU8::new(CAStore::Bundled as u8); - -pub fn ca_store() -> CAStore { - match Bun__Node__CAStore.load(Ordering::Relaxed) { - store if store == CAStore::System as u8 => CAStore::System, - store if store == CAStore::OpenSSL as u8 => CAStore::OpenSSL, - _ => CAStore::Bundled, - } -} - const CA_STORE_FLAGS: [&CStr; 3] = [c"--use-system-ca", c"--use-openssl-ca", c"--use-bundled-ca"]; pub fn is_ca_store_flag(arg: &[u8]) -> bool { CA_STORE_FLAGS.iter().any(|flag| flag.to_bytes() == arg) } -/// Index into `CA_STORE_FLAGS`, or its length. +/// Index into `CA_STORE_FLAGS`, or its length. `Relaxed` is enough: it is written while the arguments are parsed, before there is another thread. static PASSED_CA_STORE_FLAG: AtomicU8 = AtomicU8::new(CA_STORE_FLAGS.len() as u8); /// The one of `CA_STORE_FLAGS` that was passed, to pass on to a `bun` that this process spawns. @@ -927,8 +903,7 @@ pub fn ca_store_flag() -> Option<&'static ZStr> { .map(ZStr::from_cstr) } -/// Handles `CA_STORE_FLAGS`. Exits with an error if more than one is passed, and falls back to -/// `NODE_USE_SYSTEM_CA` if none is. +/// Handles `CA_STORE_FLAGS`. Exits with an error if more than one is passed. pub fn set_ca_store_from_flags(has_flag: impl Fn(&'static [u8]) -> bool) { let passed = CA_STORE_FLAGS.map(|flag| has_flag(flag.to_bytes())); let count = passed.iter().filter(|&&passed| passed).count(); @@ -938,22 +913,44 @@ pub fn set_ca_store_from_flags(has_flag: impl Fn(&'static [u8]) -> bool) { ); exit(1); } - let store = match passed { - [true, ..] => CAStore::System, - // Node exits if `NODE_USE_SYSTEM_CA=1` comes with this flag: - // https://github.com/nodejs/node/blob/v26.3.0/src/node_options.cc#L212 - // Here a `.env` file can set the variable for the processes that this one spawns, which get - // the flag too. - [_, true, _] => CAStore::OpenSSL, - [_, _, true] => CAStore::Bundled, - _ if crate::env_var::NODE_USE_SYSTEM_CA.get().unwrap_or(false) => CAStore::System, - _ => CAStore::Bundled, - }; - Bun__Node__CAStore.store(store as u8, Ordering::Relaxed); let index = passed.iter().position(|&passed| passed); PASSED_CA_STORE_FLAG.store(index.unwrap_or(passed.len()) as u8, Ordering::Relaxed); } +/// The roots in the default certificate store, besides `NODE_EXTRA_CA_CERTS` and OpenSSL's default paths. +#[repr(C)] +struct DefaultCASources { + bundled: bool, + system: bool, +} + +#[unsafe(no_mangle)] +extern "C" fn Bun__defaultCASources() -> DefaultCASources { + let (bundled, system) = match ca_store_flag().map(ZStr::as_bytes) { + Some(b"--use-system-ca") => (true, true), + // Node exits if `NODE_USE_SYSTEM_CA=1` comes with this flag. Here a `.env` file can set the variable for the processes that this one spawns, which get the flag too. + Some(b"--use-openssl-ca") => (false, false), + // Read when asked, not at startup: a compiled executable parses no flags. + _ => ( + true, + crate::env_var::NODE_USE_SYSTEM_CA.get().unwrap_or(false), + ), + }; + DefaultCASources { bundled, system } +} + +/// The `tls.getCACertificates()` types that make up `"default"`. OpenSSL's default paths have no type, as in Node. +pub fn default_ca_certificate_types() -> impl Iterator { + let sources = Bun__defaultCASources(); + [ + (sources.bundled, "bundled"), + (sources.system, "system"), + (true, "extra"), + ] + .into_iter() + .filter_map(|(used, name)| used.then_some(name)) +} + /// Prevent the linker from dead-code-eliminating `#[no_mangle]` symbols that are /// only ever called from C/C++ (so rustc sees no Rust caller). Expands to one /// `core::hint::black_box(f as *const ())` per path — purely a side-effect, so diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 2fb725f3a083..e7d3dcf56494 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -1253,16 +1253,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 = @@ -1276,10 +1283,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)); @@ -1291,6 +1294,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 // ─────────────────────────────────────────────────────────────────────────── @@ -3535,6 +3558,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/bun_core/lib.rs b/src/bun_core/lib.rs index 412272fcf591..e8e6ef75b6a4 100644 --- a/src/bun_core/lib.rs +++ b/src/bun_core/lib.rs @@ -464,22 +464,6 @@ pub mod vec { unsafe { spare_bytes_mut(v) } } - /// View the **entire** allocation `v[0..capacity]` as `&mut [u8]`. - /// For the spare-only `[len..capacity]` view use [`spare_bytes_mut`]. - /// - /// # Safety - /// Bytes in `[len, capacity)` are uninitialized; treat that tail as - /// write-only (same contract as [`spare_bytes_mut`]). The caller must not - /// rely on the tail's prior contents. - #[inline] - pub unsafe fn allocated_bytes_mut(v: &mut Vec) -> &mut [u8] { - let cap = v.capacity(); - // SAFETY: `as_mut_ptr()` returns a pointer valid for `cap` bytes of - // the backing allocation; caller upholds the write-only contract on - // the uninitialized tail. - unsafe { core::slice::from_raw_parts_mut(v.as_mut_ptr(), cap) } - } - /// Advance `v.len()` by `n` after a producer has initialized the first /// `n` bytes of [`spare_bytes_mut`]`(v)`. /// diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs index 4c205aeac69e..d7858bc28def 100644 --- a/src/bun_core/util.rs +++ b/src/bun_core/util.rs @@ -4942,6 +4942,24 @@ impl Timespec { } } + /// The monotonic clock as of the kernel's last timer tick on Linux, so 1 to 10 ms behind [`Timespec::now`] at a fifth of the cost. + pub fn now_coarse() -> Timespec { + #[cfg(any(target_os = "linux", target_os = "android"))] + { + let mut ts = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + if clock_gettime(libc::CLOCK_MONOTONIC_COARSE, &mut ts) == 0 { + return Timespec { + sec: ts.tv_sec, + nsec: ts.tv_nsec, + }; + } + } + Self::now_real() + } + #[inline] pub fn since_now(&self, mode: TimespecMockMode) -> u64 { Self::now(mode).duration(self).ns() @@ -4958,14 +4976,6 @@ pub enum TimespecMockMode { ForceRealTime, } -/// `bun_core::timespec_mode::Mode` namespace shim — -/// Rust can't do inherent associated types stably, so expose a module -/// instead. Callers write `bun_core::timespec_mode::AllowMockedTime` or use -/// the `Timespec::now_allow_mocked_time()` helper. -pub mod timespec_mode { - pub use super::TimespecMockMode::*; -} - /// Mocked-time storage. The data lives at T0 so `Timespec::now` reads it /// directly; the test-runner (`useFakeTimers`) writes via `set`/`clear` /// from `bun_runtime::test_runner::timers::FakeTimers`. Thread-local, like @@ -5159,8 +5169,6 @@ pub mod perf { unsafe extern "C" { /// No preconditions; returns 0/1 based on tracefs availability. pub safe fn Bun__linux_trace_init() -> core::ffi::c_int; - /// No preconditions. - pub safe fn Bun__linux_trace_close(); pub fn Bun__linux_trace_emit( event_name: *const core::ffi::c_char, duration_ns: i64, @@ -5238,7 +5246,7 @@ pub mod perf { } // ── form_data ───────────────────────────────────────────────────────────── -// `bun.FormData.{Encoding, AsyncFormData, getBoundary}`. +// `bun.FormData.{Encoding, getBoundary}`. // The JSC-touching parts (`toJS`, the field map, // multipart parser) stay in `bun_runtime::webcore::form_data`; T0 owns only // the encoding-detection types so `Request`/`Response`/`Body` can name them @@ -5331,22 +5339,6 @@ pub mod form_data { } None } - - /// `FormData.AsyncFormData` — heap-allocated, owns its `Encoding`. - /// Cleanup is `Drop` on the - /// `Box`/`Box<[u8]>` fields — no explicit impl needed. - #[derive(Debug)] - pub struct AsyncFormData { - pub encoding: Encoding, - } - - impl AsyncFormData { - #[inline] - pub fn init(encoding: Encoding) -> Box { - // With `Box<[u8]>`, boundary ownership has already transferred. - Box::new(AsyncFormData { encoding }) - } - } } /// `bun.FormData` — capitalized namespace alias. pub use form_data as FormData; diff --git a/src/bundler/aot/services.rs b/src/bundler/aot/services.rs index 7490820aae9d..df6ec47f066d 100644 --- a/src/bundler/aot/services.rs +++ b/src/bundler/aot/services.rs @@ -589,7 +589,7 @@ impl SourceProvider for BundlerServices { Some(known) => known.value.into_owned(), None => written.to_ascii_lowercase(), }; - let kept = MimeType::MimeType::init(&kept, false, None); + let kept = MimeType::MimeType::init(&kept, false); Some(Loader::from_mime_type(kept)) } diff --git a/src/bundler/native_test_shims.rs b/src/bundler/native_test_shims.rs index 772c1f8973c4..935d2a369a6b 100644 --- a/src/bundler/native_test_shims.rs +++ b/src/bundler/native_test_shims.rs @@ -86,7 +86,7 @@ extern "C" fn Bun__JSC__operationMathPow(base: f64, exponent: f64) -> f64 { // No text is a URL here: whoever asks goes on as behind a text that does not parse. #[unsafe(no_mangle)] #[linkage = "weak"] -extern "C" fn URL__getHref(_text: &bun_core::String) -> bun_core::String { +extern "C" fn URL__getHref(_text: &bun_core::String, _exclude_fragment: bool) -> bun_core::String { bun_core::String::DEAD } @@ -95,6 +95,7 @@ extern "C" fn URL__getHref(_text: &bun_core::String) -> bun_core::String { extern "C" fn URL__getHrefJoin( _base: &bun_core::String, _relative: &bun_core::String, + _exclude_fragment: bool, ) -> bun_core::String { bun_core::String::DEAD } diff --git a/src/cares_sys/c_ares.rs b/src/cares_sys/c_ares.rs index fe5b10505168..533e60de80cf 100644 --- a/src/cares_sys/c_ares.rs +++ b/src/cares_sys/c_ares.rs @@ -1624,6 +1624,8 @@ pub enum Error { /// libuv's `UV_EAI_AGAIN`, the `errno` Node reports for getaddrinfo `EAI_AGAIN`. const UV_EAI_AGAIN: i32 = -3001; +/// libuv's `UV_EAI_NONAME`, the `errno` Node reports with getaddrinfo's `ENOTFOUND`. +pub const UV_EAI_NONAME: i32 = -3008; impl Error { // Deferred / toDeferred / toJSWithSyscall / toJSWithSyscallAndHostname @@ -1702,6 +1704,32 @@ impl Error { } } + /// The `errno` Node reports for libc's getaddrinfo(3) status `rc`, libuv's `UV_EAI_*`: https://github.com/libuv/libuv/blob/v1.51.0/include/uv/errno.h#L33-L46 + pub fn uv_eai(rc: i32) -> Option { + #[cfg(windows)] + { + // `init_eai` turns every Winsock status into ENOTFOUND. + (rc != 0).then_some(UV_EAI_NONAME) + } + #[cfg(not(windows))] + { + match EAI::from_raw(rc) { + EAI::ADDRFAMILY => Some(-3000), + EAI::AGAIN => Some(UV_EAI_AGAIN), + EAI::BADFLAGS => Some(-3002), + EAI::FAIL => Some(-3004), + EAI::FAMILY => Some(-3005), + EAI::MEMORY => Some(-3006), + EAI::NODATA => Some(-3007), + EAI::NONAME => Some(UV_EAI_NONAME), + EAI::SERVICE => Some(-3010), + #[cfg(any(target_os = "linux", target_os = "android"))] + EAI::SOCKTYPE => Some(-3011), + _ => None, + } + } + } + /// This platform's raw getaddrinfo(3) status for the `EAI_*` code named `name`. pub fn eai_raw_by_name(name: &[u8]) -> Option { #[cfg(windows)] diff --git a/src/codegen/builtins/data/module-exports.txt b/src/codegen/builtins/data/module-exports.txt index 4cf2b836b0dc..aa50a31f1017 100644 --- a/src/codegen/builtins/data/module-exports.txt +++ b/src/codegen/builtins/data/module-exports.txt @@ -8,8 +8,8 @@ node:_http_agent Agent default globalAgent shouldUseEnvProxy node:_http_client ClientRequest default node:_http_common CRLF HTTPParser MAX_HEADER_PAIRS _checkInvalidHeaderChar _checkIsHttpToken calculateLenientFlags chunkExpression continueExpression default freeParser isLenient kIncomingMessage kSkipPendingData methods parsers prepareError validateHeaderName validateHeaderValue node:_http_incoming IncomingMessage default kReqShouldKeepAlive readStart readStop -node:_http_outgoing FakeSocket OutgoingMessage default kErrored kHighWaterMark kOutHeaders kRejectNonStandardBodyWrites kSocket kUniqueHeaders parseUniqueHeadersOption validateHeaderName validateHeaderValue -node:_http_server Server ServerResponse default kConnectionsCheckingInterval +node:_http_outgoing OutgoingMessage default kErrored kHighWaterMark kOutHeaders kRejectNonStandardBodyWrites kSocket kUniqueHeaders parseUniqueHeadersOption validateHeaderName validateHeaderValue +node:_http_server Server ServerResponse _connectionListener default httpServerPreClose kConnectionsCheckingInterval setupConnectionsTracking storeHTTPOptions node:_stream_duplex=function default from fromWeb toWeb node:_stream_passthrough=function default node:_stream_readable=function ReadableState _fromList default from fromWeb toWeb wrap @@ -35,7 +35,7 @@ node:domain _stack active create createDomain default node:events=function EventEmitter EventEmitterAsyncResource addAbortListener captureRejectionSymbol captureRejections default defaultMaxListeners errorMonitor getEventListeners getMaxListeners init listenerCount on once prototype setMaxListeners usingDomains node:fs Dir Dirent F_OK FileReadStream FileWriteStream R_OK ReadStream Stats Utf8Stream W_OK WriteStream X_OK _toUnixTimestamp access accessSync appendFile appendFileSync chmod chmodSync chown chownSync close closeSync constants copyFile copyFileSync cp cpSync createReadStream createWriteStream default exists existsSync fchmod fchmodSync fchown fchownSync fdatasync fdatasyncSync fstat fstatSync fsync fsyncSync ftruncate ftruncateSync futimes futimesSync glob globSync lchmod lchmodSync lchown lchownSync link linkSync lstat lstatSync lutimes lutimesSync mkdir mkdirSync mkdtemp mkdtempDisposableSync mkdtempSync open openAsBlob openSync opendir opendirSync promises read readFile readFileSync readSync readdir readdirSync readlink readlinkSync readv readvSync realpath realpathSync rename renameSync rm rmSync rmdir rmdirSync stat statSync statfs statfsSync symlink symlinkSync truncate truncateSync unlink unlinkSync unwatchFile utimes utimesSync watch watchFile write writeFile writeFileSync writeSync writev writevSync node:fs/promises access appendFile chmod chown close constants copyFile cp default exists fchmod fchown fdatasync fstat fsync ftruncate futimes glob lchmod lchown link lstat lutimes mkdir mkdtemp mkdtempDisposable open opendir read readFile readdir readlink readv realpath rename rm rmdir stat statfs symlink truncate unlink utimes watch write writeFile writev -node:http Agent ClientRequest CloseEvent IncomingMessage METHODS MessageEvent OutgoingMessage STATUS_CODES Server ServerResponse WebSocket createServer default get globalAgent maxHeaderSize request setGlobalProxyFromEnv setMaxIdleHTTPParsers validateHeaderName validateHeaderValue +node:http Agent ClientRequest CloseEvent IncomingMessage METHODS MessageEvent OutgoingMessage STATUS_CODES Server ServerResponse WebSocket _connectionListener createServer default get globalAgent maxHeaderSize request setGlobalProxyFromEnv setMaxIdleHTTPParsers validateHeaderName validateHeaderValue node:http2 ClientHttp2Session Http2ServerRequest Http2ServerResponse connect constants createSecureServer createServer default getDefaultSettings getPackedSettings getUnpackedSettings performServerHandshake sensitiveHeaders node:https Agent Server createServer default get globalAgent request node:inspector Session close console default open url waitForDebugger diff --git a/src/codegen/builtins/data/own-properties.txt b/src/codegen/builtins/data/own-properties.txt index 059528f78d1c..b95925e91429 100644 --- a/src/codegen/builtins/data/own-properties.txt +++ b/src/codegen/builtins/data/own-properties.txt @@ -21,7 +21,7 @@ %IntlSegments%.prototype : @@iterator/wc containing/wc %IteratorHelper%.prototype : @@toStringTag/c next/wc return/wc %MapIterator%.prototype : @@toStringTag/c next/wc -%NodeHTTPResponse%.prototype : @@toStringTag/c abort/w aborted/a bufferedAmount/a cork/w end/w finished/a flags/a flushHeaders/w getBytesWritten/w hasBody/a headersObject/w notifyWhenReadParsed/w onabort/a ondata/a onwritable/a pause/w pauseReads/w ref/w resume/w takeRawHeaders/w takeRequestTrailers/w unref/w upgraded/a write/w writeContinue/w writeHead/w writeHeadAndEnd/w writeInformational/w writeWholeResponse/w +%NodeHTTPResponse%.prototype : @@toStringTag/c abort/w bufferedAmount/a end/w finished/a flags/a flushHeaders/w hasBody/a headersObject/w notifyWhenReadParsed/w onabort/a ondata/a onwritable/a pause/w resume/w takeRawHeaders/w takeRequestTrailers/w write/w writeContinue/w writeHead/w writeHeadAndEnd/w writeHeadAndWrite/w writeInformational/w writeWholeResponse/w %RegExpStringIterator%.prototype : @@toStringTag/c next/wc %S3File%.prototype : @@toStringTag/c bucket/ac presign/c stat/c %ServerWebSocket%.prototype : @@toStringTag/c binaryType/a close/w constructor/wc cork/w data/a getBufferedAmount/w isPaused/a isSubscribed/w pause/w ping/w pong/w publish/w publishBinary/w publishText/w readyState/a remoteAddress/a resume/w send/w sendBinary/w sendText/w subscribe/w subscriptions/a terminate/w unsubscribe/w diff --git a/src/codegen/builtins/host/features-bun.builtins.ts b/src/codegen/builtins/host/features-bun.builtins.ts index 38bd906d44dd..0521b2252736 100644 --- a/src/codegen/builtins/host/features-bun.builtins.ts +++ b/src/codegen/builtins/host/features-bun.builtins.ts @@ -128,6 +128,13 @@ export default b.declarations( ["InternalForTesting", "TlsSockets"], ...nativeBindings("Bindgen", "SecureContext.bind.ts", "liveCount"), ...nativeBindings("Bindgen", "socket_body.bind.ts", "setSocketOptions"), + ...nativeBindings( + "Bindgen", + "socket_body.bind.ts", + "namedPipeLiveCount", + "sweepSocketTimers", + "upgradedDuplexLiveCount", + ), ), extendAll( ["InternalForTesting", "NodeFs"], @@ -140,6 +147,7 @@ export default b.declarations( ), ), extendAll(["InternalForTesting", "FsStats"], ...nativeBindings("Bindgen", "Stat.bind.ts", "createStatsForIno")), + extendAll(["InternalForTesting", "Test"], ...nativeBindings("Bindgen", "FakeTimers.bind.ts", "fakeLibraryTimers")), extendAll(["InternalForTesting", "Buffer"], ...nativeBindings("Bindgen", "memory_pressure.bind.ts", "psiTrigger")), extendAll( @@ -365,7 +373,6 @@ export default b.declarations( "dgramCloseFd", "dgramGetSockNameFd", "dgramGuessHandleType", - "dgramIsFdAdopted", "dgramListenFd", "dgramNewSocketFd", "disconnect", @@ -403,7 +410,7 @@ export default b.declarations( extendAll( ["Sockets"], "%Listener%", - ...nativeBindings("Bindgen", "socket_body.bind.ts", "getBufferedAmount", "hasPendingRead", "isNamedPipeSocket"), + ...nativeBindings("Bindgen", "socket_body.bind.ts", "getBytesRead", "hasPendingRead", "isNamedPipeSocket"), ), extendAll( @@ -434,6 +441,7 @@ export default b.declarations( "%DNSResolver%", ...nativeBindings("Bindgen", "dns_internals.bind.ts", "getRuntimeDefaultResultOrderOption"), ...nativeBindings("Bindgen", "dns_internals.bind.ts", "newResolver"), + ...nativeBindings("Bindgen", "dns_internals.bind.ts", "setConnectResultOrder"), ), extendAll( @@ -471,6 +479,14 @@ export default b.declarations( extendAll( ["Tls"], ...nativeBindings("Bindgen", "Listener.bind.ts", "addServerName", "setSecureContext"), + ...nativeBindings("Bindgen", "SSLConfig.bind.ts", "protocolVersionCode"), + ...nativeBindings( + "Bindgen", + "node_tls_binding.bind.ts", + "getDefaultCA", + "getDefaultCACertificateTypes", + "setDefaultCA", + ), ...nativeBindings( "Bindgen", "socket_body.bind.ts", diff --git a/src/codegen/builtins/host/features-node.builtins.ts b/src/codegen/builtins/host/features-node.builtins.ts index b300d89ecd4d..3899ff5a4bf3 100644 --- a/src/codegen/builtins/host/features-node.builtins.ts +++ b/src/codegen/builtins/host/features-node.builtins.ts @@ -104,13 +104,7 @@ export default b.declarations( extendAll(["NodeFs", "FsStats"], "%StatWatcher%"), extendAll(["NodeFs"], "%FSWatcher%"), - extendAll( - ["HttpParser"], - "node:_http_common", - "node:_http_client", - "node:_http_server", - "internal/http1_server_fallback", - ), + extendAll(["HttpParser"], "node:_http_common", "node:_http_client", "node:_http_server"), extendAll(["StringDecoder", "Buffer"], "node:string_decoder"), @@ -120,8 +114,8 @@ export default b.declarations( "Bindgen", "node_cluster_binding.bind.ts", "channelIgnoreOneDisconnectEventListener", + "clusterAdoptFd", "clusterCloseHandle", - "clusterValidateFd", "onInternalMessageChild", "onInternalMessagePrimary", "setRef", @@ -138,14 +132,18 @@ export default b.declarations( ...nativeBindings( "Bindgen", "node_http_binding.bind.ts", - "getBunServerOpenCount", + "closedWithAllDataSent", + "httpConnectionOf", "insecureHTTPParser", "getMaxHTTPHeaderSize", "sendWebSocketFrame", + "setBunServerSocketHandlers", "setMaxHTTPHeaderSize", - "upgradeNodeHTTPResponseToWebSocket", + "setServerAppFlags", + "setServerCustomOptions", + "setServerMaxHeadersCount", + "upgradeSocketToWebSocket", ), - ...nativeBindings("Bindgen", "node_http_binding.bind.ts", "getBunServerAllClosedPromise"), ), extendAll( @@ -239,6 +237,7 @@ export default b.declarations( extendAll( ["Undici", "TlsSockets", "X509", "Tls", "Buffer"], ...nativeBindings("Cpp", "Undici.cpp", "createUndiciInternalBinding"), + ...nativeBindings("Bindgen", "ReadableStream.bind.ts", "isDecodedBody"), ), extendAll(["NodeEvents"], "node:events"), diff --git a/src/codegen/builtins/host/features-web.builtins.ts b/src/codegen/builtins/host/features-web.builtins.ts index 3cd0962d5ad0..43f702b27986 100644 --- a/src/codegen/builtins/host/features-web.builtins.ts +++ b/src/codegen/builtins/host/features-web.builtins.ts @@ -200,7 +200,7 @@ export default b.declarations( ["InternalForTesting", "HttpClient"], ...nativeBindings("Bindgen", "headers_jsc.bind.ts", "h2LiveCounts"), ...nativeBindings("Bindgen", "headers_jsc.bind.ts", "h3LiveCounts"), - ...nativeBindings("Bindgen", "proxy_testing.bind.ts", "noProxyMatches", "parseURL", "proxyFor"), + ...nativeBindings("Bindgen", "proxy_testing.bind.ts", "noProxyMatches", "parseURL", "proxyFor", "tunnelLiveCount"), ), extendAll(["InternalForTesting", "WebStreams"], ...nativeBindings("Bindgen", "ByteStream.bind.ts", "cancelOnDrain")), ); diff --git a/src/codegen/builtins/host/globals.builtins.ts b/src/codegen/builtins/host/globals.builtins.ts index 87d22b7aea1d..fe7b34836b34 100644 --- a/src/codegen/builtins/host/globals.builtins.ts +++ b/src/codegen/builtins/host/globals.builtins.ts @@ -661,6 +661,7 @@ const serverOptionNames = [ "hostname", "host", "unix", + "fd", "baseURI", "id", "reusePort", @@ -683,8 +684,8 @@ const pluginsSetUpUnder = (...path: readonly string[]) => args: [b.any], result: "Escapes", }); -// The server, the URL, the method, the dispatch bits, the response, whether there is a body, the socket, whether it is new, its duplex, whether the request is HTTP/1.0, the bytes after the head, and whether the request was pipelined. -const nodeRequestHandlerArguments = Array.from({ length: 12 }, () => b.any); +// The URL, the method, the dispatch bits, the response, whether there is a body, the socket, whether it is new, its duplex, whether the request is HTTP/1.0, the bytes after the head, and whether the request was pipelined. +const nodeRequestHandlerArguments = Array.from({ length: 11 }, () => b.any); const routeMethodNames = ["CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "TRACE"]; // A socket is one object for as long as it lives, whichever handler it is handed to. @@ -1005,7 +1006,7 @@ const serverMembers = { returns: thisServer, uses: { this: serverState, options: serverOptions(thisServer) }, }, - // The handler gets a request of its own and no second argument: a copy of the Request that is passed, which is told from any other object by its class. + // The handler gets a request of its own: a copy of the Request that is passed, which is told from any other object by its class. Any other object is read as its href. fetch: { ...startedByOptions, args: { request: b.any.optional, options: b.any.optional }, @@ -1016,7 +1017,7 @@ const serverMembers = { b.keptCalled(requestHandlerCell, { times: "Once", this: thisServer, - args: [b.instance("Request").fresh], + args: [b.instance("Request").fresh, thisServer], result: "Escapes", }), ], @@ -1047,7 +1048,7 @@ const serverMembers = { ...changesServerState, throws: true, args: { request: b.any.optional, seconds: b.any.optional }, - returns: b.union(b.undefined, b.strictNull), + returns: b.undefined, uses: { this: serverState, request: [b.readsContents(), b.writesContents()], seconds: b.inspected }, }, port: readsServerState(b.union(b.f64, b.undefined)), diff --git a/src/codegen/builtins/host/native-bindings.builtins.ts b/src/codegen/builtins/host/native-bindings.builtins.ts index e6c56bf65f77..2d28bc57805a 100644 --- a/src/codegen/builtins/host/native-bindings.builtins.ts +++ b/src/codegen/builtins/host/native-bindings.builtins.ts @@ -1,7 +1,7 @@ // The objects that the host answers for `$cpp(file, symbol)`, each from the code of the function of that name. operand-changes.builtins.ts says which binding answers which of the objects whose functions change an operand. import * as b from "bun-builtins-table"; import { fsOperandUses, heapsOfViews } from "./fs-operands.ts"; -import { answering, constructing, retainedByNativeCode } from "./native-code.ts"; +import { answering, constructing } from "./native-code.ts"; const externalState = ["ExternalState"]; const viewHeaps = ["TypedArrayStorage", "TypedArrayElements"]; @@ -39,19 +39,6 @@ const convertsDomain = { implementation: b.generic, } satisfies b.FunctionField; -// Native code keeps the server's flags and limits. It finds the server behind the wrapper, and throws for anything else. -// It reads its operands without asking how many were passed. -const configuresServer = (operands: Readonly>) => - ({ - args: Object.fromEntries(Object.keys(operands).map(name => [name, b.any])), - returns: b.undefined, - capability: "js", - throws: true, - uses: operands, - writes: externalState, - implementation: b.generic, - }) satisfies b.FunctionField; - // The options of a node:fs operation decide the type of its result (args::*::from_js in node_fs.rs). const parsesFsOptions = (args: Readonly>, returns: b.TableType) => ({ @@ -142,25 +129,6 @@ export default b.declarations( length: 0, }), bagOfFunctions(httpFunctions, { - // The two callbacks are called for each client error and each connection. - setServerCustomOptions: configuresServer({ - server: retainedByNativeCode, - requireHostHeader: b.inspected, - useStrictMethodValidation: b.inspected, - lenientHttpFlags: converted, - maxHeaderSize: converted, - onClientError: retainedByNativeCode, - onConnection: retainedByNativeCode, - httpAllowHalfOpen: b.inspected, - }), - setServerAppFlags: configuresServer({ - server: retainedByNativeCode, - requireHostHeader: b.inspected, - useStrictMethodValidation: b.inspected, - lenientHttpFlags: converted, - httpAllowHalfOpen: b.inspected, - }), - setServerMaxHeadersCount: configuresServer({ server: retainedByNativeCode, maxHeadersCount: converted }), drainMicrotasks: { returns: b.undefined, capability: "js", throws: true, implementation: b.generic }, }), answers("native:Cpp:NodeHTTP.cpp:createNodeHTTPInternalBinding", b.builtin(httpFunctions)), diff --git a/src/codegen/builtins/host/native-functions.builtins.ts b/src/codegen/builtins/host/native-functions.builtins.ts index f5eb621222de..82ae45d6b709 100644 --- a/src/codegen/builtins/host/native-functions.builtins.ts +++ b/src/codegen/builtins/host/native-functions.builtins.ts @@ -341,11 +341,12 @@ export default b.declarations( writes: externalState, implementation: b.hostCall, }), + // Answers the handle of the native source that the stream holds, if it has one that nothing has read from. b.static(cpp("streams/BunStreamConsumers.cpp", "jsFunctionTransferToNativeReadableStream"), { args: { stream: b.any.optional }, - returns: b.undefined, - capability: "none", - uses: { stream: b.inspected }, + returns: b.any, + capability: "heap", + uses: { stream: b.retainedByNativeCode }, writes: externalState, implementation: b.hostCall, }), diff --git a/src/codegen/builtins/host/operand-changes.builtins.ts b/src/codegen/builtins/host/operand-changes.builtins.ts index ddd3bf152c5d..c5a3a16dae80 100644 --- a/src/codegen/builtins/host/operand-changes.builtins.ts +++ b/src/codegen/builtins/host/operand-changes.builtins.ts @@ -346,26 +346,6 @@ export default b.declarations( handlerOf("Bun.RedisClient.prototype.onclose", { this: b.operand("this"), args: [b.any] }), // It also answers the callback of a write that has not gone out whole, which was handed over like any operand. responseHandler("onwritable", { this: b.undefined, args: [b.f64] }), - // What the callback writes goes out in one piece when it returns. - b.method("%NodeHTTPResponse%.prototype.cork", { - args: { callback: b.callable }, - returns: b.callbackResult("callback"), - capability: "js", - throws: true, - uses: { - this: retainedByNativeCode, - callback: [ - b.called({ - afterwards: "TouchesOnlyWhatRowNames", - times: "Once", - this: b.undefined, - args: [], - result: "Returned", - }), - ], - }, - implementation: b.hostCall, - }), // Each own member of the object is read and turned into a string. b.method("%ParsedShellScript%.prototype.setEnv", { diff --git a/src/codegen/builtins/host/runtime-dependencies.builtins.ts b/src/codegen/builtins/host/runtime-dependencies.builtins.ts index f558eedc5ed1..daa79b75f167 100644 --- a/src/codegen/builtins/host/runtime-dependencies.builtins.ts +++ b/src/codegen/builtins/host/runtime-dependencies.builtins.ts @@ -712,6 +712,7 @@ const nativeCalls = [ times: 2, onBehalfOf: [...channelUsers, "%Subprocess%.prototype.send"], }, + { file: "src/runtime/ipc_host.rs", caller: "handle_ipc_close", onBehalfOf: channelUsers }, { file: "src/runtime/node/node_crypto_binding.rs", caller: "random_int", @@ -735,7 +736,7 @@ const nativeCalls = [ ], calls: environmentObjectMade(), otherMentions: [ - { file: inBindings("JSEnvironmentVariableMap.cpp") }, + { file: inBindings("JSEnvironmentVariableMap.cpp"), times: 2 }, { file: inBindings("JSEnvironmentVariableMap.h") }, { file: inBindings("JSPropertyIterator.cpp") }, { file: inBindings("ZigGlobalObject.cpp"), times: 4 }, @@ -790,7 +791,6 @@ const callsOfInspect = b.nativeCalls({ { file: inBindings("UtilInspect.cpp") }, { file: inBindings("ZigGlobalObject.cpp"), times: 2 }, { file: inBindings("ZigGlobalObject.h") }, - { file: inBindings("bindings.cpp") }, { file: inBindings("webcore/JSBroadcastChannel.cpp") }, { file: inBindings("webcore/JSPerformanceEntry.cpp") }, { file: inBindings("webcore/JSURLSearchParams.cpp") }, diff --git a/src/codegen/builtins/host/socket-options.ts b/src/codegen/builtins/host/socket-options.ts index 53c69949b5cc..7aa1b1b58523 100644 --- a/src/codegen/builtins/host/socket-options.ts +++ b/src/codegen/builtins/host/socket-options.ts @@ -67,6 +67,7 @@ const socketOptionNames = [ "unix", "fd", "fdIsRawSocket", + "fdIsWriteOnly", "localAddress", "localPort", "exclusive", @@ -88,8 +89,7 @@ const socketHandlerArguments: Readonly> = open: [socket], data: [socket, receivedChunk], close: [socket, b.thrown], - // A TLS socket passes a negative error number after a send has failed for good. - drain: [socket, b.union(b.f64, b.undefined)], + drain: [socket], error: [socket, b.thrown], end: [socket], timeout: [socket], @@ -98,6 +98,8 @@ const socketHandlerArguments: Readonly> = session: [socket, b.instance("Buffer").fresh], keylog: [socket, b.instance("Buffer").fresh], alpnCallback: [socket, b.union(b.string, b.undefined), b.instance("Buffer").fresh], + // The status of the write: 0, or a negative error number. + writeComplete: [socket, b.f64], }; // The data of the listener for a socket that it accepted, the socket's own for one that was upgraded, undefined where there is none. @@ -105,7 +107,7 @@ const dataOfServer = [b.nativeCell(socketDataCell), b.undefined]; // SocketConfig.bind.ts and SSLConfig.bind.ts: a dictionary. Each member is read under its name and is of its type or refused, but these: // `hostname`, `host` and `port` are converted, so is a String object as `binaryType`, and `ca`, `cert`, `key` and `crl` of `tls` may be a sequence, which is iterated. -// Listener.rs describes a `secureContext` that is none in its message: whatever is there may be printed. +// SecureContext.rs describes a `secureContext` that is none in its message: whatever is there may be printed. export const socketOptions = [ b.storedIn(b.nativeCell(socketOptionsCell)), b.reachableValuesStoredIn(b.nativeCell(socketOptionsCell)), diff --git a/src/codegen/builtins/host/unseen-code.builtins.ts b/src/codegen/builtins/host/unseen-code.builtins.ts index dc8d1c16689f..a85184d49ea2 100644 --- a/src/codegen/builtins/host/unseen-code.builtins.ts +++ b/src/codegen/builtins/host/unseen-code.builtins.ts @@ -71,6 +71,7 @@ const seenCodeEntries = [ "native:NewCppFunction:NodeModuleModule.cpp:jsFunctionIsModuleResolveFilenameSlowPathEnabled", "native:NewCppFunction:NodeVM.cpp:jsFunctionSetNewContextsHaveGC", "native:NewCppFunction:Worker.cpp:jsFunctionPostMessage", + "native:NewCppFunction:node/NodeTimers.cpp:functionSetImmediate", "native:Bindgen:napi_body.bind.ts:threadsafeFunctionLiveCount", "node:module._debug", "node:module._findPath", diff --git a/src/codegen/builtins/host/web-classes.builtins.ts b/src/codegen/builtins/host/web-classes.builtins.ts index c269c097f921..1c35950cb1ad 100644 --- a/src/codegen/builtins/host/web-classes.builtins.ts +++ b/src/codegen/builtins/host/web-classes.builtins.ts @@ -21,6 +21,8 @@ const operandCodeOnly = { const anyCode = { capability: "js", throws: true, implementation: b.hostCall } as const; const primitiveOrAbsent = b.primitiveValue.optional; +// JSON.stringify asks a BigInt for its toJSON. +const primitiveWithoutToJSON = b.union(b.undefined, b.strictNull, b.bool, b.f64, b.string, b.symbol).optional; const anythingOrAbsent = b.any.optional; const typedArrayOrBuffer = b.union( b.instance("TypedArray"), @@ -187,7 +189,7 @@ const responseConstructionFromParts = [ // A call without a value makes an empty body, and a call with undefined throws. const responseFromJSON = [ - { ...nativeOnly, args: { value: primitiveOrAbsent, init: primitiveOrAbsent }, returns: newResponse }, + { ...nativeOnly, args: { value: primitiveWithoutToJSON, init: primitiveOrAbsent }, returns: newResponse }, { ...operandCodeOnly, args: { value: anythingOrAbsent, init: primitiveOrAbsent }, diff --git a/src/codegen/builtins/host/web-values.builtins.ts b/src/codegen/builtins/host/web-values.builtins.ts index 00523d497ab3..dd8101963257 100644 --- a/src/codegen/builtins/host/web-values.builtins.ts +++ b/src/codegen/builtins/host/web-values.builtins.ts @@ -173,14 +173,6 @@ const storesInForm = [ ...takingStrings(["name", "value"], [], { ...stateChanged, throws: true }), ] satisfies b.FunctionField[]; -const otherReceiverIgnored = { - this: b.any, - returns: b.undefined, - capability: "none", - uses: { this: b.inspected }, - implementation: b.hostCall, -} satisfies b.FunctionField; - const parsesURL = (returns: b.TableType, throws: boolean) => takingStrings(["url"], ["base"], { returns, throws }); // Native code makes the object at the first read and keeps it for the later ones. It is nothing that the program has handed over. @@ -371,14 +363,7 @@ export default b.declarations( b.construction("Headers", [ { args: { init: b.undefined.optional }, returns: newHeaders, capability: "heap", implementation: b.hostCall }, - { - args: { init: b.instance("Headers") }, - returns: newHeaders, - capability: "heap", - uses: { init: readsState }, - reads: externalState, - implementation: b.hostCall, - }, + // Headers too: a Symbol.iterator that a subclass or the program has replaced is run. { ...operandCodeOnly, args: { init: b.any }, @@ -395,14 +380,8 @@ export default b.declarations( delete: takingStrings(["name"], [], { ...stateChanged, throws: true }), set: takingStrings(["name", "value"], [], { ...stateChanged, throws: true }), append: takingStrings(["name", "value"], [], { ...stateChanged, throws: true }), - getAll: [ - ...takingStrings(["name"], [], { ...stateRead, returns: newArrayOfStrings, throws: true }), - otherReceiverIgnored, - ], - getSetCookie: [ - { ...stateRead, returns: newArrayOfStrings, capability: "heap", implementation: b.hostCall }, - otherReceiverIgnored, - ], + getAll: takingStrings(["name"], [], { ...stateRead, returns: newArrayOfStrings, throws: true }), + getSetCookie: { ...stateRead, returns: newArrayOfStrings, capability: "heap", implementation: b.hostCall }, toJSON: { ...stateRead, returns: b.plainData, capability: "heap", implementation: b.hostCall }, count: [ { ...stateRead, getter: b.u32, capability: "none", implementation: b.hostCall }, diff --git a/src/collections/hive_array.rs b/src/collections/hive_array.rs index afef6bf8aa41..ce2668bcdde8 100644 --- a/src/collections/hive_array.rs +++ b/src/collections/hive_array.rs @@ -688,8 +688,7 @@ impl HiveRef { } /// Owning handle to a refcounted [`HiveRef`] pool slot. `Clone` increments, -/// `Drop` decrements and recycles when the count hits zero. Cross FFI with -/// [`as_ptr`](Self::as_ptr) / [`from_raw`](Self::from_raw), like `Rc`/`Box`. +/// `Drop` decrements and recycles when the count hits zero. pub struct HiveRefHandle { ptr: NonNull>, } @@ -716,18 +715,6 @@ impl HiveRefHandle { } } - /// Reclaim ownership of a `+1` ref previously leaked as a raw pointer - /// (e.g. via [`as_ptr`](Self::as_ptr) + `mem::forget`). - /// - /// # Safety - /// `ptr` must be a live slot whose `+1` has not already been released. - #[inline] - pub unsafe fn from_raw(ptr: *mut HiveRef) -> Self { - Self { - ptr: NonNull::new(ptr).expect("HiveRefHandle::from_raw(null)"), - } - } - /// Raw pointer for FFI/intrusive use. Does not affect the refcount. #[inline] pub fn as_ptr(&self) -> *mut HiveRef { diff --git a/src/dns/lib.rs b/src/dns/lib.rs index aac68528dcf6..61ed7710f2c1 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; @@ -456,8 +457,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, and set only by the CLI flag and the main thread. + 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/errno/windows_errno.rs b/src/errno/windows_errno.rs index 44e099ec4ef1..39bf283113f2 100644 --- a/src/errno/windows_errno.rs +++ b/src/errno/windows_errno.rs @@ -590,10 +590,6 @@ impl SystemErrnoInit for u32 { impl SystemErrno { pub(crate) const MAX: usize = 138; - /// Windows' libuv-mapped errno set spells this `ENOTSUP`; alias the POSIX - /// `EOPNOTSUPP` name so cross-platform `match` arms compile unchanged. - pub const EOPNOTSUPP: SystemErrno = SystemErrno::ENOTSUP; - #[inline] pub const fn to_e(self) -> E { // SystemErrno and E share identical #[repr(u16)] discriminant sets. diff --git a/src/event_loop/ConcurrentTask.rs b/src/event_loop/ConcurrentTask.rs index 579af326001f..1953ae83c111 100644 --- a/src/event_loop/ConcurrentTask.rs +++ b/src/event_loop/ConcurrentTask.rs @@ -78,7 +78,8 @@ pub mod task_tag { ShellYesTask, // shell.Interpreter.Builtin.Yes.YesTask Close, CppTask, - DuplexUpgradeContext, + UpgradedDuplex, + FdPipe, FetchTasklet, FetchTaskletDeinit, FetchTaskletPromiseSettle, @@ -119,8 +120,6 @@ pub mod task_tag { HTTPSServerDeinit, DebugHTTPServerDeinit, DebugHTTPSServerDeinit, - HTTPAppClose, - HTTPSAppClose, ShellAsync, ShellCondExprStatTask, ShellCpTask, @@ -138,13 +137,15 @@ pub mod task_tag { AsyncCpTask, ShellAsyncCpTask, StreamPending, + TCPSocketWriteCompletion, // bun_runtime::socket::DeferredWriteCompletion + TLSSocketWriteCompletion, // bun_runtime::socket::DeferredWriteCompletion ThreadSafeFunction, UvDnsPollFree, // a closed c-ares uv_poll_t (windows) UvHandleClose, // an addon's uv_close (posix) UvLoopDispatch, // an addon's uv_async_send (posix) ValkeyDeferredClose, ValkeyDeferredFailure, - WindowsNamedPipeContext, + WindowsNamedPipe, Write, Writev, } diff --git a/src/event_loop/EventLoopTimer.rs b/src/event_loop/EventLoopTimer.rs index b2673da47e22..f44a7bf6d624 100644 --- a/src/event_loop/EventLoopTimer.rs +++ b/src/event_loop/EventLoopTimer.rs @@ -193,7 +193,6 @@ pub enum Tag { TimeoutObject, ImmediateObject, StatWatcherScheduler, - UpgradedDuplex, DNSResolver, DnsSdConnection, WindowsNamedPipe, @@ -213,7 +212,6 @@ pub enum Tag { EventLoopDelayMonitor, CronJob, GcRepeating, - QuicEndpoint, FakeTimersTick, ViWait, /// An N-API addon's `uv_timer_t` (POSIX, where Bun emulates libuv). @@ -247,7 +245,6 @@ impl Tag { Tag::TimeoutObject | Tag::ImmediateObject | Tag::StatWatcherScheduler - | Tag::UpgradedDuplex | Tag::DNSResolver | Tag::DnsSdConnection | Tag::WindowsNamedPipe @@ -260,7 +257,6 @@ impl Tag { | Tag::SubprocessTimeout | Tag::AbortSignalTimeout | Tag::CronJob - | Tag::QuicEndpoint | Tag::FakeTimersTick | Tag::ViWait | Tag::UvTimer diff --git a/src/http/AsyncHTTP.rs b/src/http/AsyncHTTP.rs index c3cd9844af22..50fdb968b3ca 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. @@ -49,8 +49,6 @@ pub struct AsyncHTTP<'a> { pub async_http_id: u32, pub elapsed: u64, - - pub(crate) signals: Signals, } bun_threading::intrusive_work_task!(['a] AsyncHTTP<'a>, task); @@ -151,12 +149,11 @@ 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], signals: Signals, - async_http_id: u32, http_proxy: Option>, proxy_headers: Option, redirect_type: FetchRedirect, @@ -191,11 +188,9 @@ fn make_client<'a>( proxy_headers, proxy_authorization: None, proxy_tunnel: None, - h2: None, - h3: None, + carrier: crate::Carrier::None, pending_h2: None, signals, - async_http_id, unix_socket_path: b"", compress: None, compressed_request_body: Vec::new(), @@ -290,13 +285,13 @@ 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 } /// A store into the shared signal `Store`, not into `self`. pub fn enable_response_body_streaming(&self) { - self.signals.store( + self.client.signals.store( crate::signals::Field::ResponseBodyStreaming, true, Ordering::Release, @@ -359,7 +354,8 @@ impl Preconnect { } } -pub fn preconnect(url: URL<'static>, is_url_owned: bool) { +/// `tls_props` is what the request this is for will pass: a pooled socket is only taken by a request with the same one. +pub fn preconnect(url: URL<'static>, is_url_owned: bool, tls_props: Option) { if !FeatureFlags::IS_FETCH_PRECONNECT_SUPPORTED { if is_url_owned { // SAFETY: `is_url_owned` is the caller's promise that `url.href` is a @@ -396,7 +392,10 @@ pub fn preconnect(url: URL<'static>, is_url_owned: bool) { b"", HTTPClientResultCallback::new::(this, Preconnect::on_result), FetchRedirect::Manual, - Options::default(), + Options { + tls_props, + ..Default::default() + }, )); async_http.client.flags.is_preconnect_only = true; @@ -409,8 +408,10 @@ 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], @@ -419,18 +420,29 @@ impl<'a> AsyncHTTP<'a> { redirect_type: FetchRedirect, options: Options<'a>, ) -> AsyncHTTP<'a> { - let async_http_id = if options - .signals - .as_ref() - .map(|s| s.aborted.is_some()) - .unwrap_or(false) - { - crate::ASYNC_HTTP_ID_MONOTONIC.fetch_add(1, Ordering::Relaxed) - } else { - 0 - }; + Self::init_with_method( + method.into(), + url, + headers, + headers_buf, + request_body, + callback, + redirect_type, + options, + ) + } - let signals = options.signals.unwrap_or_default(); + fn init_with_method( + method: MethodRef, + url: URL<'a>, + headers: headers::EntryList, + headers_buf: &'a [u8], + request_body: &'a [u8], + callback: HTTPClientResultCallback, + redirect_type: FetchRedirect, + options: Options<'a>, + ) -> AsyncHTTP<'a> { + let async_http_id = crate::ASYNC_HTTP_ID_MONOTONIC.fetch_add(1, Ordering::Relaxed); let client = make_client( method, @@ -439,8 +451,7 @@ impl<'a> AsyncHTTP<'a> { // and `client.header_entries`; `MultiArrayList` owns its allocation, so clone here. headers.clone().expect("OOM"), headers_buf, - signals, - async_http_id, + options.signals.unwrap_or_default(), options.http_proxy, options.proxy_headers, redirect_type, @@ -463,7 +474,6 @@ impl<'a> AsyncHTTP<'a> { err: None, async_http_id, elapsed: 0, - signals, }; if let Some(val) = options.unix_socket_path { this.client.unix_socket_path = val; @@ -472,8 +482,7 @@ impl<'a> AsyncHTTP<'a> { this.client.flags.disable_timeout = val; } if let Some(val) = options.idle_timeout_seconds { - this.client.idle_timeout_seconds = - Some(crate::normalize_idle_timeout_seconds(val.into())); + this.client.idle_timeout_seconds = Some(val); } if let Some(val) = options.verbose { this.client.verbose = val; @@ -694,26 +703,6 @@ impl<'a> AsyncHTTP<'a> { (*this).response = None; } - // Log the tracker count, then shrink - // the abort tracker back down once it has drained from a - // high-water mark (capacity > 10_000 entries but fewer than 100 - // live), so long-lived heavy-fetch processes don't pin the peak - // allocation forever. HTTP-thread-only access to the global. - { - let tracker = crate::abort_tracker(); - let count = tracker.count(); - if count > 0 { - bun_core::scoped_log!( - AsyncHTTP, - "socket_async_http_abort_tracker count: {}", - count - ); - } - if tracker.capacity() > 10_000 && count < 100 { - tracker.shrink_and_free(count); - } - } - let has_more = result.has_more; if has_more { callback.run(async_http, result); @@ -766,8 +755,8 @@ impl<'a> AsyncHTTP<'a> { drop(core::mem::take(&mut client.prev_redirect)); drop(core::mem::take(&mut client.compressed_request_body)); drop(core::mem::take(&mut client.proxy_authorization)); - client.close_proxy_tunnel(false); - debug_assert!(client.h2.is_none()); + drop(core::mem::take(&mut client.proxy_tunnel)); + debug_assert!(matches!(client.carrier, crate::Carrier::None)); drop(core::mem::take(&mut client.custom_ssl_ctx)); // `state` was `Default` at `ptr::read` time and was // populated by the clone (`on_start` → `client.start`); it @@ -787,11 +776,10 @@ impl<'a> AsyncHTTP<'a> { bun_core::from_field_ptr!(ThreadlocalAsyncHTTP, async_http, async_http); { let in_flight = &mut crate::http_thread().in_flight; - if let Some(i) = in_flight - .iter() - .position(|n| n.as_ptr() == threadlocal_http) - { - in_flight.swap_remove(i); + in_flight.swap_remove(&(*this).async_http_id); + // Don't pin the allocation of a past peak. + if in_flight.capacity() > 10_000 && in_flight.count() < 100 { + in_flight.shrink_and_free(in_flight.count()); } } // Note: reclaiming as `Box<_>` here would drop the diff --git a/src/http/H2Client.rs b/src/http/H2Client.rs index 4563ac7761f5..5d838c2f10cb 100644 --- a/src/http/H2Client.rs +++ b/src/http/H2Client.rs @@ -24,7 +24,7 @@ pub(crate) const LOCAL_MAX_HEADER_LIST_SIZE: u32 = 256 * 1024; /// CONTINUATION frames allowed per header block. Matches nghttp2's /// `NGHTTP2_DEFAULT_MAX_CONTINUATIONS` (CVE-2024-28182). -pub(crate) const LOCAL_MAX_CONTINUATIONS: u8 = 8; +pub(crate) const LOCAL_MAX_CONTINUATIONS: u32 = 8; /// `write_buffer` high-water mark. `writeDataWindowed` stops queueing once the /// userland send buffer crosses this even if flow-control window remains, so a @@ -64,74 +64,3 @@ pub use client_session::ClientSession; pub(crate) use client_session::SessionPtr; pub use pending_connect::PendingConnect; pub use stream::Stream; - -// ═══════════════════════════════════════════════════════════════════════ -// Thin `h2_*` forwarders on HTTPClient / HTTPContext that the h2_client -// 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). 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}; - - impl HTTPClient<'_> { - #[inline] - pub(crate) fn h2_register_abort_tracker(&mut self, socket: HTTPSocket) { - self.register_abort_tracker::(socket); - } - #[inline] - pub(crate) fn h2_retry_after_coalesce(&mut self) { - self.retry_after_h2_coalesce(); - } - #[inline] - pub(crate) fn h2_retry(&mut self) { - self.retry_from_h2(); - } - #[inline] - pub(crate) fn h2_fail(&mut self, err: crate::Error) { - self.fail_from_h2(err); - } - #[inline] - pub(crate) fn h2_progress_update( - &mut self, - ctx: *mut NewHTTPContext, - socket: HTTPSocket, - ) { - self.progress_update::(ctx, socket); - } - #[inline] - pub(crate) fn h2_do_redirect( - &mut self, - ctx: *mut NewHTTPContext, - socket: HTTPSocket, - ) { - self.do_redirect::(ctx, socket); - } - #[inline] - pub(crate) fn h2_clone_metadata(&mut self, response: &bun_picohttp::Response<'_>) { - self.clone_metadata(response); - } - #[inline] - pub(crate) fn h2_handle_response_body( - &mut self, - buf: &[u8], - is_only_buffer: bool, - ) -> crate::Result { - self.handle_response_body(buf, is_only_buffer) - } - #[inline] - pub(crate) fn h2_drain_response_body(&mut self, socket: HTTPSocket) { - self.drain_response_body::(socket); - } - } - - impl NewHTTPContext { - #[inline] - pub(crate) fn h2_register(&mut self, session: *mut super::ClientSession) { - self.register_h2(session); - } - } -} diff --git a/src/http/HTTPContext.rs b/src/http/HTTPContext.rs index f1bd419b95e7..cad7a04a65bf 100644 --- a/src/http/HTTPContext.rs +++ b/src/http/HTTPContext.rs @@ -12,7 +12,7 @@ use crate::{ use bun_boringssl::ssl_ctx_setup; use bun_boringssl_sys::OwnedSslCtx; use bun_collections::{HiveArray, TaggedPtrUnion}; -use bun_core::strings; +use bun_core::{Timespec, strings}; use bun_ptr::RefPtr; use bun_uws as uws; @@ -241,8 +241,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 `Timespec::now_coarse()` passes this many nanoseconds. `u64::MAX` is the default timeout, which only the timer enforces. + pub(crate) reuse_deadline_ns: u64, } /// `&mut` access to a pooled / found-slot HTTP/2 session, for the field @@ -340,6 +342,10 @@ impl HTTPContext { Handler::::add_memory_back_to_pool(tagged.as_unchecked::>()); } } + // A request never outlives its socket's tag with the socket still recorded. + if let Some(client) = tagged.client_mut() { + client.carrier = http::Carrier::None; + } Self::set_socket_ext(socket, ActiveSocket::::init(dead_socket())); } @@ -453,33 +459,11 @@ impl HTTPContext { .push(unsafe { RefPtr::init_ref(session) }); } - /// Called from drainQueuedShutdowns when the abort-tracker lookup - /// misses: a request parked in `PendingConnect.waiters` (coalesced - /// onto a leader's in-flight TLS connect) never registered a socket, - /// so it can only be found by scanning here. - pub(crate) fn abort_pending_h2_waiter(&mut self, async_http_id: u32) -> bool { - if !SSL { - return false; - } + /// Takes an aborted request off the in-flight TLS connect it was coalesced onto. + pub(crate) fn remove_pending_h2_waiter(&mut self, client: NonNull>) { for pc in &mut self.pending_h2_connects { - // `waiters` hold back-references to HTTPClients owned by their - // AsyncHTTP (alive until completion callback). `BackRef::from` - // encodes that invariant so reading the Copy `async_http_id` - // field goes through the safe `Deref` impl. - let pos = pc - .waiters - .iter() - .position(|w| bun_ptr::BackRef::from(*w).async_http_id == async_http_id); - if let Some(i) = pos { - let waiter = pc.waiters.swap_remove(i); - // Same liveness as above; exclusive access — the waiter was - // just removed from the only container that aliased it, and - // the HTTP thread is single-threaded here. - h2::PendingConnect::waiter_mut(waiter).fail_from_h2(crate::Error::Aborted); - return true; - } + pc.waiters.retain(|waiter| *waiter != client); } - false } /// Take `session` out of `active_h2_sessions`, releasing the registry's @@ -518,20 +502,8 @@ impl HTTPContext { Self::set_socket_ext(socket, ActiveSocket::::init(session)); } - pub(crate) fn init_with_client_config( - &mut self, - client: &mut HTTPClient, - ) -> Result<(), InitError> { - // Rust cannot reject a const-generic bool branch at compile time on - // stable, so this is a debug_assert. - debug_assert!(SSL, "ssl only"); - let opts = client - .tls_props - .as_ref() - .unwrap() - .get() - .as_usockets_for_client_verification(); - self.init_with_opts(&opts) + pub(crate) fn init_with_config(&mut self, config: &SSLConfig) -> Result<(), InitError> { + self.init_with_opts(&config.as_usockets_for_client_verification()) } fn init_with_opts( @@ -681,7 +653,13 @@ impl HTTPContext { proxy_auth_hash, h2_session, pool_id: pool.id, - idle_timeout_seconds: pool.idle_timeout_seconds, + idle_timeout_seconds: pool.idle_timeout_ms_or_default().div_ceil(1000), + reuse_deadline_ns: match pool.idle_timeout_ms { + 0 => u64::MAX, + ms => Timespec::now_coarse() + .ns() + .saturating_add(u64::from(ms) * 1_000_000), + }, }; let parked = match transport { Transport::Tcp => Self::park(self.pending_sockets.get_or_init(), pooled, pool), @@ -708,7 +686,6 @@ impl HTTPContext { bun_core::scoped_log!(HTTPContext, "close socket"); if let Some(t) = tunnel { ProxyTunnel::shutdown(t.as_non_null()); - crate::proxy_tunnel::raw_as_mut(t.as_ptr()).detach_socket(); } Self::close_socket(socket); } @@ -764,19 +741,12 @@ impl HTTPContext { ActiveSocket::::init(slot.addr().as_ptr().cast_const()), ); socket.flush(); - Self::arm_idle_timeout(socket, options.idle_timeout_seconds); + // The sweep runs every 4 seconds, so this only closes the socket. `reuse_deadline_ns` is the exact bound on reuse. + socket.set_timeout(pooled.idle_timeout_seconds); slot.write(pooled); None } - /// How long a parked socket may sit idle; 0 is the 5 minute default. - fn arm_idle_timeout(socket: HTTPSocket, idle_timeout_seconds: u32) { - socket.set_timeout(match idle_timeout_seconds { - 0 => 5 * 60, - seconds => seconds, - }); - } - #[allow(clippy::too_many_arguments)] fn existing_socket( &mut self, @@ -915,6 +885,14 @@ impl HTTPContext { continue; } + // The origin is about to close it, and a non-idempotent request that loses that race isn't retried. + if socket.reuse_deadline_ns != u64::MAX + && Timespec::now_coarse().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 @@ -1035,7 +1013,6 @@ impl HTTPContext { .cast::>(), ) .ptr(), - false, // dont allow half-open sockets ) .inspect_err(|_| client.record_socket_open_errno())?; client.allow_retry = false; @@ -1071,8 +1048,9 @@ impl HTTPContext { // Same guard as the pool path (`existing_socket`). && client.socket_verification().admits(s.verification) }); - if let Some(session) = reusable { - h2::ClientSession::adopt(session, client); + if let Some(session) = reusable + && h2::ClientSession::adopt(session, client) + { return Ok(None); } let cfg_nn = cfg.and_then(|p| NonNull::new(p.cast_mut())); @@ -1085,6 +1063,7 @@ impl HTTPContext { // client outlives the pending connect (resolved before // its terminal callback fires). pc.waiters.push(client.as_erased_ptr()); + client.carrier = http::Carrier::H2Connect; return Ok(None); } } @@ -1110,7 +1089,7 @@ impl HTTPContext { } else { 0 }; - if let Some(mut found) = self.existing_socket( + while let Some(mut found) = self.existing_socket( client.socket_verification(), client.target_verification(), hostname, @@ -1150,8 +1129,11 @@ impl HTTPContext { let session = session.into_this_ptr(); Self::tag_as_h2(sock, session.as_ptr()); self.register_h2(session.as_ptr()); - h2::ClientSession::adopt(session, client); - return Ok(None); + if h2::ClientSession::adopt(session, client) { + return Ok(None); + } + client.flags.reused_socket_verification = PeerVerification::None; + continue; } if let Some(tunnel) = found.tunnel { // Reattach the pooled tunnel BEFORE onOpen so the @@ -1161,7 +1143,7 @@ impl HTTPContext { // become no-ops for the CONNECT/handshake phases. // `adopt` moves the pool's strong ref into // `client.proxy_tunnel`. - crate::proxy_tunnel::ProxyTunnel::adopt::(tunnel, client, sock); + crate::proxy_tunnel::ProxyTunnel::adopt(tunnel, client); client.on_open::(sock)?; client.on_writable::(sock); } else { @@ -1192,7 +1174,6 @@ impl HTTPContext { .cast::>(), ) .ptr(), - false, ) .inspect_err(|_| client.record_socket_open_errno())?; client.allow_retry = false; @@ -1397,7 +1378,7 @@ impl Handler { // checkServerIdentity already called closeAndFail() → fail() // → result callback, which may have destroyed the // AsyncHTTP that embeds `client`. Socket is terminated - // and the abort tracker is unregistered there, so the + // and the request has let go of it there, so the // only safe action is to return without touching // `client` again. return; @@ -1447,7 +1428,7 @@ impl Handler { if handshake_success { if let Some(pooled) = active.pooled_mut() { // Allow pooled sockets to be reused if the handshake was successful. - HTTPContext::::arm_idle_timeout(socket, pooled.idle_timeout_seconds); + socket.set_timeout(pooled.idle_timeout_seconds); return; } } @@ -1460,15 +1441,11 @@ impl Handler { HTTPContext::::mark_socket_as_dead(socket); if let Some(client) = tagged.client_mut() { - return client.on_close::(socket); + return client.on_close(); } if let Some(session) = tagged.session() { return h2::ClientSession::on_close(session, crate::Error::ConnectionClosed); } - // PooledSocket/DeadSocket: whoever retagged the ext should have - // unregistered; sweep by pointer so a miss can't leave a stale - // entry for `drain_queued_shutdowns` to deref after free. - crate::unregister_abort_tracker_for_socket(socket.socket); } unsafe fn add_memory_back_to_pool(pooled_ptr: *mut PooledSocket) { @@ -1503,7 +1480,7 @@ impl Handler { pub fn on_data(ptr: *mut c_void, socket: HTTPSocket, buf: &[u8]) { let tagged = HTTPContext::::get_tagged(ptr); if let Some(client) = tagged.client_mut() { - return client.on_data::(buf, client.get_ssl_ctx::(), socket); + return client.on_data::(buf, socket); } else if let Some(session) = tagged.session() { return h2::ClientSession::on_data(session, buf); } else if let Some(pooled) = tagged.pooled_mut() { @@ -1555,7 +1532,7 @@ impl Handler { } } - pub fn on_long_timeout(ptr: *mut c_void, socket: HTTPSocket) { + pub fn on_timeout(ptr: *mut c_void, socket: HTTPSocket) { let tagged = HTTPContext::::get_tagged(ptr); if let Some(client) = tagged.client_mut() { return client.on_timeout::(socket); @@ -1564,17 +1541,14 @@ 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); } - /// Short-tick (seconds-granularity) idle timer. Same handling as - /// [`on_long_timeout`]; `HTTPClient::set_timeout` routes to whichever - /// timer suits the configured duration, so both must dispatch. - pub fn on_timeout(ptr: *mut c_void, socket: HTTPSocket) { - Self::on_long_timeout(ptr, socket); - } - pub fn on_connect_error(ptr: *mut c_void, socket: HTTPSocket, errno: c_int) { // Read before the socket is marked dead: uSockets keeps the // connecting socket alive for the whole dispatch. @@ -1583,11 +1557,6 @@ impl Handler { HTTPContext::::mark_tagged_socket_as_dead(socket, tagged); if let Some(client) = tagged.client_mut() { client.on_connect_error(dns_error, errno); - } else { - // Same backstop as `on_close`: a SEMI_SOCKET/connecting socket - // whose ext is no longer a client never dispatches `on_close`, - // so sweep any leftover tracker entry here. - crate::unregister_abort_tracker_for_socket(socket.socket); } // us_connecting_socket_close is always called internally by uSockets } @@ -1619,7 +1588,7 @@ impl Handler { } else { socket.close(uws::CloseKind::Normal); } - client.on_close::(socket); + client.on_close(); return; } if let Some(session) = tagged.session() { diff --git a/src/http/HTTPThread.rs b/src/http/HTTPThread.rs index 090e5441b4f0..8e2ef8663c7f 100644 --- a/src/http/HTTPThread.rs +++ b/src/http/HTTPThread.rs @@ -12,10 +12,9 @@ use bun_threading::{Mutex, UnboundedQueue}; use bun_uws as uws; use crate::async_http::{ACTIVE_REQUESTS_COUNT, MAX_SIMULTANEOUS_REQUESTS}; -use crate::http_context::ActiveSocketExt; use crate::proxy_tunnel::ProxyTunnel; use crate::ssl_config::{self, SSLConfig}; -use crate::{AsyncHttp, HTTPContext, HttpClient, InitError, NewHttpContext, h2, h3}; +use crate::{AsyncHttp, HttpClient, InitError, NewHttpContext, h3}; // The scope registry keys on name, so the two visibilities (.hidden + // .visible) are split into two scope names. @@ -26,20 +25,19 @@ bun_core::declare_scope!(HTTPThread_log, visible); // log /// Since configs are interned via `ssl_config::global_registry`, pointer /// equality is sufficient for lookup. Each entry holds a ref on its SSLConfig. struct SslContextCacheEntry { - ctx: RefPtr>, + /// The sockets, keep-alive pool and `SSL_CTX` of the config's TCP connections. + tcp: Option>>, + /// QUIC has its own `SSL_CTX`: TLS 1.3 only, with a verify step that reads each connection's `rejectUnauthorized`. + quic: Option, last_used_ns: u64, /// Strong ref held by the cache entry (released on eviction). _config_ref: ssl_config::SharedPtr, } -impl SslContextCacheEntry { - /// Mutable access to the cached `NewHttpContext`. The map and all callers - /// are HTTP-thread-only, so the returned `&mut` is the sole live borrow. - #[inline] - fn ctx_mut<'a>(&self) -> &'a mut NewHttpContext { - // SAFETY: see above. - unsafe { &mut *self.ctx.as_ptr() } - } +#[inline] +fn https_context_mut<'a>(ctx: &RefPtr>) -> &'a mut NewHttpContext { + // SAFETY: the map and all callers are HTTP-thread-only, so this is the sole live borrow. + unsafe { &mut *ctx.as_ptr() } } const SSL_CONTEXT_CACHE_MAX_SIZE: usize = 60; const SSL_CONTEXT_CACHE_TTL_NS: u64 = 30 * (60 * 1_000_000_000); // 30 minutes @@ -93,12 +91,14 @@ pub struct HttpThread { /// the HTTP thread; never accessed concurrently. pub(crate) deferred_tasks: Vec>>, /// Set by `drainQueuedShutdowns` when a shutdown's `async_http_id` wasn't in - /// `socket_async_http_abort_tracker` — the request is either not yet started + /// `in_flight` — the request is either not yet started /// (still in `queued_tasks`/`deferred_tasks`) or already done. `drainEvents` /// uses this to decide whether it must scan the queued/deferred lists for /// aborted tasks when `active >= max`; without it the common at-capacity /// path stays O(1). Owned by the HTTP thread. pub(crate) has_pending_queued_abort: bool, + /// How many requests had been created when `ask_for_slots` last asked. + slots_asked_at: u32, pub(crate) queued_shutdowns: Vec, pub(crate) queued_writes: Vec, @@ -126,14 +126,14 @@ pub struct HttpThread { pub(crate) timer: Instant, pub(crate) lazy_libdeflater: Option>, - /// Every `ThreadlocalAsyncHTTP` box currently in flight on this thread. + /// Every `ThreadlocalAsyncHTTP` box in flight on this thread, by the `async_http_id` the JS thread's messages name. /// Inserted by [`start_queued_task`] right after `heap::release`; removed /// by `AsyncHTTP::on_async_http_callback_raw` immediately before its /// `std::alloc::dealloc`. HTTP-thread-only. Exists so /// [`shutdown_for_exit`] can reclaim each clone-owned box at process exit /// — the request socket never reaches a terminal state once the JS thread /// stops driving the world, so the box would otherwise strand. - pub(crate) in_flight: Vec>>, + pub(crate) in_flight: ArrayHashMap>>, } impl HttpThread { @@ -170,6 +170,7 @@ impl HttpThread { queued_tasks: Queue::new(), deferred_tasks: Vec::new(), has_pending_queued_abort: false, + slots_asked_at: 0, queued_shutdowns: Vec::new(), queued_writes: Vec::new(), queued_receive_resumes: Vec::new(), @@ -186,7 +187,7 @@ impl HttpThread { has_awoken: AtomicBool::new(false), timer: Instant::now(), lazy_libdeflater: None, - in_flight: Vec::new(), + in_flight: ArrayHashMap::new(), } } } @@ -390,7 +391,7 @@ impl HttpThread { } /// A new reference to the default HTTPS context's `SSL_CTX`, which holds the CA options of [`InitOpts`]. - pub(crate) fn default_ssl_ctx(&mut self) -> OwnedSslCtx { + fn default_ssl_ctx(&mut self) -> OwnedSslCtx { self.ensure_https_context_init(); self.https_context .secure @@ -398,6 +399,97 @@ impl HttpThread { .expect("init_with_thread_opts sets `secure` or diverges") } + /// The `SSL_CTX` of a TLS connection over TCP with these options: the default one, unless they need their own. + pub(crate) fn ssl_ctx_for( + &mut self, + tls: Option<&ssl_config::SharedPtr>, + ) -> crate::Result { + let Some(tls) = tls.filter(|tls| tls.get().requires_custom_request_ctx) else { + return Ok(self.default_ssl_ctx()); + }; + Ok(self + .custom_https_context(tls)? + .secure + .clone() + .expect("init_with_config sets `secure` or fails")) + } + + /// The `SSL_CTX` of this config's HTTP/3 connections. `None` if the config can't be loaded. + pub(crate) fn quic_ssl_ctx(&mut self, tls: &ssl_config::SharedPtr) -> Option { + let entry = self.ssl_context_cache_entry(tls); + if entry.quic.is_none() { + entry.quic = tls + .get() + .as_usockets_for_client_verification() + .create_ssl_context(&mut uws::create_bun_socket_error_t::none) + .inspect(uws::quic::context::prepare_client_ssl_ctx); + } + entry.quic.clone() + } + + /// The context of this config's TCP connections. + fn custom_https_context( + &mut self, + tls: &ssl_config::SharedPtr, + ) -> crate::Result>> { + let entry = self.ssl_context_cache_entry(tls); + if let Some(ctx) = &entry.tcp { + return Ok(ctx.clone()); + } + let ctx = RefPtr::new(NewHttpContext:: { + ref_count: Cell::new(1), + pending_sockets: crate::http_context::LazyPool::new(), + pending_unix_sockets: crate::http_context::LazyPool::new(), + park_seq: 0, + group: uws::SocketGroup::default(), + secure: None, + active_h2_sessions: Vec::new(), + pending_h2_connects: Vec::new(), + session_cache: crate::session_cache::SessionCache::new(), + }); + // `init_with_config` fails before `group.init()` runs, which `impl Drop for HTTPContext` tolerates. + https_context_mut(&ctx) + .init_with_config(tls.get()) + .map_err(|err| match err { + InitError::InvalidCRL => crate::Error::InvalidCRL, + InitError::FailedToOpenSocket + | InitError::InvalidCA + | InitError::InvalidCAFile + | InitError::LoadCAFile => crate::Error::FailedToOpenSocket, + })?; + entry.tcp = Some(ctx.clone()); + Ok(ctx) + } + + /// The cache entry of an interned config, empty on first use. + fn ssl_context_cache_entry( + &mut self, + tls: &ssl_config::SharedPtr, + ) -> &'static mut SslContextCacheEntry { + self.evict_stale_ssl_contexts(); + let now = self.timer_read(); + let config: *const SSLConfig = tls.get(); + if !custom_ssl_context_map().contains(&config) { + let _ = custom_ssl_context_map().put( + config, + SslContextCacheEntry { + tcp: None, + quic: None, + last_used_ns: now, + _config_ref: tls.clone(), + }, + ); + if custom_ssl_context_map().count() > SSL_CONTEXT_CACHE_MAX_SIZE { + evict_oldest_ssl_context(); + } + } + let entry = custom_ssl_context_map() + .get_mut(&config) + .expect("the newest entry is never the one evicted"); + entry.last_used_ns = now; + entry + } + pub(crate) fn connect( &mut self, client: &mut HttpClient, @@ -416,68 +508,10 @@ impl HttpThread { if !tls.get().requires_custom_request_ctx { break 'custom_ctx; } - let requested_config: *const SSLConfig = tls.get(); - - // Evict stale entries from the cache - self.evict_stale_ssl_contexts(); - - // Look up by pointer equality (configs are interned) - if let Some(entry) = custom_ssl_context_map().get_mut(&requested_config) { - // Cache hit - reuse existing SSL context - entry.last_used_ns = self.timer_read(); - client.set_custom_ssl_ctx(entry.ctx.clone()); - // Note: NewHttpContext == NewHttpContext here (IS_SSL branch). - return Self::dial(entry.ctx_mut(), client) - .map(|o| o.map(|s| s.cast_ssl::())); - } - - // Cache miss - create new SSL context - let ctx = RefPtr::new(NewHttpContext:: { - ref_count: Cell::new(1), - pending_sockets: crate::http_context::LazyPool::new(), - pending_unix_sockets: crate::http_context::LazyPool::new(), - park_seq: 0, - group: uws::SocketGroup::default(), - secure: None, - active_h2_sessions: Vec::new(), - pending_h2_connects: Vec::new(), - session_cache: crate::session_cache::SessionCache::new(), - }); - // SAFETY: fresh allocation; HTTP-thread-only. - let custom_context = unsafe { &mut *ctx.as_ptr() }; - if let Err(err) = custom_context.init_with_client_config(client) { - // `init_with_client_config` fails before `group.init()` runs. - // `impl Drop for HTTPContext` tolerates an uninitialized - // group (skips close_all/destroy when `group.loop_` is - // null), so dropping `ctx` here is safe. - return Err(match err { - InitError::InvalidCRL => crate::Error::InvalidCRL, - InitError::FailedToOpenSocket - | InitError::InvalidCA - | InitError::InvalidCAFile - | InitError::LoadCAFile => crate::Error::FailedToOpenSocket, - }); - } - - let now = self.timer_read(); + let ctx = self.custom_https_context(&tls)?; client.set_custom_ssl_ctx(ctx.clone()); - let _ = custom_ssl_context_map().put( - requested_config, - SslContextCacheEntry { - ctx, - last_used_ns: now, - // Strong ref for the cache entry; client.tls_props keeps its own. - _config_ref: tls, - }, - ); - - // Enforce max cache size - evict oldest entry - if custom_ssl_context_map().count() > SSL_CONTEXT_CACHE_MAX_SIZE { - evict_oldest_ssl_context(); - } - // Note: NewHttpContext == NewHttpContext here (IS_SSL branch). - return Self::dial(custom_context, client) + return Self::dial(https_context_mut(&ctx), client) .map(|o| o.map(|s| s.cast_ssl::())); } } @@ -503,8 +537,6 @@ impl HttpThread { } } let (hn, pt) = (client.url.hostname, client.url.get_port_auto()); - // The hostname is passed to the socket layer as a C string, so it must not contain a NUL. - crate::validate_request_target(hn)?; ctx.connect(client, hn, pt) } @@ -523,16 +555,11 @@ impl HttpThread { } } - fn abort_pending_h2_waiter(&mut self, async_http_id: u32) -> bool { - if self.https_context.abort_pending_h2_waiter(async_http_id) { - return true; - } - for entry in custom_ssl_context_map().values_mut() { - if entry.ctx_mut().abort_pending_h2_waiter(async_http_id) { - return true; - } - } - false + /// The client of a started request whose final result hasn't gone out. + fn request(&self, async_http_id: u32) -> Option>> { + let http = *self.in_flight.get(&async_http_id)?; + // SAFETY: `in_flight` holds live boxes, and the projection keeps the allocation's own provenance. + Some(unsafe { NonNull::new_unchecked(&raw mut (*http.as_ptr()).async_http.client) }) } fn drain_queued_shutdowns(&mut self) { @@ -545,58 +572,10 @@ impl HttpThread { }; for http in &queued_shutdowns { - let tracker = abort_tracker(); - let found_idx = tracker.keys().iter().position(|&k| k == http.async_http_id); - if let Some(idx) = found_idx { - let (_k, socket_ptr) = tracker.swap_remove_at(idx); - match socket_ptr { - uws::AnySocket::SocketTls(socket) => { - let tagged = HTTPContext::::get_tagged_from_socket(socket); - if let Some(client) = tagged.client_mut() { - // If we only call socket.close(), then it won't - // call `onClose` if this happens before `onOpen` is - // called. - client.close_and_abort::(socket); - continue; - } - if let Some(session) = tagged.session() { - h2::ClientSession::abort_by_http_id(session, http.async_http_id); - continue; - } - socket.close(uws::CloseKind::Failure); - } - uws::AnySocket::SocketTcp(socket) => { - let tagged = HTTPContext::::get_tagged_from_socket(socket); - if let Some(client) = tagged.client_mut() { - client.close_and_abort::(socket); - continue; - } - if let Some(session) = tagged.session() { - h2::ClientSession::abort_by_http_id(session, http.async_http_id); - continue; - } - socket.close(uws::CloseKind::Failure); - } - } - } else { - // No socket for this id. It may be a request coalesced onto a - // leader's in-flight h2 TLS connect (parked in `pc.waiters` - // with no abort-tracker entry); scan those first so the abort - // doesn't wait for the leader's connect to resolve. - if self.abort_pending_h2_waiter(http.async_http_id) { - continue; - } - // Or it's on an HTTP/3 session, which has no TCP socket to - // register in the tracker. - if h3::ClientContext::abort_by_http_id(http.async_http_id) { - continue; - } - // Otherwise the request either hasn't started yet (still in - // `queued_tasks`/`deferred_tasks`) or has already completed. - // Flag it so `drainEvents` knows to scan the queue for - // aborted-but-unstarted tasks even when `active >= max` - // would otherwise short-circuit. - self.has_pending_queued_abort = true; + match self.request(http.async_http_id) { + Some(client) => HttpClient::abort(client), + // Still in `queued_tasks` / `deferred_tasks`, or already done. `drain_events` scans those even when `active >= max`. + None => self.has_pending_queued_abort = true, } } let len = queued_shutdowns.len(); @@ -615,45 +594,8 @@ impl HttpThread { core::mem::take(&mut self.queued_writes) }; for write in &queued_writes { - let message = write.kind; - - if let Some(socket_ptr) = abort_tracker().get(&write.async_http_id) { - match *socket_ptr { - uws::AnySocket::SocketTls(socket) => { - if socket.is_closed() || socket.is_shutdown() { - continue; - } - let tagged = HTTPContext::::get_tagged_from_socket(socket); - if let Some(client) = tagged.client_mut() { - client.on_request_stream_message::(message, socket); - } - if let Some(session) = tagged.session() { - h2::ClientSession::stream_body_by_http_id( - session, - write.async_http_id, - message, - ); - } - } - uws::AnySocket::SocketTcp(socket) => { - if socket.is_closed() || socket.is_shutdown() { - continue; - } - let tagged = HTTPContext::::get_tagged_from_socket(socket); - if let Some(client) = tagged.client_mut() { - client.on_request_stream_message::(message, socket); - } - if let Some(session) = tagged.session() { - h2::ClientSession::stream_body_by_http_id( - session, - write.async_http_id, - message, - ); - } - } - } - } else { - h3::ClientContext::stream_body_by_http_id(write.async_http_id, message); + if let Some(client) = self.request(write.async_http_id) { + HttpClient::request_stream_message(client, write.kind); } } let len = queued_writes.len(); @@ -672,33 +614,8 @@ impl HttpThread { core::mem::take(&mut self.queued_cert_check_resumes) }; for resume in &queued_cert_check_resumes { - // Both arms are required: an HTTPS target behind a plaintext - // proxy parks behind a SocketTcp tracker entry. - if let Some(socket_ptr) = abort_tracker().get(&resume.async_http_id) { - match *socket_ptr { - uws::AnySocket::SocketTls(socket) => { - if socket.is_closed() || socket.is_shutdown() { - continue; - } - let tagged = HTTPContext::::get_tagged_from_socket(socket); - if let Some(client) = tagged.client_mut() { - // May synchronously reach close_and_fail → - // dispatch_result_and_reset; do not touch - // `client` after this call. - client.resume_after_cert_check::(socket); - } - } - uws::AnySocket::SocketTcp(socket) => { - if socket.is_closed() || socket.is_shutdown() { - continue; - } - let tagged = HTTPContext::::get_tagged_from_socket(socket); - if let Some(client) = tagged.client_mut() { - // See Tls arm. - client.resume_after_cert_check::(socket); - } - } - } + if let Some(client) = self.request(resume.async_http_id) { + HttpClient::cert_check_passed(client); } } let len = queued_cert_check_resumes.len(); @@ -724,8 +641,12 @@ impl HttpThread { for pool_id in queued { self.http_context.close_idle_sockets(pool_id); self.https_context.close_idle_sockets(pool_id); - for entry in custom_ssl_context_map().values_mut() { - entry.ctx_mut().close_idle_sockets(pool_id); + for ctx in custom_ssl_context_map() + .values() + .iter() + .flat_map(|e| &e.tcp) + { + https_context_mut(ctx).close_idle_sockets(pool_id); } h3::ClientContext::close_idle_sessions(pool_id); } @@ -742,39 +663,8 @@ impl HttpThread { return; } for id in queued { - if let Some(socket_ptr) = abort_tracker().get(&id) { - match *socket_ptr { - uws::AnySocket::SocketTls(socket) => { - let tagged = HTTPContext::::get_tagged_from_socket(socket); - if let Some(client) = tagged.client_mut() { - client.resume_receive::(socket); - client.drain_response_body::(socket); - } - if let Some(session) = tagged.session() { - // The resume may tear the session down and - // release the socket's ref; hold one across - // the second call. - let _guard = RefPtr::from_this(session); - h2::ClientSession::resume_receive_by_http_id(session, id); - h2::ClientSession::drain_response_body_by_http_id(session, id); - } - } - uws::AnySocket::SocketTcp(socket) => { - let tagged = HTTPContext::::get_tagged_from_socket(socket); - if let Some(client) = tagged.client_mut() { - client.resume_receive::(socket); - client.drain_response_body::(socket); - } - if let Some(session) = tagged.session() { - // See the Tls arm. - let _guard = RefPtr::from_this(session); - h2::ClientSession::resume_receive_by_http_id(session, id); - h2::ClientSession::drain_response_body_by_http_id(session, id); - } - } - } - } else { - h3::ClientContext::resume_receive_by_http_id(id); + if let Some(client) = self.request(id) { + HttpClient::receive_resumed(client); } } } @@ -801,13 +691,32 @@ impl HttpThread { self.deferred_tasks.insert(first_that_yields, http); } + /// Every slot is taken. A response that was dropped with its body unread holds one until a garbage collection finds it, and an idle heap is rarely collected. + #[cold] + fn ask_for_slots(&mut self, max: usize) { + if self.deferred_tasks.is_empty() && self.queued_tasks.is_empty() { + return; + } + // Asking again is worth it once the program has run: it has when it made more requests. + let created = crate::ASYNC_HTTP_ID_MONOTONIC.load(Ordering::Relaxed); + if (created.wrapping_sub(self.slots_asked_at) as usize) < (max / 4).max(1) { + return; + } + self.slots_asked_at = created; + for &id in self.in_flight.keys() { + if let Some(client) = self.request(id) { + HttpClient::report_slot_wanted(client); + } + } + } + pub(crate) fn drain_events(&mut self) { // Process any pending writes **before** aborting. self.drain_queued_receive_resumes(); self.drain_queued_writes(); self.drain_queued_shutdowns(); // After shutdowns: an abort or cert-rejection scheduled in the same JS - // turn removes the abort-tracker entry first, so the resume becomes a + // turn ends the request first, so the resume becomes a // no-op and the request is never transmitted after a same-tick abort. self.drain_queued_cert_check_resumes(); self.drain_queued_pool_closes(); @@ -822,10 +731,11 @@ impl HttpThread { // Fast path: at capacity and no queued/deferred task could possibly be // aborted. A queued task can only become aborted via `scheduleShutdown`, // which we just drained — `drainQueuedShutdowns` sets - // `has_pending_queued_abort` for any id it couldn't find in the socket + // `has_pending_queued_abort` for any id it couldn't find in the `in_flight` // tracker. If that's clear, there's nothing to fail-fast and nothing can // start, so don't walk the lists. if active >= max && !self.has_pending_queued_abort { + self.ask_for_slots(max); return; } @@ -1016,7 +926,7 @@ impl HttpThread { while let Some(http) = NonNull::new(self.queued_tasks.pop()) { release_unstarted(http); } - for nn in core::mem::take(&mut self.in_flight) { + for &nn in core::mem::take(&mut self.in_flight).values() { // SAFETY: every entry is the `heap::release` allocation pushed by // `start_queued_task`; HTTP-thread-only and removed at the // callback dealloc site, so each is still live and uniquely @@ -1036,7 +946,7 @@ impl HttpThread { drop(core::mem::take(&mut client.prev_redirect)); drop(core::mem::take(&mut client.compressed_request_body)); drop(core::mem::take(&mut client.proxy_authorization)); - client.close_proxy_tunnel(false); + drop(core::mem::take(&mut client.proxy_tunnel)); drop(core::mem::take(&mut client.custom_ssl_ctx)); drop(core::mem::take(&mut client.state)); if let Some(f) = release.release_at_shutdown { @@ -1128,7 +1038,7 @@ fn evict_oldest_ssl_context() { fn start_queued_task( http: *mut AsyncHttp, - in_flight: &mut Vec>>, + in_flight: &mut ArrayHashMap>>, ) { // SAFETY: http points to a live AsyncHttp queued by the caller thread. let cloned = crate::ThreadlocalAsyncHttp::new(unsafe { core::ptr::read(http) }); @@ -1141,10 +1051,13 @@ fn start_queued_task( // reborrow would be frozen by the first of them. let cloned_ptr = bun_core::heap::into_raw(cloned); let cloned_nn = NonNull::new(cloned_ptr).expect("freshly leaked Box is non-null"); - in_flight.push(cloned_nn.cast::>()); // SAFETY: freshly leaked; this thread is its sole owner until the request // completes and `in_flight` gives the pointer back. let cloned = unsafe { &mut *cloned_ptr }; + let _ = in_flight.put( + cloned.async_http.async_http_id, + cloned_nn.cast::>(), + ); cloned.async_http.real = NonNull::new(http); // Clear stale queue pointers - the clone inherited http.next and http.task.node.next // which may point to other AsyncHTTP structs that could be freed before the callback @@ -1155,24 +1068,20 @@ fn start_queued_task( cloned.async_http.on_start(); } -/// Borrow the HTTP-thread abort tracker. PORTING.md §Global mutable state: -/// HTTP-thread-only, per-statement reborrow. +/// Debug+ASAN invariant check: the socket a request records as its carrier is live (unfreed) memory. #[inline] -fn abort_tracker() -> &'static mut ArrayHashMap { - crate::abort_tracker() -} - -/// Debug+ASAN invariant check: every socket pointer in the abort tracker must -/// point at live (unfreed) memory. A stale entry here means some socket-close -/// path forgot `unregister_abort_tracker`, which later manifests as a -/// use-after-free in `drain_queued_shutdowns`/`drain_queued_writes` when the -/// JS thread aborts that request id. Runs before and after each loop tick so -/// the report fires at the tick that leaked, not at the eventual abort. -#[inline] -fn assert_abort_tracker_sockets_alive() { +fn assert_request_sockets_alive(thread: &HttpThread) { if cfg!(debug_assertions) { - for socket in abort_tracker().values() { - if let Some(usocket) = socket.socket().get() { + for &id in thread.in_flight.keys() { + let Some(client) = thread.request(id) else { + continue; + }; + let socket = match bun_ptr::ParentRef::from(client).carrier { + crate::Carrier::Tcp(socket) => socket.socket, + crate::Carrier::Tls(socket) => socket.socket, + _ => continue, + }; + if let Some(usocket) = socket.get() { bun_core::asan::assert_unpoisoned(usocket); } } @@ -1256,16 +1165,10 @@ mod _event_loop_draft { fn on_start(opts: InitOpts) { Output::Source::configure_named_thread(bun_core::zstr!("HTTP Client")); - // Normalising once here (see `normalize_idle_timeout_seconds`) keeps - // the h1 (`HTTPClient::set_timeout`) and h2 - // (`ClientSession::rearm_timeout`) paths identical without duplicating - // the math at each call site. crate::IDLE_TIMEOUT_SECONDS.store( - crate::normalize_idle_timeout_seconds( - bun_core::env_var::BUN_CONFIG_HTTP_IDLE_TIMEOUT - .get() - .unwrap_or(300), - ), + bun_core::env_var::BUN_CONFIG_HTTP_IDLE_TIMEOUT + .get() + .map_or(300, |seconds| u32::try_from(seconds).unwrap_or(u32::MAX)), core::sync::atomic::Ordering::Relaxed, ); @@ -1362,7 +1265,7 @@ mod _event_loop_draft { } self.drain_events(); self.free_dead_ssl_contexts(); - assert_abort_tracker_sockets_alive(); + assert_request_sockets_alive(self); Output::flush(); let uws_loop = self.uws_loop_ref(); @@ -1378,7 +1281,7 @@ mod _event_loop_draft { // to this `MiniEventLoop` is live here (`uws_loop` re-derived // per iteration, last used above). unsafe { (*self.loop_.cast_mut()).on_after_event_loop() }; - assert_abort_tracker_sockets_alive(); + assert_request_sockets_alive(self); if cfg!(debug_assertions) { Output::flush(); @@ -1454,9 +1357,6 @@ pub fn shutdown_for_exit() -> bool { true } -// dispatch_deps bridge removed — real impls now live in -// h3_client/ClientContext.rs (abort_by_http_id / stream_body_by_http_id). - /// Module-level bridge for `HTTPThread::init`. The real body lives in /// `_event_loop_draft` below (depends on `bun_event_loop::MiniEventLoop`, /// which is outside this crate's dep set). Call sites in AsyncHTTP.rs hit diff --git a/src/http/InternalState.rs b/src/http/InternalState.rs index d2c6cb245acf..ea555aefcb79 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_ms: 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 @@ -54,8 +55,8 @@ pub struct InternalState<'a> { pub(crate) original_request_body: HTTPRequestBody<'a>, pub(crate) request_sent_len: usize, pub(crate) fail: Option, - /// `errno` of the failed `connect(2)` when `fail` is `ConnectionRefused`; 0 otherwise. - pub(crate) connect_errno: i32, + /// `errno` of the failed `connect(2)` when `fail` is `ConnectionRefused`. + pub(crate) connect_errno: Option, /// Raw `getaddrinfo(3)` return code when `fail` is `DNSResolveFailed`; /// 0 otherwise. The JS side turns it into the resolver error /// (`ENOTFOUND`, ...) with `syscall`/`hostname`, matching `node:dns`. @@ -77,8 +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, /// Set when the TLS handshake completed but the user-supplied JS @@ -96,8 +95,10 @@ pub struct InternalStateFlags { /// `reset()`/`init()` so each redirect/retry hop re-compresses from the /// original uncompressed `original_request_body`. pub(crate) body_compressed: bool, - /// Held input or buffered decoder output remains for `HTTPClient::drain_response_body`. + /// Held input or buffered decoder output remains for `HTTPClient::send_held_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,14 +107,13 @@ 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, is_waiting_for_cert_check: false, receive_paused: false, body_compressed: false, decompress_output_pending: false, + has_body_ahead_of_failure: false, } } } @@ -126,7 +126,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,12 +133,13 @@ impl Default for InternalState<'_> { compressed_body: MutableString::init_empty(), compressed_body_consumed: 0, content_length: None, + keepalive_reuse_ms: None, total_body_received: 0, request_body: bun_ptr::RawSlice::EMPTY, original_request_body: HTTPRequestBody::Bytes(b""), request_sent_len: 0, fail: None, - connect_errno: 0, + connect_errno: None, dns_error: 0, dns_hostname: None, request_stage: HTTPStage::Pending, @@ -189,6 +189,15 @@ impl<'a> InternalState<'a> { }; } + /// Lets go of the body as it stands: input that isn't decoded yet, the decoder, and output that isn't reported yet. + pub(crate) fn drop_body(&mut self) { + self.compressed_body = MutableString::init_empty(); + self.compressed_body_consumed = 0; + self.decompressor = Decompressor::None; + self.decoded_body = MutableString::init_empty(); + self.flags.decompress_output_pending = false; + } + /// The buffer response body bytes accumulate into. For compressed /// responses this is the intermediate `compressed_body`; otherwise it is /// the owned `decoded_body`. @@ -238,7 +247,6 @@ impl<'a> InternalState<'a> { bun_core::feature_flags::is_libdeflate_enabled() && self.encoding == Encoding::Gzip && !self.flags.is_libdeflate_fast_path_disabled - && !self.flags.is_redirect_pending && matches!(self.decompressor, Decompressor::None) && self.is_done() && gzip_trailer_size(&self.compressed_body.list).is_some_and(|size| { @@ -251,29 +259,12 @@ impl<'a> InternalState<'a> { /// than failing it: chunked decoder already in the trailers state, or a /// close-delimited response (no Content-Length, no Transfer-Encoding). pub(crate) fn is_body_complete_on_close(&self) -> bool { - // Every byte arrived; only the decode is outstanding. - if self.flags.decompress_output_pending && self.is_done() { - return true; - } if self.is_chunked_encoding() { return bun_picohttp::phr_decode_chunked_is_in_trailers(&self.chunked_decoder) != 0; } self.content_length.is_none() && self.response_stage == HTTPStage::Body } - /// Mark the body complete and drive `process_body_buffer` one last time - /// with `is_final_chunk = true` so a compressed stream that never reached - /// stream-end is rejected. Call from every site that flips - /// `received_last_chunk` on an end-of-body signal that arrives with no - /// accompanying body bytes (h1 FIN, proxy-tunnel close, h2/h3 END_STREAM). - /// No-op for complete, empty, or uncompressed bodies. - pub(crate) fn finalize_body_on_eof(&mut self) -> Result<(), Error> { - self.flags.received_last_chunk = true; - let buffer_snap = core::mem::take(&mut self.get_body_buffer().list); - self.process_body_buffer(buffer_snap, true, usize::MAX) - .map(drop) - } - pub(crate) fn decompress_bytes( &mut self, buffer: &[u8], @@ -441,13 +432,6 @@ impl<'a> InternalState<'a> { is_final_chunk: bool, max_output: usize, ) -> Result { - if self.flags.is_redirect_pending { - // Caller moved the bytes out of the body buffer; put them back so the - // take is a no-op. - self.get_body_buffer().list = buffer; - return Ok(false); - } - match self.encoding { Encoding::Brotli | Encoding::Gzip | Encoding::Deflate | Encoding::Zstd => { let start = self.compressed_body_consumed; diff --git a/src/http/ProxyTunnel.rs b/src/http/ProxyTunnel.rs index b6ea15bf7be0..375d4b8b11d1 100644 --- a/src/http/ProxyTunnel.rs +++ b/src/http/ProxyTunnel.rs @@ -1,6 +1,6 @@ use core::cell::Cell; -use core::ptr::{NonNull, addr_of, addr_of_mut}; -use core::sync::atomic::Ordering; +use core::ptr::{NonNull, addr_of}; +use core::sync::atomic::{AtomicU32, Ordering}; use crate::Error; use bun_core::scoped_log; @@ -10,9 +10,8 @@ use bun_uws as uws; use crate::http_cert_error::HTTPCertError; use crate::http_context::{HTTPSocket, PeerVerification}; use crate::internal_state::{HTTPStage, Stage}; -use crate::ssl_config::SSLConfig; -use crate::ssl_wrapper::{Handlers as SSLWrapperHandlers, InitError, SSLWrapper, WriteDataError}; -use crate::{AlpnOffer, HTTPClient}; +use crate::ssl_wrapper::{Handlers as SSLWrapperHandlers, SSLWrapper, WriteDataError}; +use crate::{AlpnOffer, Carrier, HTTPClient}; bun_core::declare_scope!(http_proxy_tunnel, visible); @@ -33,18 +32,9 @@ pub(crate) fn raw_as_mut<'a>(ptr: *mut ProxyTunnel) -> &'a mut ProxyTunnel { type ProxyTunnelWrapper = SSLWrapper<*mut HTTPClient<'static>>; -/// active socket is the socket that is currently being used -// `HTTPSocket` = `uws::SocketHandler` = `NewSocketHandler`, so the -// canonical 3-arm enum lives in `bun_uws` next to its payload type. -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, /// Property of the inner TLS session, not the owning client. Captured from /// the client in detachOwner() and restored to the next client in adopt() /// so the pool's did_have_handshaking_error_while_reject_unauthorized_is_false @@ -60,13 +50,20 @@ pub struct ProxyTunnel { pub(crate) ref_count: Cell, } +/// For the leak test in proxy-stress-concurrent.test.ts, which reads it from the JS thread. +pub static LIVE_TUNNELS: AtomicU32 = AtomicU32::new(0); + +impl Drop for ProxyTunnel { + fn drop(&mut self) { + LIVE_TUNNELS.fetch_sub(1, Ordering::Relaxed); + } +} + impl Default for ProxyTunnel { fn default() -> Self { + LIVE_TUNNELS.fetch_add(1, Ordering::Relaxed); Self { wrapper: None, - shutdown_err: Cell::new(crate::Error::ConnectionClosed), - socket: Socket::None, - write_buffer: bun_io::StreamBuffer::default(), did_have_handshaking_error: false, verification: PeerVerification::None, ref_count: Cell::new(1), @@ -74,14 +71,6 @@ impl Default for ProxyTunnel { } } -impl Drop for ProxyTunnel { - fn drop(&mut self) { - // `wrapper` / `write_buffer` are handled by their own Drop impls; - // just clear the socket tag. - self.socket = Socket::None; - } -} - // ─── intrusive refcount: derived via #[derive(CellRefCounted)] above ───────── // ─── raw-pointer field accessors ───────────────────────────────────────────── @@ -91,49 +80,6 @@ impl Drop for ProxyTunnel { // the in-flight `&SSLWrapper` overlaps), so the returned borrow does not // alias the caller. See the ALIASING NOTE below for the full invariant. impl ProxyTunnel { - /// Read-only access to `socket` (disjoint from `wrapper`). - #[inline] - fn socket_of<'a>(this: NonNull) -> &'a Socket { - // SAFETY: `this` is a live intrusive-refcounted tunnel; `socket` is - // disjoint from `wrapper`. HTTP-thread-only. - unsafe { &*addr_of!((*this.as_ptr()).socket) } - } - - /// Overwrite `socket` (disjoint from `wrapper`). - #[inline] - fn set_socket(this: NonNull, s: Socket) { - // SAFETY: see [`Self::socket_of`]. - unsafe { *addr_of_mut!((*this.as_ptr()).socket) = s }; - } - - /// Mutable access to `write_buffer` (disjoint from `wrapper`). - #[inline] - fn write_buffer_of<'a>(this: NonNull) -> &'a mut bun_io::StreamBuffer { - // SAFETY: see [`Self::socket_of`]. - 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 +120,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 +195,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,17 +219,15 @@ 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; } }; if report_progress { // `this` dead (NLL); reborrow via `client_from_ctx` inside. - progress_update_for_proxy_socket(ctx, proxy_nn); + progress_update_for_proxy_socket(ctx); return; } } @@ -295,40 +239,30 @@ 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; } }; if report_progress { // `this` dead (NLL); see Body arm. - progress_update_for_proxy_socket(ctx, proxy_nn); + progress_update_for_proxy_socket(ctx); return; } } HTTPStage::ProxyHeaders => { scoped_log!(http_proxy_tunnel, "ProxyTunnel onData proxy_headers"); - // `hctx` is the pool the finished tunnel is released into. It must - // be the context that owns the outer socket (the per-config custom - // context when `tls` needs one, see `HTTPThread::connect`), which - // is also the only pool the next request with that config searches. - match ProxyTunnel::socket_of(proxy_nn) { - &Socket::Ssl(socket) => { - let hctx = this.get_ssl_ctx::(); - this.handle_on_data_headers::(decoded_data, hctx, socket); - } - &Socket::Tcp(socket) => { - let hctx = this.get_ssl_ctx::(); - this.handle_on_data_headers::(decoded_data, hctx, socket); - } - Socket::None => {} + match this.carrier { + Carrier::Tls(socket) => this.handle_on_data_headers::(decoded_data, socket), + Carrier::Tcp(socket) => this.handle_on_data_headers::(decoded_data, socket), + _ => {} } } _ => { 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 +279,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 +295,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 { @@ -381,8 +314,8 @@ fn on_handshake( // SAFETY: `ssl_ptr` is the live SSL handle from the tunnel's // SSLWrapper for the open inner TLS connection (NonNull invariant). let ssl = unsafe { &mut *ssl_ptr.as_ptr() }; - match ProxyTunnel::socket_of(proxy_nn) { - &Socket::Ssl(socket) => { + match this.carrier { + Carrier::Tls(socket) => { if !this.check_server_identity::(socket, ssl, false) { scoped_log!( http_proxy_tunnel, @@ -395,17 +328,17 @@ fn on_handshake( return; } } - &Socket::Tcp(socket) => { + Carrier::Tcp(socket) => { if !this.check_server_identity::(socket, ssl, false) { scoped_log!( http_proxy_tunnel, "ProxyTunnel onHandshake checkServerIdentity failed" ); - // see Ssl arm — `this` may be freed here. + // see Tls arm — `this` may be freed here. return; } } - Socket::None => {} + _ => {} } } @@ -414,14 +347,10 @@ fn on_handshake( // socket out first, then let `this` (NLL) end before the call so the // reentrant `&mut *ctx` inside write_encrypted does not alias. // `client_from_ctx` re-derives the fresh sole `&mut HTTPClient`. - match ProxyTunnel::socket_of(proxy_nn) { - &Socket::Ssl(socket) => { - client_from_ctx(ctx).on_writable::(socket); - } - &Socket::Tcp(socket) => { - client_from_ctx(ctx).on_writable::(socket); - } - Socket::None => {} + match this.carrier { + Carrier::Tls(socket) => client_from_ctx(ctx).on_writable::(socket), + Carrier::Tcp(socket) => client_from_ctx(ctx).on_writable::(socket), + _ => {} } } else { scoped_log!(http_proxy_tunnel, "ProxyTunnel onHandshake failed"); @@ -440,56 +369,32 @@ 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; } } -pub(crate) fn write_encrypted(ctx: *mut HTTPClient, encoded_data: &[u8]) { +pub(crate) fn write_encrypted(ctx: *mut HTTPClient, encoded_data: &[u8]) -> usize { // write_encrypted is fired from inside SSLWrapper::flush/handle_traffic; // the call chains that reach here (e.g. on_handshake → on_writable → flush, // on_open → flush) each NLL-end their `client_from_ctx`/`*ctx` borrow // before reentering, so there is NO live `&mut HTTPClient` anywhere up the // stack — re-deriving via the centralised `client_from_ctx` accessor here - // is the sole live borrow, dropped immediately after copying out the - // tunnel pointer. The pointee is alive: this client holds a - // strong ref to the tunnel for the duration of tunneling. - let Some(proxy_nn) = client_from_ctx(ctx).proxy_tunnel_ptr() else { - return; - }; - // Live intrusive-refcounted tunnel. Access `write_buffer` and `socket` via - // disjoint field accessors only — never form `&mut ProxyTunnel`, because - // the caller (flush/handle_traffic) holds `&SSLWrapper` which IS - // `(*proxy).wrapper`; a whole-struct `&mut` would overlap it. - let write_buffer = ProxyTunnel::write_buffer_of(proxy_nn); - // Preserve TLS record ordering: if any encrypted bytes are buffered, - // enqueue new bytes and flush them in FIFO via onWritable. - if write_buffer.is_not_empty() { - if write_buffer.write(encoded_data).is_err() { - bun_core::out_of_memory(); - } - return; + // is the sole live borrow, dropped immediately after copying out the carrier. + let client = client_from_ctx(ctx); + if client.proxy_tunnel.is_none() { + return encoded_data.len(); } - let written = match ProxyTunnel::socket_of(proxy_nn) { - &Socket::Ssl(socket) => socket.write(encoded_data), - &Socket::Tcp(socket) => socket.write(encoded_data), - Socket::None => 0, + let written = match client.carrier { + Carrier::Tls(socket) => socket.write(encoded_data), + Carrier::Tcp(socket) => socket.write(encoded_data), + _ => 0, }; - let pending = &encoded_data[usize::try_from(written).expect("int cast")..]; - if !pending.is_empty() { - // lets flush when we are truly writable - if write_buffer.write(pending).is_err() { - bun_core::out_of_memory(); - } - } + usize::try_from(written).expect("int cast") } 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 +405,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; }; @@ -509,15 +415,13 @@ fn on_close(ctx: *mut HTTPClient) { // If a response is in progress, mirror HTTPClient.onClose semantics: // treat connection close as end-of-body for identity transfer when no content-length. - let in_progress = this.state.stage != Stage::Done - && this.state.stage != Stage::Fail - && !this.state.flags.is_redirect_pending; + let in_progress = this.state.stage != Stage::Done && this.state.stage != Stage::Fail; let mut fail_err: Option = None; if in_progress && this.state.is_body_complete_on_close() { - match this.state.finalize_body_on_eof() { + match this.end_body() { Ok(()) => { // `this` dead (NLL); reborrow via `client_from_ctx` inside. - progress_update_for_proxy_socket(ctx, proxy_nn); + progress_update_for_proxy_socket(ctx); crate::http_thread().schedule_proxy_deref(keepalive); return; } @@ -525,45 +429,33 @@ 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 this = client_from_ctx(ctx); + // `close_and_fail` de-tags the outer socket before `fail()` frees the client. + match this.carrier { + Carrier::Tls(socket) => this.close_and_fail::(err, socket), + Carrier::Tcp(socket) => this.close_and_fail::(err, socket), + _ => this.fail(err), } - ProxyTunnel::set_socket(proxy_nn, Socket::None); crate::http_thread().schedule_proxy_deref(keepalive); } -/// `ctx` and `proxy` must be live. Caller must not hold `&mut HTTPClient` or -/// `&mut ProxyTunnel` across this call (they are reborrowed inside via the -/// module's `client_from_ctx` invariant — see ALIASING NOTE above). -fn progress_update_for_proxy_socket(ctx: *mut HTTPClient, proxy: NonNull) { - // Same context rule as the ProxyHeaders arm of `on_data`: the tunnel is - // pooled into whichever context `progress_update` is handed. - match ProxyTunnel::socket_of(proxy) { - &Socket::Ssl(socket) => { - let client = client_from_ctx(ctx); - let hctx = client.get_ssl_ctx::(); - client.progress_update::(hctx, socket); - } - &Socket::Tcp(socket) => { - let client = client_from_ctx(ctx); - let hctx = client.get_ssl_ctx::(); - client.progress_update::(hctx, socket); - } - Socket::None => {} +/// `ctx` must be live, and the caller must not hold `&mut HTTPClient` across this call. +fn progress_update_for_proxy_socket(ctx: *mut HTTPClient) { + let client = client_from_ctx(ctx); + match client.carrier { + Carrier::Tls(socket) => client.progress_update::(socket), + Carrier::Tcp(socket) => client.progress_update::(socket), + _ => {} } } @@ -584,28 +476,25 @@ impl ProxyTunnel { pub(crate) fn start( this: &mut HTTPClient, socket: HTTPSocket, - ssl_options: Option<&SSLConfig>, start_payload: &[u8], ) { - let mut err = uws::create_bun_socket_error_t::none; - let ssl_ctx = match ssl_options { - // We always request the cert so we can verify it and also we manually abort the connection if the hostname doesn't match - Some(ssl_options) => ssl_options - .as_usockets_for_client_verification() - .create_ssl_context(&mut err), - // The context a direct connection uses: it holds the thread's CA options (`bun install --ca`). - None => Some(crate::http_thread().default_ssl_ctx()), - }; - let Some(ssl_ctx) = ssl_ctx else { - // Invalid TLS options; the errors a direct request reports for them. - let error = match err { - uws::create_bun_socket_error_t::invalid_crl => crate::Error::InvalidCRL, - _ => crate::Error::FailedToOpenSocket, - }; - this.close_and_fail::(error, socket); - return; + // The context a direct connection with these options uses. + let ssl_ctx = match crate::http_thread().ssl_ctx_for(this.tls_props.as_ref()) { + Ok(ssl_ctx) => ssl_ctx, + Err(err) => { + this.close_and_fail::(err, socket); + return; + } }; - let wrapper = match ProxyTunnelWrapper::init_with_ctx( + Self::start_with_ctx(this, ssl_ctx, start_payload); + } + + fn start_with_ctx( + this: &mut HTTPClient, + ssl_ctx: bun_boringssl_sys::OwnedSslCtx, + start_payload: &[u8], + ) { + let wrapper = ProxyTunnelWrapper::init_with_ctx( ssl_ctx, true, SSLWrapperHandlers { @@ -614,25 +503,10 @@ impl ProxyTunnel { on_handshake, on_close, write: write_encrypted, - // fetch's proxy tunnel surfaces no 'session'/'keylog' events; - // opting out keeps its SSL off the parked queues entirely. - on_session: None, - on_keylog: None, - server_identity: Some(server_identity), ctx: this.as_erased_ptr().as_ptr(), }, - ) { - Ok(w) => w, - Err(e) => { - if e == InitError::OutOfMemory { - bun_core::out_of_memory(); - } - - // invalid TLS Options - this.close_and_fail::(crate::Error::FailedToOpenSocket, socket); - return; - } - }; + ); + wrapper.set_server_identity_check(server_identity); // The inner connection's form of the `set_inline_reject` call in `HTTPClient::configure_tls`. if this.flags.reject_unauthorized { wrapper.set_inline_reject(); @@ -642,7 +516,6 @@ impl ProxyTunnel { // hand-off to the keep-alive pool. let mut tunnel = ProxyTunnel::default(); tunnel.wrapper = Some(wrapper); - tunnel.socket = Socket::from_generic::(socket); let proxy_tunnel = RefPtr::new(tunnel); let proxy_ptr = proxy_tunnel.as_ptr(); this.proxy_tunnel = Some(proxy_tunnel); @@ -661,29 +534,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. @@ -697,33 +547,13 @@ impl ProxyTunnel { /// handle (see [`Self::ref_guard`]): the flush below can complete or fail /// the request, which releases that handle, leaving the guard's ref as the /// last one. A `&mut self` receiver would then be freed while still live. - pub(crate) fn on_writable(this: NonNull, socket: HTTPSocket) { + pub(crate) fn on_writable(this: NonNull) { scoped_log!(http_proxy_tunnel, "ProxyTunnel onWritable"); let _guard = Self::ref_guard(this); - // flush() must run AFTER the body but BEFORE the guard's deref; that - // order is written out explicitly at the single exit below. - // flush() → handle_traffic → write_encrypted reenters and touches - // `write_buffer`/`socket` via raw projection; we must not hold a - // `&mut ProxyTunnel` (or any borrow overlapping those fields) across it. - { - let write_buffer = ProxyTunnel::write_buffer_of(this); - let encoded_data = write_buffer.slice(); - if !encoded_data.is_empty() { - let written = socket.write(encoded_data); - let written = usize::try_from(written).expect("int cast"); - if written == encoded_data.len() { - write_buffer.reset(); - } else { - write_buffer.cursor += written; - } - } - } // drop &mut write_buffer before flush() reborrows it inside write_encrypted - // Refcount > 0 until _guard drops. The reentrant write_encrypted - // touches only `write_buffer`/`socket` via accessors, disjoint from - // the `&wrapper` returned by `wrapper_ref`. + // The reentrant write_encrypted touches no field of the tunnel, so it can't alias the `&wrapper` from `wrapper_ref`. if let Some(wrapper) = ProxyTunnel::wrapper_ref(this.as_ptr()) { - // Cycle to through the SSL state machine - let _ = wrapper.flush(); + // Sends what the socket refused, then cycles through the SSL state machine. + wrapper.flush(); } // _guard derefs here. } @@ -755,20 +585,11 @@ impl ProxyTunnel { Err(crate::Error::ConnectionClosed) } - /// Forget the outer socket. Holders call this before dropping their - /// `RefPtr`, so a tunnel that outlives them (a deferred `on_close` deref - /// is still pending) never retains a dangling socket. - #[inline] - pub(crate) fn detach_socket(&mut self) { - self.socket = Socket::None; - } - /// Detach the tunnel from its current HTTPClient owner so it can be safely /// pooled for keepalive. The inner TLS session is preserved. The tunnel's /// refcount is NOT changed — the caller must ensure the ref is transferred /// to the pool (or dereffed on failure to pool). pub(crate) fn detach_owner(&mut self, client: &HTTPClient) { - self.socket = Socket::None; // Capture the handshaking-error flag from the client — this is a property // of the inner TLS session, not the client. adopt() restores it to the // next client so re-pooling doesn't erase it. @@ -779,28 +600,17 @@ impl ProxyTunnel { self.verification = self.verification.max(client.target_verification()); // We intentionally leave wrapper.handlers.ctx stale here. The tunnel is // idle in the pool and no callbacks will fire until adopt() reattaches - // a new owner and socket. + // a new owner. } - /// Reattach a pooled tunnel to a new HTTPClient and socket. The TLS session + /// Reattach a pooled tunnel to a new HTTPClient. The TLS session /// is reused as-is — no CONNECT and no new TLS handshake. The client's /// request/response stage is set to .proxy_headers so the next onWritable /// writes the HTTP request directly into the tunnel. /// /// `tunnel` is the pool's handle; it moves into `client.proxy_tunnel` as /// is, so the ref the client later releases is the one the pool held. - pub(crate) fn adopt( - tunnel: RefPtr, - client: &mut HTTPClient, - socket: HTTPSocket, - ) { - raw_as_mut(tunnel.as_ptr()).socket = Socket::from_generic::(socket); - Self::adopt_owner(tunnel, client); - } - - /// `adopt` without the socket, so it is compiled once for both `IS_SSL`. - #[inline(never)] - fn adopt_owner(tunnel: RefPtr, client: &mut HTTPClient) { + pub(crate) fn adopt(tunnel: RefPtr, client: &mut HTTPClient) { scoped_log!( http_proxy_tunnel, "ProxyTunnel adopt (reusing pooled tunnel)" @@ -809,11 +619,6 @@ impl ProxyTunnel { // writes; nothing re-enters the tunnel until the client's next socket // event, after this borrow has ended. let this = raw_as_mut(tunnel.as_ptr()); - // Discard any stale encrypted bytes from the previous request. A clean - // request boundary should leave this empty, but an early server response - // (e.g. HTTP 413) with Connection: keep-alive before the full body was - // consumed could leave unsent bytes that would corrupt the next request. - this.write_buffer.reset(); if let Some(wrapper) = &this.wrapper { let mut handlers = wrapper.handlers.get(); handlers.ctx = client.as_erased_ptr().as_ptr(); diff --git a/src/http/SendFile.rs b/src/http/SendFile.rs index 8130a8106525..94a2af66d34a 100644 --- a/src/http/SendFile.rs +++ b/src/http/SendFile.rs @@ -1,7 +1,10 @@ -use bun_core::feature_flags; +use bun_core::{RacyCell, feature_flags}; use bun_sys::{self, Fd}; use bun_url::URL; +// Allocated at the first copy: the executable's zero-filled data is the one mapping that can't opt out of transparent huge pages. +static COPY_BUFFER: RacyCell>> = RacyCell::new(None); + #[derive(Copy, Clone)] pub struct SendFile { pub fd: Fd, @@ -19,6 +22,55 @@ impl SendFile { url.is_http() && url.href.len() > 0 } + /// Moves the cursor back to the first byte so a redirect or a retry can send the body again. + pub(crate) fn rewind(&mut self) { + self.offset -= self.content_size - self.remain; + self.remain = self.content_size; + } + + /// The head promised `content_size` bytes, and the file can have become shorter since it was measured. + fn at_end_of_file(&self) -> Status { + if self.remain == 0 { + Status::Done + } else { + Status::Err(crate::Error::RequestBodyLengthMismatch) + } + } + + /// `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 { + // SAFETY: only the HTTP thread sends requests, and every pass refills the buffer before `send` reads it. + let buf = unsafe { &mut *COPY_BUFFER.get() } + .get_or_insert_with(|| vec![0; 256 * 1024].into_boxed_slice()); + // 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) { + Ok(0) => return self.at_end_of_file(), + 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 { @@ -43,6 +95,10 @@ impl SendFile { self.offset = u64::try_from(signed_offset).expect("int cast") as usize; let errcode = bun_sys::get_errno(val); + // The send buffer is full, for example with this request's own head. + if errcode == bun_sys::E::EAGAIN { + return Status::Again; + } self.remain = (self.remain as u64) .saturating_sub((self.offset as u64).saturating_sub(begin as u64)) @@ -50,7 +106,7 @@ impl SendFile { if errcode != bun_sys::E::SUCCESS || self.remain == 0 || val == 0 { if errcode == bun_sys::E::SUCCESS { - return Status::Done; + return self.at_end_of_file(); } return Status::Err(bun_errno::from_errno(errcode as i32).into()); @@ -76,41 +132,13 @@ impl SendFile { 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 self.at_end_of_file(); } return Status::Err(bun_errno::from_errno(errcode as i32).into()); } } - #[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; - let errcode = bun_sys::get_errno(bun_sys::c::sendfile( - self.fd.native(), - socket_fd.native(), - signed_offset, - &mut sbytes, - 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); } @@ -120,9 +148,7 @@ impl SendFile { } pub(crate) enum Status { - #[cfg(not(windows))] Done, - #[cfg(not(windows))] Err(crate::Error), Again, } diff --git a/src/http/Signals.rs b/src/http/Signals.rs index 57a0696ef9e1..efe8a6e8c618 100644 --- a/src/http/Signals.rs +++ b/src/http/Signals.rs @@ -101,6 +101,27 @@ impl Signals { }) } + /// `Unclaimed` or `Paused`: nobody is taking the body, and perhaps nobody can any more. + #[inline] + pub(crate) fn is_body_unread(self) -> bool { + self.body_receive_mode + .map(bun_ptr::BackRef::from) + .is_some_and(|a| { + matches!( + BodyReceiveMode::from_u8(a.load(Ordering::Acquire)), + BodyReceiveMode::Unclaimed | BodyReceiveMode::Paused + ) + }) + } + + /// `Abandoned`: nobody will read the body. + #[inline] + pub(crate) fn is_body_abandoned(self) -> bool { + self.body_receive_mode + .map(bun_ptr::BackRef::from) + .is_some_and(|a| a.load(Ordering::Acquire) == BodyReceiveMode::Abandoned as u8) + } + /// `Unclaimed -> Paused`: a whole body waits for the consumer, which resumes the transport. #[inline] pub(crate) fn hold_for_consumer(self) -> bool { diff --git a/src/http/error.rs b/src/http/error.rs index 605c2973b979..5cda9af10854 100644 --- a/src/http/error.rs +++ b/src/http/error.rs @@ -67,12 +67,8 @@ pub enum Error { RequestBodyLengthMismatch, #[error("UnsupportedRedirectProtocol")] UnsupportedRedirectProtocol, - #[error("RedirectURLTooLong")] - RedirectURLTooLong, #[error("RedirectURLInvalid")] RedirectURLInvalid, - #[error("InvalidRedirectURL")] - InvalidRedirectURL, #[error("UnexpectedRedirect")] UnexpectedRedirect, #[error("ShortRead")] @@ -299,9 +295,7 @@ impl Error { Self::RequestBodyNotReusable => "RequestBodyNotReusable", Self::RequestBodyLengthMismatch => "RequestBodyLengthMismatch", Self::UnsupportedRedirectProtocol => "UnsupportedRedirectProtocol", - Self::RedirectURLTooLong => "RedirectURLTooLong", Self::RedirectURLInvalid => "RedirectURLInvalid", - Self::InvalidRedirectURL => "InvalidRedirectURL", Self::UnexpectedRedirect => "UnexpectedRedirect", Self::ShortRead => "ShortRead", Self::WantRead => "WantRead", @@ -374,7 +368,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 d523deccb929..8aaa83d2b498 100644 --- a/src/http/h2_client/ClientSession.rs +++ b/src/http/h2_client/ClientSession.rs @@ -9,11 +9,15 @@ use std::time::{Duration, Instant}; use crate::Error; use bun_collections::{ArrayHashMap, VecExt}; use bun_core::strings; +use bun_http_types::h2::flow_control::{DEFAULT_WINDOW_SIZE, RecvWindow, SendWindow}; +use bun_http_types::h2::frame::{ErrorCode, FrameHeader, FrameType, MAX_STREAM_ID}; +use bun_http_types::h2::header_block::HeaderBlockAssembler; +use bun_http_types::h2::settings::DEFAULT_MAX_FRAME_SIZE; +use bun_http_types::h2::stream::State as StreamState; use bun_ptr::RefPtr; -use super::stream::{State as StreamState, Stream}; +use super::stream::Stream; use super::{dispatch, encode}; -use crate::h2_frame_parser as wire; use crate::http_context::{HTTPSocket, PeerVerification}; use crate::http_request_body::HTTPRequestBody; use crate::http_thread::WriteMessageType; @@ -21,7 +25,7 @@ use crate::internal_state::HTTPStage; use crate::lshpack; use crate::signals; use crate::ssl_config; -use crate::{HTTPClient, HTTPVerboseLevel, HeaderResult, NewHTTPContext, Protocol}; +use crate::{Carrier, HTTPClient, HTTPVerboseLevel, NewHTTPContext, Protocol, StreamProgress}; /// HTTP/2 only ever runs over TLS in this client (ALPN "h2"). pub type Socket = HTTPSocket; @@ -32,7 +36,7 @@ const LOCAL_INITIAL_WINDOW_SIZE: u32 = super::LOCAL_INITIAL_WINDOW_SIZE; const LIVENESS_GRACE: Duration = Duration::from_secs(1); /// Cap on the idle timer while a liveness PING is unanswered, unless /// `BUN_CONFIG_HTTP_IDLE_TIMEOUT` is shorter. Go's default `PingTimeout`. -const PING_ACK_TIMEOUT_SECONDS: u64 = 15; +const PING_ACK_TIMEOUT_SECONDS: core::ffi::c_uint = 15; // 31-/24-bit wire fields stored as u32; range asserts at use sites. #[allow(non_camel_case_types)] @@ -57,7 +61,7 @@ pub struct ClientSession { /// (see [`ClientSession::enter`]). socket_ref_owed: Cell, - pub(crate) hpack: lshpack::HpackHandle, // RAII owner; Deref/DerefMut to lshpack::HPACK + pub(crate) hpack: lshpack::Coder, pub(crate) socket: Socket, pub(crate) ctx: *mut NewHTTPContext, // BACKREF: context outlives and registers this session @@ -82,17 +86,9 @@ pub struct ClientSession { pub(crate) read_buffer: Vec, pub(crate) streams: ArrayHashMap, - /// Secondary index over `streams` keyed by the owning client's - /// `async_http_id`, so the per-chunk wakeups from the JS thread - /// (`stream_body_by_http_id` / `resume_receive_by_http_id` / - /// `drain_response_body_by_http_id` / `abort_by_http_id`) resolve in O(1) - /// instead of scanning every live stream on the session. - pub(crate) by_http_id: ArrayHashMap, pub(crate) next_stream_id: u31, - /// Stream id whose CONTINUATION sequence is in progress; 0 = none. - pub(crate) expecting_continuation: u31, - /// CONTINUATION frames seen so far in the current header block. - pub(crate) continuation_count: u8, + /// The inbound HEADERS + CONTINUATION sequence in progress. + pub(crate) header_block: HeaderBlockAssembler, /// Cold-start coalesced requests parked until the server's first SETTINGS /// frame arrives so the real MAX_CONCURRENT_STREAMS cap can be honoured. @@ -101,11 +97,6 @@ pub struct ClientSession { pub(crate) preface_sent: bool, pub(crate) settings_received: bool, pub(crate) goaway_received: bool, - /// Set when the HPACK encoder's dynamic table has diverged from the - /// server's view (writeRequest failed mid-encode). Existing siblings whose - /// HEADERS already went out are unaffected, but no new stream may be - /// opened on this connection. - pub(crate) encoder_poisoned: bool, /// True while onData's deliver loop is running. retryFromH2/doRedirect /// re-dispatch may try to adopt back onto this same session; blocking /// that during delivery prevents `streams` mutation under iteration and @@ -127,31 +118,18 @@ pub struct ClientSession { /// close a connection that sends several PINGs with neither in between: /// https://github.com/golang/go/issues/70575 pub(crate) liveness_ping_spent: bool, - pub(crate) goaway_last_stream_id: u31, pub(crate) fatal_error: Option, - /// HEADERS/CONTINUATION fragments for a stream we no longer track (e.g. - /// in flight when we RST'd it). RFC 9113 §4.3 still requires the block be - /// fed to the HPACK decoder so the connection-level dynamic table stays - /// in sync. - pub(crate) orphan_header_block: Vec, - /// Reused HPACK-encode scratch for `writeRequest` so each request doesn't - /// alloc/free its own header-block buffer. + /// Reused by `write_request`: a request's fields, and their HPACK encoding. + pub(crate) request_fields: lshpack::HeaderBlock, pub(crate) encode_scratch: Vec, pub(crate) remote_max_frame_size: u24, pub(crate) remote_max_concurrent_streams: u32, pub(crate) remote_initial_window_size: u32, - /// SETTINGS_HEADER_TABLE_SIZE received from the peer that hasn't yet been - /// acknowledged with a Dynamic Table Size Update (RFC 7541 §6.3) at the - /// start of a header block. lshpack's encoder doesn't emit that opcode - /// itself, so writeRequest must prepend it before the first encode call. - pub(crate) pending_hpack_enc_capacity: Option, /// Connection-level send window. Starts at the spec default regardless of /// SETTINGS; only WINDOW_UPDATE on stream 0 grows it. - pub(crate) conn_send_window: i32, - - /// DATA bytes consumed since the last connection-level WINDOW_UPDATE. - pub(crate) conn_unacked_bytes: u32, + pub(crate) conn_send_window: SendWindow, + pub(crate) conn_recv_window: RecvWindow, /// Index in the context's active-session list while reachable for /// concurrent attachment; maxInt when not listed. @@ -301,8 +279,10 @@ impl ClientSession { /// Multiplex `client` onto an established (registered or pool-resumed) /// session; see [`Self::adopt_client`]. - pub(crate) fn adopt(this: SessionPtr, client: &mut HTTPClient) { - Self::enter(this, |s| s.adopt_client(client)); + pub(crate) fn adopt(this: SessionPtr, client: &mut HTTPClient) -> bool { + let mut adopted = false; + Self::enter(this, |s| adopted = s.adopt_client(client)); + adopted } /// Open the first stream on a session `create` just returned, for the @@ -312,31 +292,57 @@ impl ClientSession { Self::enter(this, |s| s.attach(client)); } - /// Called from the HTTP thread's shutdown queue when a fetch on this - /// session is aborted; see [`Self::abort_request`]. - pub(crate) fn abort_by_http_id(this: SessionPtr, async_http_id: u32) { - Self::enter(this, |s| s.abort_request(async_http_id)); + /// The session of a stream that a request's `Carrier::H2` names. + fn of_stream(stream: NonNull) -> SessionPtr { + Self::this_ptr(stream_ref(stream.as_ptr()).session) } - /// HTTP-thread wake-up from `scheduleRequestWrite`; see - /// [`Self::stream_request_body`]. - pub(crate) fn stream_body_by_http_id( - this: SessionPtr, - async_http_id: u32, - message: WriteMessageType, + /// The request on `stream` was aborted. + pub(crate) fn abort_stream(stream: NonNull) { + Self::enter(Self::of_stream(stream), |s| { + let opened_at = stream_ref(stream.as_ptr()).opened_at; + s.detach_with_failure(stream.as_ptr(), crate::Error::Aborted); + s.after_abort(s.last_read.max(opened_at)); + }); + } + + /// A request in `pending_attach` was aborted. + pub(crate) fn abort_parked( + session: NonNull, + client: NonNull>, ) { - Self::enter(this, |s| s.stream_request_body(async_http_id, message)); + Self::enter(Self::this_ptr(session), |s| { + s.pending_attach.retain(|&parked| parked != client.as_ptr()); + pending_client_mut(client.as_ptr()).fail(crate::Error::Aborted); + s.after_abort(s.last_read); + }); } - /// HTTP-thread wake-up from `resumeReceive`; see [`Self::resume_receive`]. - pub(crate) fn resume_receive_by_http_id(this: SessionPtr, async_http_id: u32) { - Self::enter(this, |s| s.resume_receive(async_http_id)); + /// HTTP-thread wake-up from `scheduleRequestWrite`; see [`Self::on_request_body`]. + pub(crate) fn stream_request_body(stream: NonNull, message: WriteMessageType) { + Self::enter(Self::of_stream(stream), |s| { + s.on_request_body(stream.as_ptr(), message) + }); } - /// HTTP-thread wake-up from `scheduleResponseBodyDrain`; see - /// [`Self::drain_response_body`]. - pub(crate) fn drain_response_body_by_http_id(this: SessionPtr, async_http_id: u32) { - Self::enter(this, |s| s.drain_response_body(async_http_id)); + /// The consumer of the stream's response body takes bytes again. + pub(crate) fn resume_receive(stream: NonNull) { + Self::enter(Self::of_stream(stream), |s| { + s.replenish_window(); + if s.write_buffer.is_not_empty() { + if let Err(err) = s.flush() { + return s.fail_all(err); + } + } + let Some(client) = stream_mut(stream.as_ptr()).client_mut() else { + return; + }; + if let Err(err) = client.send_held_body() { + s.detach_with_failure(stream.as_ptr(), err); + s.rearm_timeout(); + s.maybe_release(); + } + }); } #[inline] @@ -353,14 +359,14 @@ impl ClientSession { /// `session` backref is not dereferenced while `&mut self` is already /// live on the stack — re-entering via the raw backref would form a /// second aliased `&mut ClientSession` (Stacked-Borrows UB). - pub(crate) fn rst_stream(&mut self, stream: &mut Stream, code: wire::ErrorCode) { + pub(crate) fn rst_stream(&mut self, stream: &mut Stream, code: ErrorCode) { if stream.rst_done || stream.state == StreamState::Closed { return; } stream.rst_done = true; stream.state = StreamState::Closed; let value: [u8; 4] = code.0.to_be_bytes(); - self.write_frame(wire::FrameType::HTTP_FRAME_RST_STREAM, 0, stream.id, &value); + self.write_frame(FrameType::RstStream, 0, stream.id, &value); } /// Allocate a session for a socket whose ALPN just selected h2 and list it @@ -375,7 +381,7 @@ impl ClientSession { let this = bun_core::heap::into_raw(Box::new(ClientSession { ref_count: Cell::new(1), socket_ref_owed: Cell::new(false), - hpack: lshpack::HpackHandle::new(4096), + hpack: lshpack::Coder::default(), socket, ctx, hostname: Box::<[u8]>::from(client.connected_url.hostname), @@ -387,48 +393,42 @@ impl ClientSession { write_buffer: bun_io::StreamBuffer::default(), read_buffer: Vec::new(), streams: ArrayHashMap::default(), - by_http_id: ArrayHashMap::default(), next_stream_id: 1, - expecting_continuation: 0, - continuation_count: 0, + header_block: HeaderBlockAssembler::default(), pending_attach: Vec::new(), preface_sent: false, settings_received: false, goaway_received: false, - encoder_poisoned: false, delivering: false, stream_progressed: false, last_read: Instant::now(), awaiting_ping_ack: false, ping_cap_armed: false, liveness_ping_spent: false, - goaway_last_stream_id: 0, fatal_error: None, - orphan_header_block: Vec::new(), + request_fields: lshpack::HeaderBlock::default(), encode_scratch: Vec::new(), - remote_max_frame_size: wire::DEFAULT_MAX_FRAME_SIZE, + remote_max_frame_size: DEFAULT_MAX_FRAME_SIZE, remote_max_concurrent_streams: 100, - remote_initial_window_size: wire::DEFAULT_WINDOW_SIZE, - pending_hpack_enc_capacity: None, - conn_send_window: wire::DEFAULT_WINDOW_SIZE as i32, - conn_unacked_bytes: 0, + remote_initial_window_size: DEFAULT_WINDOW_SIZE, + conn_send_window: SendWindow::new(DEFAULT_WINDOW_SIZE), + conn_recv_window: RecvWindow::new(LOCAL_INITIAL_WINDOW_SIZE), registry_index: Cell::new(u32::MAX), })); super::live_sessions.fetch_add(1, Ordering::Relaxed); // `ctx` is a live back-ref to the owning context (set-once, // HTTP-thread-only, no ancestor `&mut HTTPContext` on this path) — // route through the centralised [`HTTPClient::ssl_ctx_mut`] accessor. - HTTPClient::ssl_ctx_mut(ctx).h2_register(this); + HTTPClient::ssl_ctx_mut(ctx).register_h2(this); Self::this_ptr(NonNull::new(this).expect("heap::into_raw is non-null")) } pub(crate) fn has_headroom(&self) -> bool { !self.goaway_received - && !self.encoder_poisoned && !self.awaiting_ping_ack && self.fatal_error.is_none() && self.streams.count() < self.remote_max_concurrent_streams as usize - && self.next_stream_id < wire::MAX_STREAM_ID + && self.next_stream_id < MAX_STREAM_ID } pub(crate) fn matches( @@ -448,8 +448,8 @@ impl ClientSession { && strings::eql_long(&self.hostname, hostname, true) } - fn adopt_client(&mut self, client: &mut HTTPClient) { - client.h2_register_abort_tracker(self.socket); + /// Returns false if the session has no room for `client`, which `connect()` then places elsewhere. + fn adopt_client(&mut self, client: &mut HTTPClient) -> bool { // Park instead of attaching when (a) we're inside onData's deliver // loop — attach() mustn't mutate `streams` under iteration — or (b) // the server's first SETTINGS hasn't arrived yet, so the real @@ -457,26 +457,24 @@ impl ClientSession { // shouldn't risk a REFUSED_STREAM. The leader bypasses adopt() and // attaches directly so the preface still goes out. if self.delivering || !self.settings_received { - self.pending_attach.push(client.as_erased_ptr().as_ptr()); - self.rearm_timeout(); - return; + self.park(client); + return true; } // Belt-and-suspenders: callers gate on hasHeadroom(), but a session // pulled from the keep-alive pool (HTTPContext.existingSocket) may have // remote_max_concurrent_streams == 0 if a mid-connection SETTINGS - // dropped it. Re-dispatch instead of asserting in attach(). + // dropped it. if !self.has_headroom() { - client.h2_retry_after_coalesce(); self.maybe_release(); - return; + return false; } self.attach(client); - // If attach() poisoned the encoder and left the session empty, release - // it now — adopt() callers (keep-alive resume, active-session match) - // have no tail maybeRelease of their own. - if self.encoder_poisoned { + if self.streams.count() == 0 { + // The request couldn't be encoded. `connect()` is on the stack and borrows the pool, so close instead of parking. + self.goaway_received = true; self.maybe_release(); } + true } /// Park a request that was coalesced onto this session's connect until the @@ -485,10 +483,9 @@ impl ClientSession { Self::enter(this, |s| s.park(client)); } - /// Park a coalesced request until the server's SETTINGS arrive. Abort - /// is routed via the session socket so `abortByHttpId` can find it. + /// Park a coalesced request until the server's SETTINGS arrive. fn park(&mut self, client: &mut HTTPClient<'_>) { - client.h2_register_abort_tracker(self.socket); + client.carrier = Carrier::H2Parked(NonNull::from(&mut *self)); self.pending_attach.push(client.as_erased_ptr().as_ptr()); self.rearm_timeout(); } @@ -501,13 +498,13 @@ impl ClientSession { for client_ptr in waiters { let client = pending_client_mut(client_ptr); if let Some(err) = self.fatal_error { - client.h2_fail(err); + client.fail(err); } else if client.signals.get(signals::Field::Aborted) { - client.h2_fail(crate::Error::Aborted); + client.fail(crate::Error::Aborted); } else if self.has_headroom() { self.attach(client); } else { - client.h2_retry_after_coalesce(); + client.retry_after_h2_coalesce(); } } } @@ -518,13 +515,12 @@ impl ClientSession { pub(crate) fn can_pool(&self) -> bool { self.streams.count() == 0 && !self.goaway_received - && !self.encoder_poisoned && self.fatal_error.is_none() - && self.expecting_continuation == 0 + && !self.header_block.is_open() && self.read_buffer.is_empty() && self.write_buffer.is_empty() && self.remote_max_concurrent_streams > 0 - && self.next_stream_id < wire::MAX_STREAM_ID + && self.next_stream_id < MAX_STREAM_ID } /// No request is left: the connection only waits for the ACK of its liveness PING. @@ -547,19 +543,12 @@ impl ClientSession { pub(crate) fn write_frame( &mut self, - frame_type: wire::FrameType, + frame_type: FrameType, flags: u8, stream_id: u32, payload: &[u8], ) { - // Wire format: u24 length BE, u8 type, u8 flags, u32 stream-id BE. - let len = u32::try_from(payload.len()).expect("int cast"); - let mut header = [0u8; wire::FrameHeader::BYTE_SIZE]; - header[0..3].copy_from_slice(&len.to_be_bytes()[1..4]); - header[3] = frame_type as u8; - header[4] = flags; - header[5..9].copy_from_slice(&stream_id.to_be_bytes()); - self.queue(&header); + self.queue(&FrameHeader::new(frame_type, flags, stream_id, payload.len()).to_bytes()); self.queue(payload); } @@ -568,30 +557,17 @@ impl ClientSession { fn attach(&mut self, client: &mut HTTPClient) { debug_assert!(self.has_headroom()); - let send_window = i32::try_from(self.remote_initial_window_size.min(wire::MAX_WINDOW_SIZE)) - .expect("int cast"); - // Only clients with an abort-signal store are ever looked up by id - // (the HTTP-thread wake paths key off `abort_tracker()`); mirror that - // gate so unsignaled callers sharing the `0` sentinel never collide. - let async_http_id = client - .signals - .aborted - .is_some() - .then_some(client.async_http_id); let stream = bun_core::heap::into_raw(Stream::new( self.next_stream_id, - async_http_id, + NonNull::from(&mut *self), Some(client.as_erased_ptr()), - send_window, + SendWindow::new(self.remote_initial_window_size), )); super::live_streams.fetch_add(1, Ordering::Relaxed); self.next_stream_id = self.next_stream_id.saturating_add(2); let stream_ref = stream_mut(stream); let _ = self.streams.put(stream_ref.id, stream); - if let Some(id) = async_http_id { - let _ = self.by_http_id.put(id, stream); - } - client.h2 = NonNull::new(stream); + client.carrier = Carrier::H2(NonNull::from(&mut *stream_ref)); client.flags.protocol = Protocol::Http2; client.allow_retry = false; @@ -604,33 +580,18 @@ impl ClientSession { // cursor stays valid across event-loop ticks. if let Err(e) = client.compress_body_for_send(false) { self.remove_stream(stream); - client.h2 = None; + client.carrier = Carrier::None; client.fail(e); return; } - let request = client.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 - // never see. Mark the session unusable for future streams and - // remove without RST — from the server's view this stream id was - // never opened (RST on an idle stream is a connection error per - // RFC 9113 §5.1). - self.encoder_poisoned = true; + // No RST: the server never saw this stream id, and RST_STREAM on an idle stream is a connection error (RFC 9113 §5.1). self.remove_stream(stream); - client.h2 = None; - client.h2_fail(err); - // The poisoned session is dead for new work; bounce any waiters - // and let maybeRelease() drop the registration so the next fetch - // opens a fresh connection instead of waiting for idle-timeout. - for c in core::mem::take(&mut self.pending_attach) { - pending_client_mut(c).h2_retry_after_coalesce(); - } + client.carrier = Carrier::None; + client.fail(err); let _ = self.flush(); - // Do NOT maybeRelease() here: attach() runs from drainPending() - // inside onData (whose tail maybeRelease handles cleanup) and from - // adopt() (which calls maybeRelease itself when this leaves the - // session empty). Releasing twice would close+deref twice. return; } if client.verbose != HTTPVerboseLevel::None { @@ -656,24 +617,15 @@ impl ClientSession { } if client.flags.is_streaming_request_body { - client.h2_progress_update(self.ctx, self.socket); + // Nothing of the response is here to decode, so this can't fail. + let _ = client.send_progress(); } } - /// Unlink `stream` from the session map and free it. If the stream was - /// mid-CONTINUATION (HEADERS arrived without END_HEADERS), the buffered - /// fragment is moved to `orphan_header_block` so the trailing CONTINUATION - /// frames decode against the full block — otherwise HPACK-decoding the - /// suffix alone desyncs the dynamic table for every sibling stream. + /// Unlink `stream` from the session map and free it. fn remove_stream(&mut self, stream: *mut Stream) { let s = stream_mut(stream); - if self.expecting_continuation == s.id { - self.orphan_header_block = core::mem::take(&mut s.header_block); - } self.streams.swap_remove(&s.id); - if let Some(id) = s.async_http_id { - self.by_http_id.swap_remove(&id); - } drop_stream(stream); } @@ -681,15 +633,15 @@ impl ClientSession { /// session and socket stay up for siblings. pub(crate) fn detach_with_failure(&mut self, stream: *mut Stream, err: Error) { let s = stream_mut(stream); - self.rst_stream(s, wire::ErrorCode::CANCEL); + self.rst_stream(s, ErrorCode::CANCEL); let _ = self.flush(); let client = s.client.take(); if let Some(c) = client { - stream_client_mut(c).h2 = None; + stream_client_mut(c).carrier = Carrier::None; } self.remove_stream(stream); if let Some(c) = client { - stream_client_mut(c).h2_fail(err); + stream_client_mut(c).fail(err); } } @@ -730,7 +682,7 @@ impl ClientSession { } self.ping_cap_armed = false; if self.awaiting_ping_ack { - let mut cap = crate::normalize_idle_timeout_seconds(PING_ACK_TIMEOUT_SECONDS); + let mut cap = PING_ACK_TIMEOUT_SECONDS; let global = crate::idle_timeout_seconds(); if global != 0 { cap = cap.min(global); @@ -743,43 +695,9 @@ impl ClientSession { self.socket.set_timeout(want); } - /// O(1) lookup in the `by_http_id` secondary index. Returns the raw - /// stream pointer (owned by `self.streams`) so callers can re-borrow - /// `&mut self` afterwards without an outstanding shared borrow. - #[inline] - fn stream_for_http_id(&self, async_http_id: u32) -> Option<*mut Stream> { - self.by_http_id.get(&async_http_id).copied() - } - - /// A body consumer attached on the JS side: flush any body bytes that arrived between - /// metadata delivery and `getReader()`. - fn drain_response_body(&mut self, async_http_id: u32) { - let Some(stream) = self.stream_for_http_id(async_http_id) else { - return; - }; - if let Some(client) = stream_mut(stream).client_mut() { - client.h2_drain_response_body(self.socket); - } - } - - fn resume_receive(&mut self, async_http_id: u32) { - if self.stream_for_http_id(async_http_id).is_none() { - return; - } - self.replenish_window(); - if self.write_buffer.is_not_empty() { - if let Err(err) = self.flush() { - self.fail_all(err); - } - } - } - /// New request body bytes (or end-of-body) are available in the request's /// ThreadSafeStreamBuffer. - fn stream_request_body(&mut self, async_http_id: u32, message: WriteMessageType) { - let Some(stream) = self.stream_for_http_id(async_http_id) else { - return; - }; + fn on_request_body(&mut self, stream: *mut Stream, message: WriteMessageType) { { let Some(client) = stream_mut(stream).client_mut() else { return; @@ -804,25 +722,19 @@ impl ClientSession { pub(crate) fn write_window_update(&mut self, stream_id: u32, increment: u31) { let bytes = increment.to_be_bytes(); - self.write_frame( - wire::FrameType::HTTP_FRAME_WINDOW_UPDATE, - 0, - stream_id, - &bytes, - ); + self.write_frame(FrameType::WindowUpdate, 0, stream_id, &bytes); } fn replenish_window(&mut self) { - let threshold = LOCAL_INITIAL_WINDOW_SIZE / 2; - if self.conn_unacked_bytes >= threshold { - self.write_window_update(0, self.conn_unacked_bytes); - self.conn_unacked_bytes = 0; + if self.conn_recv_window.needs_update() { + let increment = self.conn_recv_window.take_update(); + self.write_window_update(0, increment); } - // Collect (id, unacked) pairs before mutating self. + // Collect (id, increment) pairs before mutating self. let mut updates: Vec<(u32, u32)> = Vec::new(); for &s in self.streams.values() { let s = stream_mut(s); - if s.unacked_bytes < threshold || s.remote_closed() { + if !s.recv_window.needs_update() || s.remote_closed() { continue; } if s.client_ref() @@ -830,11 +742,10 @@ impl ClientSession { { continue; } - updates.push((s.id, s.unacked_bytes)); - s.unacked_bytes = 0; + updates.push((s.id, s.recv_window.take_update())); } - for (id, unacked) in updates { - self.write_window_update(id, unacked); + for (id, increment) in updates { + self.write_window_update(id, increment); } // PERF: could iterate and write in one pass; profile if extra Vec matters. } @@ -927,7 +838,7 @@ impl ClientSession { // never did and hasn't RST'd) must signal abandonment so the // server can release its concurrency slot. rst() is idempotent. if s.state != StreamState::Closed { - self.rst_stream(s, wire::ErrorCode::CANCEL); + self.rst_stream(s, ErrorCode::CANCEL); rst_any = true; } self.remove_stream(stream); @@ -1015,25 +926,24 @@ impl ClientSession { // aliased `&mut NewHTTPContext` via autoref. unsafe { NewHTTPContext::::unregister_h2_raw(self.ctx, self) }; for client in core::mem::take(&mut self.pending_attach) { - pending_client_mut(client).h2_fail(err); + pending_client_mut(client).fail(err); } // `handle_data`'s deliver loop holds a stream across the callback that re-entered here. let deliver_loop_frees_streams = self.delivering; for &e in self.streams.values() { let client = stream_mut(e).client.take(); if let Some(c) = client { - stream_client_mut(c).h2 = None; + stream_client_mut(c).carrier = Carrier::None; } if !deliver_loop_frees_streams { drop_stream(e); } if let Some(c) = client { - stream_client_mut(c).h2_fail(err); + stream_client_mut(c).fail(err); } } if !deliver_loop_frees_streams { self.streams.clear_retaining_capacity(); - self.by_http_id.clear_retaining_capacity(); } self.give_up_socket_ref(); } @@ -1055,7 +965,7 @@ impl ClientSession { let mut goaway = [0u8; 8]; goaway[0..4].copy_from_slice(&0u32.to_be_bytes()); goaway[4..8].copy_from_slice(&dispatch::error_code_for(err).0.to_be_bytes()); - self.write_frame(wire::FrameType::HTTP_FRAME_GOAWAY, 0, 0, &goaway); + self.write_frame(FrameType::GoAway, 0, 0, &goaway); let _ = self.flush(); } NewHTTPContext::::mark_socket_as_dead(sock); @@ -1063,24 +973,8 @@ impl ClientSession { sock.close(bun_uws::CloseKind::Failure); } - /// RST_STREAMs (or unparks and fails) the one aborted request; siblings - /// continue. See [`Self::probe_liveness`]. - fn abort_request(&mut self, async_http_id: u32) { - // Find the index via a raw-ptr field read first, then swap_remove, so - // no `&mut HTTPClient` is held across the Vec mutation and no `&mut` - // is materialised during iteration. - let found = self - .pending_attach - .iter() - .position(|&c| pending_client_mut(c).async_http_id == async_http_id); - let mut silent_since = self.last_read; - if let Some(i) = found { - let client = self.pending_attach.swap_remove(i); - pending_client_mut(client).h2_fail(crate::Error::Aborted); - } else if let Some(stream) = self.stream_for_http_id(async_http_id) { - silent_since = silent_since.max(stream_ref(stream).opened_at); - self.detach_with_failure(stream, crate::Error::Aborted); - } + /// One request was aborted; its siblings continue. See [`Self::probe_liveness`]. + fn after_abort(&mut self, silent_since: Instant) { self.probe_liveness(silent_since); self.rearm_timeout(); self.maybe_release(); @@ -1099,7 +993,7 @@ impl ClientSession { return; } self.liveness_ping_spent = true; - self.write_frame(wire::FrameType::HTTP_FRAME_PING, 0, 0, &[0; 8]); + self.write_frame(FrameType::Ping, 0, 0, &[0; 8]); self.awaiting_ping_ack = self.flush().is_ok(); } @@ -1135,7 +1029,7 @@ impl ClientSession { } // Idempotent: a session is released exactly once. The registry index is // the sentinel — `registerH2` re-arms it on keep-alive resume, and any - // path that has already unregistered (encoder-poison, abort, a + // path that has already unregistered (abort, a // `fail_all` earlier in the same entry point) leaves it at maxInt so a // second caller can't close the socket or give up the ref twice. if self.registry_index.get() == u32::MAX { @@ -1155,7 +1049,7 @@ impl ClientSession { // ctx back-ref is valid for the session's lifetime. Unlike // `unregister_h2_raw` above, this branch is *not* reachable on the // re-entrant `connect()` → `adopt()` path: every adopt-side entry - // into `maybe_release` has `encoder_poisoned`, `!has_headroom()` + // into `maybe_release` has `goaway_received`, `!has_headroom()` // (goaway/fatal/stream-id-exhausted/unanswered PING), or `streams.count() > 0` — // all of which short-circuit before reaching `can_pool()`. So no // ancestor frame holds `&mut NewHTTPContext` here and forming one @@ -1196,17 +1090,21 @@ impl ClientSession { let client = stream_client_mut(client_ptr); if client.signals.get(signals::Field::Aborted) { - self.rst_stream(stream, wire::ErrorCode::CANCEL); + self.rst_stream(stream, ErrorCode::CANCEL); let _ = self.flush(); stream.client = None; - client.h2 = None; - client.h2_fail(crate::Error::Aborted); + client.carrier = Carrier::None; + client.fail(crate::Error::Aborted); return true; } if let Some(err) = stream.fatal_error { stream.client = None; - client.h2 = None; + client.carrier = Carrier::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 @@ -1219,146 +1117,42 @@ impl ClientSession { HTTPRequestBody::Bytes(_) ) { - client.h2_retry(); + client.retry_from_h2(); } else { - client.h2_fail(err); + client.fail(err); } return true; } - if stream.headers_ready { - stream.headers_ready = false; - let (result, response) = match client - .apply_multiplexed_headers(stream.status_code, &stream.decoded_headers) - { - Ok(r) => r, - Err(err) => { - self.rst_stream(stream, wire::ErrorCode::CANCEL); - let _ = self.flush(); - stream.client = None; - client.h2 = None; - client.h2_fail(err); - return true; - } - }; - // handleResponseMetadata set is_redirect_pending. The doRedirect - // contract assumes the caller already detached the stream. - // Detach + RST here unconditionally so the - // header_progress path below can never re-enter doRedirect via - // progressUpdate while the old Stream still points at this - // client — that path would attach a second Stream to the same - // HTTPClient and the first one's `stream.client` becomes a - // dangling pointer once the request completes. - if client.state.flags.is_redirect_pending { - self.rst_stream(stream, wire::ErrorCode::CANCEL); - let _ = self.flush(); - stream.client = None; - client.h2 = None; - client.h2_do_redirect(self.ctx, self.socket); - return true; - } - // Deep-copy before detaching: `response` borrows - // `stream.decoded_headers`. - client.h2_clone_metadata(&response); - // `is_done()`: Content-Length: 0 can arrive as `HasBody` (e.g. an - // SSE content-type), which would make the headerProgress update - // below terminal and free `client` while the stream still holds it. - if result == HeaderResult::Finished - || (stream.remote_closed() && stream.body_buffer.is_empty()) - || client.state.is_done() - { - stream.client = None; - client.h2 = None; - client.state.flags.received_last_chunk = true; - // .finished = HEAD/204/304: no body is expected regardless of - // any Content-Length header, so clear it. Otherwise leave the - // parsed value so finishStream() enforces §8.1.1 against the - // (zero) bytes actually received. - if result == HeaderResult::Finished { - client.state.content_length = Some(0); - } - return self.finish_stream(stream, client); - } - // Mirror the h1 path: deliver headers - // to JS now so `await fetch()` resolves and `getReader()` can enable - // response_body_streaming. Without this, a content-length response - // buffers the entire body before the Response promise settles. - if client.signals.get(signals::Field::HeaderProgress) { - client.h2_progress_update(self.ctx, self.socket); - } - } - - if client.state.response_stage != HTTPStage::Body { + if stream.status_code == 0 { return false; } - - if !stream.body_buffer.is_empty() { - let terminal = stream.remote_closed(); - if terminal { - client.state.flags.received_last_chunk = true; - stream.client = None; - client.h2 = None; + let head = core::mem::take(&mut stream.headers_ready) + .then_some((stream.status_code, stream.decoded_headers.as_slice())); + let mut progress = + client.receive_from_stream(head, &stream.body_buffer, stream.remote_closed()); + stream.body_buffer.clear(); + if let Ok(StreamProgress::Open { report }) = progress { + if !report { + return false; } - let report = match client.h2_handle_response_body(&stream.body_buffer, false) { - Ok(r) => r, - Err(err) => { - stream.body_buffer.clear(); - self.rst_stream(stream, wire::ErrorCode::CANCEL); - let _ = self.flush(); - if !terminal { - stream.client = None; - client.h2 = None; - } - client.h2_fail(err); - return true; - } - }; - stream.body_buffer.clear(); - if terminal { - return self.finish_stream(stream, client); - } - if report { - // handleResponseBody may report completion before END_STREAM - // (Content-Length satisfied). The terminal progressUpdate - // path frees the AsyncHTTP that owns `client`, so detach - // first; the trailing END_STREAM/trailers land on a stream - // we no longer track and are discarded. - if client.state.is_done() { - stream.client = None; - client.h2 = None; - client.h2_progress_update(self.ctx, self.socket); - return true; - } - client.h2_progress_update(self.ctx, self.socket); - } - return false; - } - - if stream.remote_closed() { - stream.client = None; - client.h2 = None; - if let Err(err) = client.state.finalize_body_on_eof() { - client.h2_fail(err); - return true; + match client.send_progress() { + Ok(_) => return false, + Err(err) => progress = Err(err), } - return self.finish_stream(stream, client); } - - false - } - - /// Terminal delivery: enforce the announced Content-Length (RFC 9113 - /// §8.1.1 — mismatch is malformed) and hand off to progressUpdate. - /// `total_body_received` is clamped at content_length by the body handler, - /// so compare the raw DATA byte count instead — that catches overshoot too. - fn finish_stream(&mut self, stream: &mut Stream, client: &mut HTTPClient) -> bool { - if let Some(cl) = client.state.content_length { - if stream.data_bytes_received != cl as u64 { - client.h2_fail(crate::Error::HTTP2ContentLengthMismatch); - return true; + // `handle_data` resets a stream that the server still has open. + stream.client = None; + client.carrier = Carrier::None; + match progress { + Ok(StreamProgress::Redirect) => client.follow_redirect(), + Ok(_) => { + if let Err(err) = client.send_progress() { + client.fail(err); + } } + Err(err) => client.fail(err), } - client.h2_progress_update(self.ctx, self.socket); true } } @@ -1368,7 +1162,7 @@ impl Drop for ClientSession { super::live_sessions.fetch_sub(1, Ordering::Relaxed); debug_assert!(self.registry_index.get() == u32::MAX); // hpack: HpackHandle drops automatically (lshpack_wrapper_deinit). - // write_buffer / read_buffer / pending_attach / orphan_header_block / + // write_buffer / read_buffer / pending_attach / header_block / // encode_scratch / hostname / ssl_config are dropped automatically. for &e in self.streams.values() { drop_stream(e); diff --git a/src/http/h2_client/Stream.rs b/src/http/h2_client/Stream.rs index 5a9ee2bb9108..5c9ebb0e34bf 100644 --- a/src/http/h2_client/Stream.rs +++ b/src/http/h2_client/Stream.rs @@ -8,6 +8,8 @@ use core::sync::atomic::Ordering; use std::time::Instant; use crate::Error; +use bun_http_types::h2::flow_control::{RecvWindow, SendWindow}; +use bun_http_types::h2::stream::{Event, State, transition}; use bun_picohttp as picohttp; // `live_streams` lives in the parent module of `h2_client/`. @@ -20,18 +22,13 @@ use crate::HTTPClient; pub struct Stream { // HTTP/2 stream IDs are 31-bit; the top bit must stay clear. pub(crate) id: u32, - /// Snapshot of `client.async_http_id` at attach time. Stored so the - /// session's `by_http_id` index can be maintained after `client` has been - /// cleared (terminal delivery nulls `client` before `remove_stream`). - /// `None` for clients without an abort-signal store (not indexed). - pub(crate) async_http_id: Option, + // BACKREF: the session owns the stream through its `streams` map. + pub(crate) session: NonNull, // BACKREF: weak back-pointer, cleared before terminal callbacks. // Lifetime-erased — the stream never reads borrowed fields through this. pub(crate) client: Option>>, pub(crate) opened_at: Instant, - /// HEADERS + CONTINUATION fragments, decoded once END_HEADERS arrives. - pub(crate) header_block: Vec, /// DATA payload accumulated across one onData() pass. pub(crate) body_buffer: Vec, @@ -43,6 +40,7 @@ pub struct Stream { /// Final (non-1xx) status code; 0 until the response HEADERS arrive. pub(crate) status_code: u32, + /// A `Stream` is created by sending HEADERS, so it starts `Open`. pub(crate) state: State, /// `.closed` was reached via RST_STREAM (sent or received). Kept distinct /// from `state` so `rst()` stays idempotent (never answers an inbound RST, @@ -56,15 +54,10 @@ pub struct Stream { /// or final status arrives. pub(crate) awaiting_continue: bool, pub(crate) fatal_error: Option, - /// 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 — - /// `total_body_received` is clamped at content_length so it can't catch - /// overshoot. - pub(crate) data_bytes_received: u64, - /// Per-stream send window (server's INITIAL_WINDOW_SIZE plus any - /// WINDOW_UPDATEs minus DATA bytes already framed). - pub(crate) send_window: i32, + /// 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, + pub(crate) recv_window: RecvWindow, + pub(crate) send_window: SendWindow, /// Unsent suffix of a `.bytes` request body, parked while the send /// window is exhausted. Borrows from `client.state.request_body`. // BACKREF: borrows from `client.state.request_body`; `RawSlice` carries @@ -99,24 +92,10 @@ impl Stream { } } -/// RFC 9113 §5.1. A `Stream` is created by sending HEADERS, so it starts -/// `.open`; `idle`/`reserved` are never represented as objects. END_STREAM -/// half-closes one side; both, or any RST_STREAM, transitions to `.closed`. -#[repr(u8)] -#[derive(Copy, Clone, PartialEq, Eq)] -pub enum State { - Open, - /// We have written END_STREAM; no more DATA may be queued. - HalfClosedLocal, - /// Peer has sent END_STREAM; further DATA is STREAM_CLOSED. - HalfClosedRemote, - Closed, -} - impl Drop for Stream { fn drop(&mut self) { let _ = super::LIVE_STREAMS.fetch_sub(1, Ordering::Relaxed); - // header_block / body_buffer / decoded_bytes / decoded_headers: Vec<_> drops automatically. + // body_buffer / decoded_bytes / decoded_headers: Vec<_> drops automatically. // Freeing the Box is the caller's drop; nothing to do here. } } @@ -124,16 +103,15 @@ impl Drop for Stream { impl Stream { pub(crate) fn new( id: u32, - async_http_id: Option, + session: NonNull, client: Option>>, - send_window: i32, + send_window: SendWindow, ) -> Box { Box::new(Self { id, - async_http_id, + session, client, opened_at: Instant::now(), - header_block: Vec::new(), body_buffer: Vec::new(), decoded_bytes: Vec::new(), decoded_headers: Vec::new(), @@ -144,8 +122,8 @@ impl Stream { headers_end_stream: false, awaiting_continue: false, fatal_error: None, - unacked_bytes: 0, - data_bytes_received: 0, + http1_required: false, + recv_window: RecvWindow::new(super::LOCAL_INITIAL_WINDOW_SIZE), send_window, pending_body: bun_ptr::RawSlice::EMPTY, }) @@ -158,31 +136,23 @@ impl Stream { // session `&mut` is already in scope at every call site. pub(crate) fn sent_end_stream(&mut self) { - self.state = match self.state { - State::Open => State::HalfClosedLocal, - State::HalfClosedRemote => State::Closed, - other => other, - }; + self.state = transition(self.state, Event::SendEndStream).unwrap_or(self.state); } pub(crate) fn recv_end_stream(&mut self) { - self.state = match self.state { - State::Open => State::HalfClosedRemote, - State::HalfClosedLocal => State::Closed, - other => other, - }; + self.state = transition(self.state, Event::RecvEndStream).unwrap_or(self.state); } /// We have sent END_STREAM (or RST): no more request DATA may be queued. #[inline] pub(crate) fn local_closed(&self) -> bool { - self.state == State::HalfClosedLocal || self.state == State::Closed + !self.state.can_send() } /// Peer has sent END_STREAM (or RST): the response body is complete and /// further inbound DATA is a protocol error. #[inline] pub(crate) fn remote_closed(&self) -> bool { - self.state == State::HalfClosedRemote || self.state == State::Closed + !self.state.can_receive() } } diff --git a/src/http/h2_client/dispatch.rs b/src/http/h2_client/dispatch.rs index 523bd752bf7d..7a8c1de3e620 100644 --- a/src/http/h2_client/dispatch.rs +++ b/src/http/h2_client/dispatch.rs @@ -1,21 +1,20 @@ -//! Inbound frame parsing and dispatch for the fetch() HTTP/2 client. -//! Free functions over `&mut ClientSession` so the session struct stays focused on -//! lifecycle and delivery; everything that interprets bytes off the wire lives -//! here. +//! Inbound frames of the fetch() HTTP/2 client: the checks that depend on this connection's streams. `bun_http_types::h2` makes the rest. + +use bun_http_types::h2::fields; +use bun_http_types::h2::frame::{ + self, ErrorCode, FRAME_HEADER_SIZE, Frame, FrameHeader, FrameType, NextFrame, flags, +}; +use bun_http_types::h2::header_block::HeaderBlockError; +use bun_http_types::h2::settings::{DEFAULT_MAX_FRAME_SIZE, SettingId}; +use bun_http_types::h2::stream::State as StreamState; use super::client_session::{ClientSession, stream_mut}; -use super::stream::{State as StreamState, Stream}; +use super::stream::Stream; use super::{LOCAL_MAX_CONTINUATIONS, LOCAL_MAX_HEADER_LIST_SIZE, WRITE_BUFFER_CONTROL_LIMIT}; -use crate::h2_frame_parser as wire; use bun_picohttp as picohttp; bun_core::declare_scope!(h2_client, hidden); -// `stream_mut` is the shared `*mut Stream` → `&mut Stream` upgrade centralised -// in `client_session` (same INVARIANT: heap-allocated entries owned by -// `session.streams`, freed only via `drop_stream`/`on_close`, disjoint from -// `&mut ClientSession`). Re-used here so the raw deref lives in one place. - /// Dispatch every complete frame in `buf` and return the number of bytes /// consumed. The caller spills the unconsumed tail (a partial frame) into /// `read_buffer`. Operating on a borrowed slice lets `onData` parse @@ -23,83 +22,37 @@ bun_core::declare_scope!(h2_client, hidden); /// frame is carried over, saving one memcpy of every body byte. pub(crate) fn parse_frames(session: &mut ClientSession, buf: &[u8]) -> usize { let mut consumed: usize = 0; - loop { - let remaining = &buf[consumed..]; - if remaining.len() < wire::FrameHeader::BYTE_SIZE { - break; - } - let mut header = wire::FrameHeader::decode( - remaining[0..wire::FrameHeader::BYTE_SIZE] - .try_into() - .unwrap(), - ); - let sid = wire::UInt31WithReserved::from(header.stream_identifier).uint31(); - header.stream_identifier = sid; - let length = header.length; - // RFC 9113 §4.2: a frame larger than the local SETTINGS_MAX_FRAME_SIZE - // (we never advertise above the 16384 default) is a connection - // FRAME_SIZE_ERROR. Bounding here also caps `read_buffer` growth. - if length > wire::DEFAULT_MAX_FRAME_SIZE { - session.fatal_error = Some(crate::Error::HTTP2FrameSizeError); - break; - } - let frame_len = wire::FrameHeader::BYTE_SIZE + length as usize; - if remaining.len() < frame_len { - break; - } - dispatch_frame( - session, - header.type_, - header.flags, - sid, - length, - &remaining[wire::FrameHeader::BYTE_SIZE..frame_len], - ); - consumed += frame_len; - if session.fatal_error.is_some() { - break; + while session.fatal_error.is_none() { + // We never advertise a SETTINGS_MAX_FRAME_SIZE above the default, which also caps `read_buffer`. + match frame::next_frame(&buf[consumed..], DEFAULT_MAX_FRAME_SIZE) { + Ok(NextFrame::Complete { header, payload }) => { + dispatch_frame(session, &header, payload); + consumed += FRAME_HEADER_SIZE + payload.len(); + } + Ok(NextFrame::NeedHeader | NextFrame::Partial { .. }) => break, + Err(err) => session.fatal_error = Some(error_for(err.code)), } } consumed } -// Frame types are non-exhaustive on the wire (any u8 is valid). A -// `#[repr(u8)]` Rust enum is UB for unknown discriminants, so dispatch on the -// raw u8. -const FT_DATA: u8 = wire::FrameType::HTTP_FRAME_DATA as u8; -const FT_HEADERS: u8 = wire::FrameType::HTTP_FRAME_HEADERS as u8; -const FT_PRIORITY: u8 = wire::FrameType::HTTP_FRAME_PRIORITY as u8; -const FT_RST_STREAM: u8 = wire::FrameType::HTTP_FRAME_RST_STREAM as u8; -const FT_SETTINGS: u8 = wire::FrameType::HTTP_FRAME_SETTINGS as u8; -const FT_PUSH_PROMISE: u8 = wire::FrameType::HTTP_FRAME_PUSH_PROMISE as u8; -const FT_PING: u8 = wire::FrameType::HTTP_FRAME_PING as u8; -const FT_GOAWAY: u8 = wire::FrameType::HTTP_FRAME_GOAWAY as u8; -const FT_WINDOW_UPDATE: u8 = wire::FrameType::HTTP_FRAME_WINDOW_UPDATE as u8; -const FT_CONTINUATION: u8 = wire::FrameType::HTTP_FRAME_CONTINUATION as u8; - -const ST_HEADER_TABLE_SIZE: u16 = wire::SettingsType::SETTINGS_HEADER_TABLE_SIZE.0; -const ST_MAX_CONCURRENT_STREAMS: u16 = wire::SettingsType::SETTINGS_MAX_CONCURRENT_STREAMS.0; -const ST_INITIAL_WINDOW_SIZE: u16 = wire::SettingsType::SETTINGS_INITIAL_WINDOW_SIZE.0; -const ST_MAX_FRAME_SIZE: u16 = wire::SettingsType::SETTINGS_MAX_FRAME_SIZE.0; +/// RFC 9113 §5.1.1: an id we never used. Push is disabled, so that includes every even id. +fn is_idle_stream(session: &ClientSession, stream_id: u32) -> bool { + stream_id & 1 == 0 || stream_id >= session.next_stream_id +} -fn dispatch_frame( - session: &mut ClientSession, - frame_type: u8, - flags: u8, - stream_id: u32, - length: u32, - payload: &[u8], -) { +fn dispatch_frame(session: &mut ClientSession, header: &FrameHeader, payload: &[u8]) { bun_core::scoped_log!( h2_client, "frame type={} len={} flags={} stream={}", - frame_type, - length, - flags, - stream_id + header.frame_type, + header.length, + header.flags, + header.stream_id ); + let stream_id = header.stream_id; - if session.expecting_continuation != 0 && frame_type != FT_CONTINUATION { + if !session.header_block.admits(header) { session.fatal_error = Some(crate::Error::HTTP2ProtocolError); return; } @@ -107,341 +60,160 @@ fn dispatch_frame( // MUST be the first frame. Without this, GOAWAY-before-SETTINGS leaves // coalesced waiters in `pending_attach` forever (drainPending is gated // on settings_received and maybeRelease won't run while it's non-empty). - if !session.settings_received && frame_type != FT_SETTINGS { + if !session.settings_received && header.frame_type != FrameType::Settings as u8 { session.fatal_error = Some(crate::Error::HTTP2ProtocolError); return; } - match frame_type { - FT_SETTINGS => { - // RFC 9113 §6.5: stream id != 0 is PROTOCOL_ERROR; ACK with a - // payload, or a non-ACK whose length isn't a multiple of 6, is - // FRAME_SIZE_ERROR. - if stream_id != 0 { - session.fatal_error = Some(crate::Error::HTTP2ProtocolError); - return; - } - if flags & wire::SettingsFlags::ACK as u8 != 0 { - if length != 0 { - session.fatal_error = Some(crate::Error::HTTP2FrameSizeError); + let frame = match Frame::parse(header, payload, usize::MAX) { + Ok(frame) => frame, + Err(err) if err.stream_id == 0 => { + session.fatal_error = Some(error_for(err.code)); + return; + } + Err(err) => { + match session.streams.get(&err.stream_id).copied() { + Some(stream_ptr) => { + let stream = stream_mut(stream_ptr); + session.rst_stream(stream, err.code); + stream.fatal_error = Some(error_for(err.code)); } - return; - } - if !(length as usize).is_multiple_of(wire::SettingsPayloadUnit::BYTE_SIZE) { - session.fatal_error = Some(crate::Error::HTTP2FrameSizeError); - return; + None if is_idle_stream(session, err.stream_id) => { + session.fatal_error = Some(crate::Error::HTTP2ProtocolError); + } + None => {} } - let mut i: usize = 0; - while i + wire::SettingsPayloadUnit::BYTE_SIZE <= payload.len() { - let mut unit = wire::SettingsPayloadUnit::default(); - wire::SettingsPayloadUnit::from::( - &mut unit, - &payload[i..i + wire::SettingsPayloadUnit::BYTE_SIZE], - 0, - ); - // Brace-expr copies of packed fields (unaligned-safe). - let utype = { unit.type_ }; - let uvalue = { unit.value }; - match utype { - ST_MAX_FRAME_SIZE => { - // RFC 9113 §6.5.2: values outside [16384, 2^24-1] - // are a connection PROTOCOL_ERROR. Without the - // lower bound, a 0 here makes writeHeaderBlock / - // writeDataWindowed spin forever emitting empty - // frames. - if uvalue < wire::DEFAULT_MAX_FRAME_SIZE || uvalue > wire::MAX_FRAME_SIZE { - session.fatal_error = Some(crate::Error::HTTP2ProtocolError); - return; - } - session.remote_max_frame_size = uvalue; // @truncate(u24) - } - ST_MAX_CONCURRENT_STREAMS => { - session.remote_max_concurrent_streams = uvalue; + return; + } + }; + + match frame { + Frame::Settings(entries) => { + for (id, value) in entries { + match SettingId::from_u16(id) { + Some(SettingId::MaxFrameSize) => session.remote_max_frame_size = value, + Some(SettingId::MaxConcurrentStreams) => { + session.remote_max_concurrent_streams = value; } - ST_HEADER_TABLE_SIZE => { - // RFC 9113 §4.3.1 / RFC 7541 §4.2: encoder MUST - // acknowledge a reduced limit with a Dynamic Table - // Size Update at the start of the next header - // block. Track the minimum seen so a reduce-then- - // raise between two blocks still signals the dip. - session.pending_hpack_enc_capacity = Some( - session - .pending_hpack_enc_capacity - .unwrap_or(uvalue) - .min(uvalue), - ); + Some(SettingId::HeaderTableSize) => { + session.hpack.set_peer_header_table_size(value); } - ST_INITIAL_WINDOW_SIZE => { - // RFC 9113 §6.5.2 / §6.9.2: values above 2^31-1, or - // a delta that pushes any open stream's window past - // that, are a connection FLOW_CONTROL_ERROR. - if uvalue > wire::MAX_WINDOW_SIZE { - session.fatal_error = Some(crate::Error::HTTP2FlowControlError); - return; - } + Some(SettingId::InitialWindowSize) => { + // RFC 9113 §6.9.2: the change applies to every open stream's window. let delta = - i64::from(uvalue) - i64::from(session.remote_initial_window_size); - session.remote_initial_window_size = uvalue; + i64::from(value) - i64::from(session.remote_initial_window_size); + session.remote_initial_window_size = value; for &s_ptr in session.streams.values() { - let s = stream_mut(s_ptr); - let next = i64::from(s.send_window) + delta; - if next > i64::from(wire::MAX_WINDOW_SIZE) { + if stream_mut(s_ptr).send_window.shift(delta).is_err() { session.fatal_error = Some(crate::Error::HTTP2FlowControlError); return; } - s.send_window = i32::try_from(next).expect("int cast"); } } _ => {} } - i += wire::SettingsPayloadUnit::BYTE_SIZE; } if session.write_buffer.size() >= WRITE_BUFFER_CONTROL_LIMIT { session.fatal_error = Some(crate::Error::HTTP2EnhanceYourCalm); return; } - session.write_frame( - wire::FrameType::HTTP_FRAME_SETTINGS, - wire::SettingsFlags::ACK as u8, - 0, - &[], - ); + session.write_frame(FrameType::Settings, flags::ACK, 0, &[]); session.settings_received = true; } - FT_WINDOW_UPDATE => { - if length != 4 { - session.fatal_error = Some(crate::Error::HTTP2FrameSizeError); - return; - } - let inc = i32::try_from(wire::UInt31WithReserved::from_bytes(&payload[0..4]).uint31()) - .expect("int cast"); + Frame::SettingsAck => {} + Frame::WindowUpdate { increment } => { if stream_id == 0 { - // RFC 9113 §6.9: zero increment on stream 0 is a - // connection PROTOCOL_ERROR; §6.9.1: overflow past - // 2^31-1 is a connection FLOW_CONTROL_ERROR. - if inc == 0 { - session.fatal_error = Some(crate::Error::HTTP2ProtocolError); - return; - } - let next = i64::from(session.conn_send_window) + i64::from(inc); - if next > i64::from(wire::MAX_WINDOW_SIZE) { + if session.conn_send_window.increase(increment).is_err() { session.fatal_error = Some(crate::Error::HTTP2FlowControlError); return; } - session.conn_send_window = i32::try_from(next).expect("int cast"); session.stream_progressed = true; - } else if let Some(&stream_ptr) = session.streams.get(&(stream_id & 0x7fff_ffff)) { + } else if let Some(&stream_ptr) = session.streams.get(&stream_id) { let stream = stream_mut(stream_ptr); - // §6.9/§6.9.1: zero increment / overflow on a stream are - // stream-level errors; RST_STREAM and fail just that one. - if inc == 0 { - session.rst_stream(stream, wire::ErrorCode::PROTOCOL_ERROR); - stream.fatal_error = Some(crate::Error::HTTP2ProtocolError); - return; - } - let next = i64::from(stream.send_window) + i64::from(inc); - if next > i64::from(wire::MAX_WINDOW_SIZE) { - session.rst_stream(stream, wire::ErrorCode::FLOW_CONTROL_ERROR); + // §6.9.1: overflow on a stream is a stream error; RST_STREAM and fail just that one. + if let Err(code) = stream.send_window.increase(increment) { + session.rst_stream(stream, code); stream.fatal_error = Some(crate::Error::HTTP2FlowControlError); return; } - stream.send_window = i32::try_from(next).expect("int cast"); session.stream_progressed = true; - } else { - // §5.1: WINDOW_UPDATE on an idle/server-initiated stream - // is a connection PROTOCOL_ERROR. Silent ignore is correct - // for closed streams (odd ids we already used). - if stream_id & 1 == 0 || stream_id >= session.next_stream_id { - session.fatal_error = Some(crate::Error::HTTP2ProtocolError); - return; - } - } - } - FT_PING => { - // RFC 9113 §6.7: length != 8 is a connection FRAME_SIZE_ERROR; - // a non-zero stream identifier is a connection PROTOCOL_ERROR. - if length != 8 { - session.fatal_error = Some(crate::Error::HTTP2FrameSizeError); - return; - } - if stream_id != 0 { + } else if is_idle_stream(session, stream_id) { session.fatal_error = Some(crate::Error::HTTP2ProtocolError); - return; - } - if flags & wire::PingFrameFlags::ACK as u8 == 0 { - if session.write_buffer.size() >= WRITE_BUFFER_CONTROL_LIMIT { - session.fatal_error = Some(crate::Error::HTTP2EnhanceYourCalm); - return; - } - session.write_frame( - wire::FrameType::HTTP_FRAME_PING, - wire::PingFrameFlags::ACK as u8, - 0, - &payload[0..8], - ); - } else { - session.awaiting_ping_ack = false; } } - FT_PRIORITY => { - // RFC 9113 §6.3: deprecated, but framing rules remain. - if stream_id == 0 { - session.fatal_error = Some(crate::Error::HTTP2ProtocolError); - return; - } - if length as usize != wire::StreamPriority::BYTE_SIZE { - session.fatal_error = Some(crate::Error::HTTP2FrameSizeError); + Frame::Ping { + ack: false, + payload, + } => { + if session.write_buffer.size() >= WRITE_BUFFER_CONTROL_LIMIT { + session.fatal_error = Some(crate::Error::HTTP2EnhanceYourCalm); return; } + session.write_frame(FrameType::Ping, flags::ACK, 0, &payload); } - FT_HEADERS => { - session.continuation_count = 0; - let mut fragment = payload; - let maybe_stream = session.streams.get(&stream_id).copied(); - if maybe_stream.is_none() { - // RFC 9113 §5.1/§5.1.1: HEADERS on a stream we never - // opened (idle: id >= next_stream_id, or even: server- - // initiated while push is disabled) is a connection - // PROTOCOL_ERROR. Only odd ids we already used can be a - // legitimate "RST crossed an in-flight HEADERS" orphan. - if stream_id == 0 || stream_id & 1 == 0 || stream_id >= session.next_stream_id { - session.fatal_error = Some(crate::Error::HTTP2ProtocolError); - return; + Frame::Ping { ack: true, .. } => session.awaiting_ping_ack = false, + Frame::Headers { + fragment, + end_stream, + end_headers, + } => { + match session.streams.get(&stream_id).copied() { + Some(stream_ptr) => { + session.stream_progressed = true; + session.liveness_ping_spent = false; + stream_mut(stream_ptr).headers_end_stream = end_stream; } - // Stream we no longer track (RST_STREAM crossed an - // in-flight HEADERS). The block must still be HPACK- - // decoded so the connection-level dynamic table stays in - // sync with the server's encoder, and CONTINUATION must - // be tracked so a follow-up frame doesn't fatal the whole - // connection. - if flags & wire::HeadersFrameFlags::PADDED as u8 != 0 { - fragment = match strip_padding(fragment) { - Some(f) => f, - None => { - session.fatal_error = Some(crate::Error::HTTP2ProtocolError); - return; - } - }; - } - if flags & wire::HeadersFrameFlags::PRIORITY as u8 != 0 { - if fragment.len() < wire::StreamPriority::BYTE_SIZE { - session.fatal_error = Some(crate::Error::HTTP2ProtocolError); - return; - } - fragment = &fragment[wire::StreamPriority::BYTE_SIZE..]; - } - if fragment.len() > LOCAL_MAX_HEADER_LIST_SIZE as usize { - session.fatal_error = Some(crate::Error::HTTP2HeaderListTooLarge); - return; - } - reset_stream_frame_received(session); - session.orphan_header_block.clear(); - session.orphan_header_block.extend_from_slice(fragment); - if flags & wire::HeadersFrameFlags::END_HEADERS as u8 != 0 { - decode_discard_orphan(session); - } else { - session.expecting_continuation = stream_id; - } - return; - } - // SAFETY: stream pointer from map is valid for session lifetime. - let stream = stream_mut(maybe_stream.unwrap()); - session.stream_progressed = true; - session.liveness_ping_spent = false; - if flags & wire::HeadersFrameFlags::PADDED as u8 != 0 { - fragment = match strip_padding(fragment) { - Some(f) => f, - None => { - session.fatal_error = Some(crate::Error::HTTP2ProtocolError); - return; - } - }; - } - if flags & wire::HeadersFrameFlags::PRIORITY as u8 != 0 { - if fragment.len() < wire::StreamPriority::BYTE_SIZE { + None if is_idle_stream(session, stream_id) => { session.fatal_error = Some(crate::Error::HTTP2ProtocolError); return; } - fragment = &fragment[wire::StreamPriority::BYTE_SIZE..]; + // Our RST_STREAM crossed this HEADERS. + None => reset_stream_frame_received(session), } - if fragment.len() > LOCAL_MAX_HEADER_LIST_SIZE as usize { - session.fatal_error = Some(crate::Error::HTTP2HeaderListTooLarge); - return; - } - stream.header_block.clear(); - stream.header_block.extend_from_slice(fragment); - stream.headers_end_stream = flags & wire::HeadersFrameFlags::END_STREAM as u8 != 0; - if flags & wire::HeadersFrameFlags::END_HEADERS as u8 != 0 { - if stream.headers_end_stream { - stream.recv_end_stream(); - } - decode_header_block(session, stream); - } else { - session.expecting_continuation = stream.id; + if session.header_block.begin(stream_id, fragment, end_headers) { + on_header_block(session, stream_id); } } - FT_CONTINUATION => { - if session.expecting_continuation == 0 || stream_id != session.expecting_continuation { - session.fatal_error = Some(crate::Error::HTTP2ProtocolError); - return; - } - session.continuation_count += 1; - if session.continuation_count > LOCAL_MAX_CONTINUATIONS { + Frame::Continuation { + fragment, + end_headers, + } => match session.header_block.push( + fragment, + end_headers, + LOCAL_MAX_CONTINUATIONS, + LOCAL_MAX_HEADER_LIST_SIZE as usize, + ) { + Ok(true) => on_header_block(session, stream_id), + Ok(false) => {} + Err(HeaderBlockError::TooManyContinuations) => { session.fatal_error = Some(crate::Error::HTTP2EnhanceYourCalm); - return; } - if let Some(&stream_ptr) = session.streams.get(&session.expecting_continuation) { - // SAFETY: stream pointer valid for session lifetime. - let stream = stream_mut(stream_ptr); - if stream.header_block.len() + payload.len() > LOCAL_MAX_HEADER_LIST_SIZE as usize { - session.fatal_error = Some(crate::Error::HTTP2HeaderListTooLarge); - return; - } - stream.header_block.extend_from_slice(payload); - if flags & wire::HeadersFrameFlags::END_HEADERS as u8 != 0 { - session.expecting_continuation = 0; - if stream.headers_end_stream { - stream.recv_end_stream(); - } - decode_header_block(session, stream); - } - } else { - if session.orphan_header_block.len() + payload.len() - > LOCAL_MAX_HEADER_LIST_SIZE as usize - { - session.fatal_error = Some(crate::Error::HTTP2HeaderListTooLarge); - return; - } - session.orphan_header_block.extend_from_slice(payload); - if flags & wire::HeadersFrameFlags::END_HEADERS as u8 != 0 { - session.expecting_continuation = 0; - decode_discard_orphan(session); - } + Err(HeaderBlockError::TooLarge) => { + session.fatal_error = Some(crate::Error::HTTP2HeaderListTooLarge); } - } - FT_DATA => { - session.conn_unacked_bytes = session.conn_unacked_bytes.saturating_add(length); - let stream_ptr = match session.streams.get(&stream_id).copied() { - Some(p) => p, - None => { - // §6.1/§5.1: DATA on stream 0, an idle stream, or a - // server-initiated stream is a connection PROTOCOL_ERROR. - // DATA on a stream we already closed/reset is ignored. - if stream_id == 0 || stream_id & 1 == 0 || stream_id >= session.next_stream_id { - session.fatal_error = Some(crate::Error::HTTP2ProtocolError); - } - reset_stream_frame_received(session); - return; + }, + Frame::Data { data, end_stream } => { + // Padding counts against both windows (§6.9.1). + session.conn_recv_window.on_data(payload.len()); + if session.conn_recv_window.is_overflowed(0) { + session.fatal_error = Some(crate::Error::HTTP2FlowControlError); + return; + } + let Some(stream_ptr) = session.streams.get(&stream_id).copied() else { + // DATA on a stream we already closed or reset is ignored. + if is_idle_stream(session, stream_id) { + session.fatal_error = Some(crate::Error::HTTP2ProtocolError); } + reset_stream_frame_received(session); + return; }; - // SAFETY: stream pointer valid for session lifetime. let stream = stream_mut(stream_ptr); session.stream_progressed = true; session.liveness_ping_spent = false; // §8.1.1: DATA before the *final* response HEADERS is malformed — // a 1xx alone (status_code still 0) doesn't satisfy this. if stream.status_code == 0 { - session.rst_stream(stream, wire::ErrorCode::PROTOCOL_ERROR); + session.rst_stream(stream, ErrorCode::PROTOCOL_ERROR); stream.fatal_error = Some(crate::Error::HTTP2ProtocolError); return; } @@ -455,42 +227,26 @@ fn dispatch_frame( .get_or_insert(crate::Error::HTTP2ProtocolError); return; } - stream.unacked_bytes = stream.unacked_bytes.saturating_add(length); - let mut fragment = payload; - if flags & wire::DataFrameFlags::PADDED as u8 != 0 { - fragment = match strip_padding(fragment) { - Some(f) => f, - None => { - session.fatal_error = Some(crate::Error::HTTP2ProtocolError); - return; - } - }; + stream.recv_window.on_data(payload.len()); + // The window is what bounds the bytes held for a paused consumer. + if stream.recv_window.is_overflowed(0) { + session.rst_stream(stream, ErrorCode::FLOW_CONTROL_ERROR); + stream.fatal_error = Some(crate::Error::HTTP2FlowControlError); + return; } - if flags & wire::DataFrameFlags::END_STREAM as u8 != 0 { + if end_stream { stream.recv_end_stream(); } - stream.data_bytes_received += fragment.len() as u64; - if !fragment.is_empty() { - stream.body_buffer.extend_from_slice(fragment); - } + stream.body_buffer.extend_from_slice(data); } - FT_RST_STREAM => { - if length != 4 { - session.fatal_error = Some(crate::Error::HTTP2FrameSizeError); - return; - } - // RFC 9113 §6.4: stream 0, or an idle stream (one we never - // opened — even ids included since push is disabled), is a - // connection PROTOCOL_ERROR. - if stream_id == 0 || stream_id & 1 == 0 || stream_id >= session.next_stream_id { + Frame::RstStream { code } => { + if is_idle_stream(session, stream_id) { session.fatal_error = Some(crate::Error::HTTP2ProtocolError); return; } - let stream_ptr = match session.streams.get(&stream_id).copied() { - Some(p) => p, - None => return, + let Some(stream_ptr) = session.streams.get(&stream_id).copied() else { + return; }; - // SAFETY: stream pointer valid for session lifetime. let stream = stream_mut(stream_ptr); if stream.rst_done { // First RST_STREAM wins; a later STREAM_CLOSED for in-flight DATA is ignored. @@ -499,44 +255,29 @@ fn dispatch_frame( let had_response = stream.remote_closed(); stream.rst_done = true; stream.state = StreamState::Closed; - let code: u32 = wire::u32_from_bytes(&payload[0..4]); // RFC 9113 §8.1: RST_STREAM(NO_ERROR) is the server's "stop // uploading, I've already sent the full response" signal — // valid only if END_STREAM had already arrived. Otherwise the // body is truncated and must surface as an error. stream.fatal_error = match code { - x if x == wire::ErrorCode::NO_ERROR.0 => { - if had_response { - None - } else { - Some(crate::Error::HTTP2StreamReset) - } - } - x if x == wire::ErrorCode::REFUSED_STREAM.0 => { - Some(crate::Error::HTTP2RefusedStream) - } + ErrorCode::NO_ERROR if had_response => None, + ErrorCode::REFUSED_STREAM => Some(crate::Error::HTTP2RefusedStream), _ => Some(crate::Error::HTTP2StreamReset), }; + stream.http1_required = code == ErrorCode::HTTP_1_1_REQUIRED; } - FT_GOAWAY => { - if stream_id != 0 { - session.fatal_error = Some(crate::Error::HTTP2ProtocolError); - return; - } - if length < 8 { - session.fatal_error = Some(crate::Error::HTTP2FrameSizeError); - return; - } + Frame::GoAway { + last_stream_id, + code, + .. + } => { session.goaway_received = true; - session.goaway_last_stream_id = - 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 last_id = session.goaway_last_stream_id; + let graceful = code == ErrorCode::NO_ERROR; + let http1_required = code == ErrorCode::HTTP_1_1_REQUIRED; 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 { + if s.id > last_stream_id { + s.http1_required |= http1_required; s.fatal_error = Some(if graceful { crate::Error::HTTP2RefusedStream } else { @@ -547,41 +288,54 @@ 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()); } } } - FT_PUSH_PROMISE => { + // We send SETTINGS_ENABLE_PUSH = 0. + Frame::PushPromise { .. } => { session.fatal_error = Some(crate::Error::HTTP2ProtocolError); } - _ => {} + Frame::Priority | Frame::AltSvc | Frame::Origin | Frame::Unknown => {} + } +} + +/// END_HEADERS arrived. A block for a stream we no longer track is still decoded, to keep the HPACK table in sync (RFC 9113 §4.3). +fn on_header_block(session: &mut ClientSession, stream_id: u32) { + let block = session.header_block.take(); + match session.streams.get(&stream_id).copied() { + Some(stream_ptr) => { + let stream = stream_mut(stream_ptr); + if stream.headers_end_stream { + stream.recv_end_stream(); + } + decode_header_block(session, stream, &block); + } + None => decode_and_discard(session, &block), } + session.header_block.recycle(block); } -/// Feed an orphaned (untracked-stream) header block through the HPACK -/// decoder purely to keep the dynamic table in sync, then discard. -pub(crate) fn decode_discard_orphan(session: &mut ClientSession) { +fn decode_and_discard(session: &mut ClientSession, block: &[u8]) { let mut offset: usize = 0; - while offset < session.orphan_header_block.len() { - // Disjoint field borrows: `hpack` (mut) vs `orphan_header_block` (shared). - let result = match session.hpack.decode(&session.orphan_header_block[offset..]) { - Ok(r) => r, + while offset < block.len() { + match session.hpack.decode(&block[offset..]) { + Ok(field) => offset += field.next, Err(_) => { session.fatal_error = Some(crate::Error::HTTP2CompressionError); - session.orphan_header_block.clear(); return; } - }; - offset += result.next; + } } - session.orphan_header_block.clear(); } -/// HPACK-decode the buffered header block at parse time. Runs for every +/// HPACK-decode a header block at parse time. Runs for every /// END_HEADERS so the dynamic table stays in sync regardless of how many /// HEADERS frames arrive in one read. 1xx and trailers are decoded then /// dropped; the final response is stored on the stream for delivery. -pub(crate) fn decode_header_block(session: &mut ClientSession, stream: &mut Stream) { - // `stream.header_block.clear()` is inlined before each return below. +fn decode_header_block(session: &mut ClientSession, stream: &mut Stream, block: &[u8]) { let mut status: u32 = 0; let mut bounds: Vec<[u32; 3]> = Vec::new(); let start_len = stream.decoded_bytes.len(); @@ -596,8 +350,8 @@ pub(crate) fn decode_header_block(session: &mut ClientSession, stream: &mut Stre let mut malformed = false; let mut offset: usize = 0; - while offset < stream.header_block.len() { - let result = match session.hpack.decode(&stream.header_block[offset..]) { + while offset < block.len() { + let result = match session.hpack.decode(&block[offset..]) { Ok(r) => r, Err(_) => { // The decoder has already committed earlier fields from this @@ -606,7 +360,6 @@ pub(crate) fn decode_header_block(session: &mut ClientSession, stream: &mut Stre // a decoding error MUST be treated as a connection error of // type COMPRESSION_ERROR. session.fatal_error = Some(crate::Error::HTTP2CompressionError); - stream.header_block.clear(); return; } }; @@ -620,15 +373,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; } @@ -636,7 +383,7 @@ pub(crate) fn decode_header_block(session: &mut ClientSession, stream: &mut Stre if stream.status_code != 0 || malformed { continue; } - if is_malformed_response_field(result.name) || is_malformed_response_value(result.value) { + if is_malformed_response_field(result.name, result.value) { malformed = true; continue; } @@ -645,7 +392,6 @@ pub(crate) fn decode_header_block(session: &mut ClientSession, stream: &mut Stre untrimmed_len += result.name.len() + result.value.len(); if untrimmed_len > LOCAL_MAX_HEADER_LIST_SIZE as usize { session.fatal_error = Some(crate::Error::HTTP2HeaderListTooLarge); - stream.header_block.clear(); return; } let name_start: u32 = u32::try_from(stream.decoded_bytes.len()).expect("int cast"); @@ -661,11 +407,9 @@ pub(crate) fn decode_header_block(session: &mut ClientSession, stream: &mut Stre ]); } - stream.header_block.clear(); - if malformed { stream.decoded_bytes.truncate(start_len); - session.rst_stream(stream, wire::ErrorCode::PROTOCOL_ERROR); + session.rst_stream(stream, ErrorCode::PROTOCOL_ERROR); stream.fatal_error = Some(crate::Error::HTTP2ProtocolError); return; } @@ -708,12 +452,7 @@ pub(crate) fn decode_header_block(session: &mut ClientSession, stream: &mut Stre // response can finish normally; Content-Length was already stripped // on this path so 0 bytes is not a §8.1.1 mismatch. stream.awaiting_continue = false; - session.write_frame( - wire::FrameType::HTTP_FRAME_DATA, - wire::DataFrameFlags::END_STREAM as u8, - stream.id, - &[], - ); + session.write_frame(FrameType::Data, flags::END_STREAM, stream.id, &[]); stream.sent_end_stream(); } let bytes = stream.decoded_bytes.as_ptr(); @@ -746,63 +485,12 @@ fn reset_stream_frame_received(session: &mut ClientSession) { session.liveness_ping_spent = false; } -fn strip_padding(payload: &[u8]) -> Option<&[u8]> { - if payload.is_empty() { - return None; - } - let pad: usize = payload[0] as usize; - if pad >= payload.len() { - return None; - } - Some(&payload[1..payload.len() - pad]) -} - -/// RFC 9113 §8.2.1/§8.2.2 response-side validation: lowercase names, no -/// hop-by-hop fields. Names from lshpack are already lowercase for table -/// hits but a literal can carry anything. -pub(crate) fn is_malformed_response_field(name: &[u8]) -> bool { - if name.is_empty() { - return true; - } - for &c in name { - match c { - b'a'..=b'z' - | b'0'..=b'9' - | b'!' - | b'#' - | b'$' - | b'%' - | b'&' - | b'\'' - | b'*' - | b'+' - | b'-' - | b'.' - | b'^' - | b'_' - | b'`' - | b'|' - | b'~' => {} - _ => return true, - } - } - matches!( - name, - b"connection" - | b"keep-alive" - | b"proxy-connection" - | b"te" - | b"transfer-encoding" - | b"upgrade" - ) -} - -/// RFC 9113 §8.2.1: a field value MUST NOT contain NUL (0x00), LF (0x0a), or -/// CR (0x0d). HPACK is length-prefixed so these would otherwise pass through -/// verbatim, breaking the no-CR/LF invariant the HTTP/1.1 parser provides and -/// enabling header injection when values are forwarded downstream. -pub(crate) fn is_malformed_response_value(value: &[u8]) -> bool { - bun_core::strings::contains_any(value, b"\0\r\n") +/// RFC 9113 §8.2 and RFC 9114 §4.2, for a response: no field of an HTTP/1.1 connection, `te` included. +pub(crate) fn is_malformed_response_field(name: &[u8], value: &[u8]) -> bool { + fields::is_malformed_name(name) + || fields::is_connection_specific(name) + || name == b"te" + || fields::is_malformed_value(value) } /// RFC 9110 §5.5: a parser strips leading and trailing SP / HTAB. HPACK and QPACK do not. @@ -810,15 +498,24 @@ pub(crate) fn trim_response_value(value: &[u8]) -> &[u8] { bun_core::strings::trim(value, b" \t") } -pub(crate) fn error_code_for(err: crate::Error) -> wire::ErrorCode { +fn error_for(code: ErrorCode) -> crate::Error { + match code { + ErrorCode::FRAME_SIZE_ERROR => crate::Error::HTTP2FrameSizeError, + ErrorCode::FLOW_CONTROL_ERROR => crate::Error::HTTP2FlowControlError, + ErrorCode::ENHANCE_YOUR_CALM => crate::Error::HTTP2EnhanceYourCalm, + _ => crate::Error::HTTP2ProtocolError, + } +} + +pub(crate) fn error_code_for(err: crate::Error) -> ErrorCode { match err { - crate::Error::HTTP2ProtocolError => wire::ErrorCode::PROTOCOL_ERROR, - crate::Error::HTTP2FrameSizeError => wire::ErrorCode::FRAME_SIZE_ERROR, - crate::Error::HTTP2FlowControlError => wire::ErrorCode::FLOW_CONTROL_ERROR, - crate::Error::HTTP2CompressionError => wire::ErrorCode::COMPRESSION_ERROR, + crate::Error::HTTP2ProtocolError => ErrorCode::PROTOCOL_ERROR, + crate::Error::HTTP2FrameSizeError => ErrorCode::FRAME_SIZE_ERROR, + crate::Error::HTTP2FlowControlError => ErrorCode::FLOW_CONTROL_ERROR, + crate::Error::HTTP2CompressionError => ErrorCode::COMPRESSION_ERROR, crate::Error::HTTP2HeaderListTooLarge | crate::Error::HTTP2EnhanceYourCalm => { - wire::ErrorCode::ENHANCE_YOUR_CALM + ErrorCode::ENHANCE_YOUR_CALM } - _ => wire::ErrorCode::INTERNAL_ERROR, + _ => ErrorCode::INTERNAL_ERROR, } } diff --git a/src/http/h2_client/encode.rs b/src/http/h2_client/encode.rs index af3e84d347aa..39c8960a1dc6 100644 --- a/src/http/h2_client/encode.rs +++ b/src/http/h2_client/encode.rs @@ -6,46 +6,37 @@ use super::client_session::ClientSession; use super::stream::Stream; use super::{LOCAL_INITIAL_WINDOW_SIZE, LOCAL_MAX_HEADER_LIST_SIZE, WRITE_BUFFER_HIGH_WATER}; use crate::HTTPClient; -use crate::h2_frame_parser as wire; use crate::http_request_body::HTTPRequestBody; use crate::internal_state::HTTPStage; use bun_core::strings; +use bun_http_types::h2::flow_control::DEFAULT_WINDOW_SIZE; +use bun_http_types::h2::frame::{CONNECTION_PREFACE, FrameType, flags}; +use bun_http_types::h2::settings::{self, SettingId}; use bun_picohttp as picohttp; pub(crate) fn write_preface(session: &mut ClientSession) { - session.queue(wire::CLIENT_PREFACE); + session.queue(CONNECTION_PREFACE); - let mut settings = [0u8; 3 * wire::SettingsPayloadUnit::BYTE_SIZE]; - encode_setting( - &mut settings[0..6], - wire::SettingsType::SETTINGS_ENABLE_PUSH, - 0, - ); - encode_setting( - &mut settings[6..12], - wire::SettingsType::SETTINGS_INITIAL_WINDOW_SIZE, - LOCAL_INITIAL_WINDOW_SIZE, - ); - encode_setting( - &mut settings[12..18], - wire::SettingsType::SETTINGS_MAX_HEADER_LIST_SIZE, - LOCAL_MAX_HEADER_LIST_SIZE, - ); - session.write_frame(wire::FrameType::HTTP_FRAME_SETTINGS, 0, 0, &settings); + let settings = [ + settings::entry(SettingId::EnablePush as u16, 0), + settings::entry( + SettingId::InitialWindowSize as u16, + LOCAL_INITIAL_WINDOW_SIZE, + ), + settings::entry( + SettingId::MaxHeaderListSize as u16, + LOCAL_MAX_HEADER_LIST_SIZE, + ), + ]; + session.write_frame(FrameType::Settings, 0, 0, settings.as_flattened()); // Connection-level window starts at 64 KiB regardless of SETTINGS; // open it to match the per-stream window so the first response isn't // throttled before our first WINDOW_UPDATE. - session.write_window_update(0, LOCAL_INITIAL_WINDOW_SIZE - wire::DEFAULT_WINDOW_SIZE); + session.write_window_update(0, LOCAL_INITIAL_WINDOW_SIZE - DEFAULT_WINDOW_SIZE); session.preface_sent = true; } -#[inline] -fn encode_setting(dst: &mut [u8], setting: wire::SettingsType, value: u32) { - dst[0..2].copy_from_slice(&setting.0.to_be_bytes()); - dst[2..6].copy_from_slice(&value.to_be_bytes()); -} - /// One classification pass per request header replaces a dozen case-insensitive /// string compares. Names are lowercased once (required for the wire anyway), /// then dispatched by length+content. @@ -85,24 +76,13 @@ fn classify_request_header(name: &[u8]) -> Option { }) } +/// `Err` leaves the connection and its HPACK table as they were: nothing of the request was queued. pub(crate) fn write_request( session: &mut ClientSession, client: &mut HTTPClient, stream: &mut Stream, request: &picohttp::Request<'_>, ) -> crate::Result<()> { - // `encode_scratch` would be borrowed mutably - // alongside `&mut *session` below; pull the Vec out, push it back at the end. - let mut encoded = core::mem::take(&mut session.encode_scratch); - encoded.clear(); - - if let Some(cap) = session.pending_hpack_enc_capacity { - session.pending_hpack_enc_capacity = None; - session.hpack.set_encoder_max_capacity(cap); - encoded.reserve(8); - encode_hpack_table_size_update(&mut encoded, cap); - } - let mut authority: &[u8] = client.url.host; let mut has_expect_continue = false; let mut lower_buf = [0u8; 256]; @@ -126,12 +106,12 @@ pub(crate) fn write_request( } } - encode_header(session, &mut encoded, b":method", request.method, false)?; - encode_header(session, &mut encoded, b":scheme", b"https", false)?; - encode_header(session, &mut encoded, b":authority", authority, false)?; - encode_header( - session, - &mut encoded, + let fields = &mut session.request_fields; + fields.clear(); + fields.push(b":method", request.method, false)?; + fields.push(b":scheme", b"https", false)?; + fields.push(b":authority", authority, false)?; + fields.push( b":path", if !request.path.is_empty() { request.path @@ -175,9 +155,16 @@ pub(crate) fn write_request( RequestHeader::Expect => {} } } - encode_header(session, &mut encoded, name, h.value(), never_index)?; + fields.push(name, h.value(), never_index)?; } + // `write_header_block` takes `&mut *session`, so the scratch Vec moves out for the call. + let mut encoded = core::mem::take(&mut session.encode_scratch); + encoded.clear(); + session + .hpack + .encode_block(&session.request_fields, &mut encoded)?; + // request_body points into original_request_body.bytes (lives in client.state). let body = client.state.request_body; let has_inline_body = matches!( @@ -199,10 +186,9 @@ pub(crate) fn write_request( &encoded, !has_inline_body && !is_streaming, ); - if encoded.capacity() > 64 * 1024 { - encoded = Vec::new(); + if encoded.capacity() <= 64 * 1024 { + session.encode_scratch = encoded; } - session.encode_scratch = encoded; if has_inline_body { stream.pending_body = body; drain_send_body(session, stream, usize::MAX); @@ -225,20 +211,20 @@ pub(crate) fn write_header_block( let chunk = &remaining[0..remaining.len().min(max)]; remaining = &remaining[chunk.len()..]; let last = remaining.is_empty(); - let mut flags: u8 = 0; + let mut frame_flags: u8 = 0; if last { - flags |= wire::HeadersFrameFlags::END_HEADERS as u8; + frame_flags |= flags::END_HEADERS; } if first && end_stream { - flags |= wire::HeadersFrameFlags::END_STREAM as u8; + frame_flags |= flags::END_STREAM; } session.write_frame( if first { - wire::FrameType::HTTP_FRAME_HEADERS + FrameType::Headers } else { - wire::FrameType::HTTP_FRAME_CONTINUATION + FrameType::Continuation }, - flags, + frame_flags, stream_id, chunk, ); @@ -262,9 +248,10 @@ pub(crate) fn write_data_windowed( let mut remaining = data; let mut consumed: usize = 0; loop { - let window: usize = - usize::try_from(stream.send_window.min(session.conn_send_window).max(0)) - .expect("int cast"); + let window = stream + .send_window + .available() + .min(session.conn_send_window.available()); if !remaining.is_empty() && window == 0 { break; } @@ -281,19 +268,18 @@ pub(crate) fn write_data_windowed( .min(session.remote_max_frame_size as usize) .min(window); let last = chunk_len == remaining.len(); - let flags: u8 = if last && end_stream { - wire::DataFrameFlags::END_STREAM as u8 - } else { - 0 - }; session.write_frame( - wire::FrameType::HTTP_FRAME_DATA, - flags, + FrameType::Data, + if last && end_stream { + flags::END_STREAM + } else { + 0 + }, stream.id, &remaining[0..chunk_len], ); - stream.send_window -= i32::try_from(chunk_len).expect("int cast"); - session.conn_send_window -= i32::try_from(chunk_len).expect("int cast"); + stream.send_window.consume(chunk_len); + session.conn_send_window.consume(chunk_len); consumed += chunk_len; remaining = &remaining[chunk_len..]; if last { @@ -368,7 +354,7 @@ pub(crate) fn drain_send_bodies(session: &mut ClientSession) -> bool { // the lowest-index stream can't monopolise conn_send_window. let slice: usize = session.remote_max_frame_size as usize; loop { - if session.conn_send_window <= 0 { + if session.conn_send_window.available() == 0 { return false; } if session.write_buffer.size() >= WRITE_BUFFER_HIGH_WATER { @@ -383,7 +369,7 @@ pub(crate) fn drain_send_bodies(session: &mut ClientSession) -> bool { let stream = session.streams.values()[i]; let s = super::client_session::stream_mut(stream); i += 1; - if s.local_closed() || s.send_window <= 0 { + if s.local_closed() || s.send_window.available() == 0 { continue; } let before = session.conn_send_window; @@ -397,43 +383,3 @@ pub(crate) fn drain_send_bodies(session: &mut ClientSession) -> bool { } } } - -fn encode_header( - session: &mut ClientSession, - encoded: &mut Vec, - name: &[u8], - value: &[u8], - never_index: bool, -) -> crate::Result<()> { - let required = encoded.len() + name.len() + value.len() + 32; - encoded.reserve(required.saturating_sub(encoded.len())); - let len = encoded.len(); - // Write through the raw buffer and set_len after. - // SAFETY: `hpack.encode` writes only into `[len..len+written]`, which is - // within the just-reserved capacity; bytes in `[0..len]` are initialized. - let buf = unsafe { bun_core::vec::allocated_bytes_mut(encoded) }; - let written = session - .hpack - .encode(name, value, never_index, buf, len) - .map_err(crate::Error::from)?; - // SAFETY: hpack wrote `written` bytes at offset `len`; new_len <= capacity. - unsafe { bun_core::vec::commit_spare(encoded, written) }; - Ok(()) -} - -/// RFC 7541 §6.3 Dynamic Table Size Update: `001` prefix, 5-bit-prefix -/// integer. Must be the first opcode in a header block. Caller guarantees -/// at least 6 bytes of capacity (max for a u32). -fn encode_hpack_table_size_update(encoded: &mut Vec, value: u32) { - if value < 31 { - encoded.push(0x20 | u8::try_from(value).expect("int cast")); - return; - } - encoded.push(0x20 | 31); - let mut rest = value - 31; - while rest >= 128 { - encoded.push((rest as u8) | 0x80); - rest >>= 7; - } - encoded.push(rest as u8); -} diff --git a/src/http/h3_client/AltSvc.rs b/src/http/h3_client/AltSvc.rs index 208498c59e30..eeeee9d4fbfd 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,29 @@ 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 colon = strings::last_index_of_char(origin, b':').unwrap_or(0); + let origin_host = &origin[..colon]; + 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 04a93b5c5344..c8296ec189f2 100644 --- a/src/http/h3_client/ClientContext.rs +++ b/src/http/h3_client/ClientContext.rs @@ -2,7 +2,7 @@ //! client engine and the live-session registry. Never freed — the engine //! lives for the process, same as the HTTP thread itself. -use core::ffi::{c_uint, c_void}; +use core::ffi::c_uint; use core::ptr::NonNull; use core::sync::atomic::Ordering; @@ -14,9 +14,9 @@ use super::callbacks; use super::client_session::{ClientSession, quic_socket_mut, session_mut}; use super::pending_connect::PendingConnect; 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::{Carrier, HTTPClient}; use crate::h3_client::h3_client; @@ -38,7 +38,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 +74,6 @@ impl ClientContext { if let Some(i) = INSTANCE.load() { return Some(i); } - LSQUIC_INIT_ONCE.call_once(quic::global_init); // SAFETY: HTTP-thread only, where `on_start` has set `uws_loop` to the // thread's live `us_loop_t`. let qctx = unsafe { @@ -109,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 {}:{}", @@ -135,7 +140,25 @@ 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 ssl_ctx = match tls_props.as_ref() { + Some(tls) => match crate::http_thread().quic_ssl_ctx(tls) { + Some(ssl_ctx) => Some(ssl_ctx), + None => return false, + }, + None => None, + }; + 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. @@ -143,9 +166,9 @@ 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, ssl_ctx.as_ref()); match result { ConnectResult::Socket(qs) => { session_mut(session).qsocket = NonNull::new(qs); @@ -180,7 +203,7 @@ impl ClientContext { ); // `fail_session` fails what is queued, and that dispatch frees // the `AsyncHTTP` the caller still holds. Queue nothing. - if let Some(stream) = client.h3 { + if let Carrier::H3(stream) = client.carrier { session_mut(session).detach(stream.as_ptr()); } self.unregister(session_mut(session)); @@ -204,59 +227,15 @@ impl ClientContext { session.registry_index = u32::MAX; } - pub(crate) fn abort_by_http_id(async_http_id: u32) -> bool { - let Some(this) = Self::get() else { - return false; - }; - // Leaked Box, process-lifetime; HTTP-thread only — `BackRef` (immortal - // referent) gives `&ClientContext` for the Vec iter. Each session is a - // disjoint heap allocation, and `ClientSession::abort_by_http_id` never - // calls back into `unregister`, so `sessions` is stable across the loop. - let ctx = bun_ptr::BackRef::from(this); - for &s in ctx.sessions.iter() { - // Registry only holds live sessions — `session_mut` upgrade. - if session_mut(s).abort_by_http_id(async_http_id) { - return true; - } - } - false - } - /// Close the connections of fetch session `pool_id` that carry no request. pub(crate) fn close_idle_sessions(pool_id: u64) { let Some(this) = Self::get() else { return; }; - // See `abort_by_http_id` — `BackRef` over the process-lifetime singleton. + // Leaked Box, process-lifetime, HTTP-thread only. `close_if_idle` doesn't unregister, so `sessions` is stable. let ctx = bun_ptr::BackRef::from(this); for &s in ctx.sessions.iter() { session_mut(s).close_if_idle(pool_id); } } - - pub(crate) fn stream_body_by_http_id(async_http_id: u32, message: WriteMessageType) { - let Some(this) = Self::get() else { - return; - }; - // See `abort_by_http_id` — `BackRef` over the process-lifetime singleton. - let ctx = bun_ptr::BackRef::from(this); - for &s in ctx.sessions.iter() { - // Registry only holds live sessions — `session_mut` upgrade. - if session_mut(s).stream_body_by_http_id(async_http_id, message) { - return; - } - } - } - - pub(crate) fn resume_receive_by_http_id(async_http_id: u32) { - let Some(this) = Self::get() else { - return; - }; - let ctx = bun_ptr::BackRef::from(this); - for &s in ctx.sessions.iter() { - if session_mut(s).resume_receive_by_http_id(async_http_id) { - return; - } - } - } } diff --git a/src/http/h3_client/ClientSession.rs b/src/http/h3_client/ClientSession.rs index 2e99a6bc3e0a..ed03bff11352 100644 --- a/src/http/h3_client/ClientSession.rs +++ b/src/http/h3_client/ClientSession.rs @@ -15,9 +15,9 @@ use super::encode; use super::stream::Stream; use crate::h3_client as H3; use crate::http_thread::WriteMessageType; -use crate::internal_state::HTTPStage; use crate::signals::Field as Signal; -use crate::{HTTPClient, HeaderResult, Protocol}; +use crate::ssl_config::{SSLConfig, SharedPtr}; +use crate::{Carrier, HTTPClient, Protocol, StreamProgress}; use crate::h3_client::h3_client; @@ -34,10 +34,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 +57,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 +66,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 +81,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 +114,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(); } } @@ -131,13 +141,13 @@ impl ClientSession { /// `onStreamOpen` pops it. pub(crate) fn enqueue(&mut self, client: &mut HTTPClient) { debug_assert!(!self.closed); - client.h3 = None; client.flags.protocol = Protocol::Http3; client.allow_retry = false; let stream = Stream::new(self, client); let _ = H3::live_streams.fetch_add(1, Ordering::Relaxed); - client.h3 = Some(NonNull::new(stream).expect("Stream::new returns a fresh allocation")); + client.carrier = + Carrier::H3(NonNull::new(stream).expect("Stream::new returns a fresh allocation")); self.pending.push(stream); self.ref_(); @@ -147,52 +157,50 @@ impl ClientSession { } } - pub(crate) fn stream_body_by_http_id( - &mut self, - async_http_id: u32, - message: WriteMessageType, - ) -> bool { - for &stream_ptr in self.pending.iter() { - let stream = stream_mut(stream_ptr); - let Some(client) = stream.client else { - continue; - }; - let client = client_mut(client); - if client.async_http_id != async_http_id { - continue; - } - if let crate::HTTPRequestBody::Stream(s) = &mut client.state.original_request_body { - if message == WriteMessageType::LengthMismatch { - self.fail(stream_ptr, crate::Error::RequestBodyLengthMismatch); - return true; - } - s.ended = message == WriteMessageType::End; - if let Some(qs) = stream.qstream_mut() { - encode::drain_send_body(stream, qs); - } - } - return true; + /// The request on `stream` was aborted. + pub(crate) fn abort_stream(stream: NonNull) { + stream_mut(stream.as_ptr()) + .session_mut() + .fail(stream.as_ptr(), crate::Error::Aborted); + } + + /// The producer of the request's stream body has more bytes, or ended it. + pub(crate) fn stream_request_body(stream_ptr: NonNull, message: WriteMessageType) { + let stream = stream_mut(stream_ptr.as_ptr()); + let Some(client) = stream.client else { + return; + }; + let crate::HTTPRequestBody::Stream(body) = + &mut client_mut(client).state.original_request_body + else { + return; + }; + if message == WriteMessageType::LengthMismatch { + return stream + .session_mut() + .fail(stream_ptr.as_ptr(), crate::Error::RequestBodyLengthMismatch); + } + body.ended = message == WriteMessageType::End; + if let Some(qs) = stream.qstream_mut() { + encode::drain_send_body(stream, qs); } - false } - pub(crate) fn resume_receive_by_http_id(&mut self, async_http_id: u32) -> bool { - for &stream_ptr in self.pending.iter() { - let stream = stream_mut(stream_ptr); - let Some(client) = stream.client else { - continue; - }; - if client_mut(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); - } - } - return true; + /// The consumer of the stream's response body takes bytes again. + pub(crate) fn resume_receive(stream_ptr: NonNull) { + let stream = stream_mut(stream_ptr.as_ptr()); + let Some(client) = stream.client else { + return; + }; + let client = client_mut(client); + // 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); + } + if let Err(err) = client.send_held_body() { + stream.session_mut().fail(stream_ptr.as_ptr(), err); } - false } /// Unlink and free `stream`, releasing the ref `enqueue` took for it. @@ -214,7 +222,7 @@ impl ClientSession { let session = st.session.as_ptr(); debug_assert!(core::ptr::eq(session, self)); if let Some(cl) = st.client { - client_mut(cl).h3 = None; + client_mut(cl).carrier = Carrier::None; } st.client = None; let request_body_done = st.request_body_done; @@ -227,6 +235,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; @@ -246,8 +257,8 @@ impl ClientSession { let client = stream_mut(stream).client; self.detach_with(stream, true); if let Some(cl) = client { - // detach() nulled cl.h3 but the HTTPClient itself is alive. - client_mut(cl).fail_from_h2(err); + // detach() cleared cl.carrier but the HTTPClient itself is alive. + client_mut(cl).fail(err); } } @@ -259,7 +270,7 @@ impl ClientSession { /// stream open. pub(crate) fn retry_or_fail(&mut self, stream: *mut Stream, err: crate::Error) { // Shaped for Stacked Borrows like `fail` below — `detach()` - // re-derives `&mut HTTPClient` from the same raw ptr to null `h3`, which + // re-derives `&mut HTTPClient` from the same raw ptr to clear `carrier`, which // would invalidate any `&mut HTTPClient` held across it. Hold the raw // `client_ptr` across `detach` and only form `&mut` afterward. let st = stream_mut(stream); @@ -270,6 +281,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 { @@ -297,41 +313,39 @@ impl ClientSession { ); self.detach_with(stream, true); // Formed only after detach() so its Unique tag is not invalidated by - // detach()'s aliasing write to `client.h3`. + // detach()'s aliasing write to `client.carrier`. 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)`). } - pub(crate) fn abort_by_http_id(&mut self, async_http_id: u32) -> bool { - // `fail` mutates `pending`, so it cannot be called while the iterator - // holds `&self.pending`, and only one entry can match — so locate - // first via raw-ptr reads, then act. - let mut found: *mut Stream = core::ptr::null_mut(); - for &stream_ptr in self.pending.iter() { - // pending entries are live until detach(); `stream_ref` reads the - // Copy `client` field — no `&mut Stream` materialized. - let Some(cl) = stream_ref(stream_ptr).client else { - continue; - }; - // `Stream.client` is a live backref while attached; `ParentRef` - // reads the Copy `async_http_id` field via shared deref. - if bun_ptr::ParentRef::from(cl).async_http_id == async_http_id { - found = stream_ptr; - break; - } + /// 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 + .client + .is_some_and(|client| client_mut(client).signals.get(Signal::Aborted)) + { + return self.fail(stream, crate::Error::Aborted); } - if !found.is_null() { - self.fail(found, crate::Error::Aborted); - return true; + if st.status_code == 0 { + // A handshake failure, a reset, or a FIN that lsquic refused because no header block came first. + return self.retry_or_fail(stream, crate::Error::HTTP3StreamReset); } - false + self.fail( + stream, + 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 + /// `done` = the FIN of the peer arrived; deliver whatever is buffered then /// detach. Mirrors H2's `ClientSession.deliverStream` so the HTTPClient state /// machine sees the same call sequence regardless of transport. pub(crate) fn deliver(&mut self, stream: *mut Stream, done: bool) { @@ -342,7 +356,7 @@ impl ClientSession { } return; }; - // NB: `detach()` writes `client.h3 = None` through this same raw + // NB: `detach()` clears `client.carrier` through this same raw // backref, which pops this `&mut`'s Unique tag under Stacked Borrows — // so every `self.detach(stream)` below that is followed by further // `client` use re-derives a fresh `&mut` from `client_ptr` first. @@ -352,96 +366,38 @@ impl ClientSession { return self.fail(stream, crate::Error::Aborted); } - if st.status_code != 0 && !st.headers_delivered { - st.headers_delivered = true; - let (result, response) = match client - .apply_multiplexed_headers(u32::from(st.status_code), &st.decoded_headers) - { - Ok(r) => r, - Err(e) => return self.fail(stream, e), - }; - // `is_done()`: Content-Length: 0 can arrive as `HasBody` (e.g. an - // SSE content-type), which would make the headerProgress update - // below terminal and free `client` while the stream still holds it. - if result == HeaderResult::Finished - || (done && st.body_buffer.is_empty()) - || client.state.is_done() - { - if client.state.flags.is_redirect_pending { - self.detach(stream); - // SAFETY: re-derive — detach() invalidated the prior Unique tag. - let client = client_mut(client_ptr); - return client.do_redirect_h3(); - } - client.clone_metadata(&response); - client.state.flags.received_last_chunk = true; - if result == HeaderResult::Finished { - client.state.content_length = Some(0); - } - self.detach(stream); - // SAFETY: re-derive — detach() invalidated the prior Unique tag. - let client = client_mut(client_ptr); - return finish(client); - } - client.clone_metadata(&response); - if client.signals.get(Signal::HeaderProgress) { - client.progress_update_h3(); - } - } - - if client.state.response_stage != HTTPStage::Body { + if st.status_code == 0 { + // Only interim responses so far. if done { - // Stream closed before headers — handshake/reset failure. - return self.retry_or_fail( - stream, - if st.status_code == 0 { - crate::Error::HTTP3StreamReset - } else { - crate::Error::ConnectionClosed - }, - ); + self.retry_or_fail(stream, crate::Error::HTTP3StreamReset); } return; } - - if !st.body_buffer.is_empty() { - if done { - client.state.flags.received_last_chunk = true; - } - let report = match client.handle_response_body(st.body_buffer.as_slice(), false) { - Ok(r) => r, - Err(e) => { - st.body_buffer.clear(); - return self.fail(stream, e); + let head = (!core::mem::replace(&mut st.headers_delivered, true)) + .then_some((u32::from(st.status_code), st.decoded_headers.as_slice())); + let progress = client.receive_from_stream(head, &st.body_buffer, done); + st.body_buffer.clear(); + match progress { + Ok(StreamProgress::Open { report: false }) => {} + Ok(StreamProgress::Open { report: true }) => { + if let Err(err) = client.send_progress() { + self.fail(stream, err); } - }; - st.body_buffer.clear(); - if done { + } + Ok(StreamProgress::Complete) => { self.detach(stream); // SAFETY: re-derive — detach() invalidated the prior Unique tag. let client = client_mut(client_ptr); - return finish(client); - } - if report { - if client.state.is_done() { - self.detach(stream); - // SAFETY: re-derive — detach() invalidated the prior Unique tag. - let client = client_mut(client_ptr); - return client.progress_update_h3(); + if let Err(err) = client.send_progress() { + client.fail(err); } - client.progress_update_h3(); } - return; - } - - if done { - self.detach(stream); - // SAFETY: re-derive — detach() invalidated the prior Unique tag. - let client = client_mut(client_ptr); - if let Err(err) = client.state.finalize_body_on_eof() { - return client.fail_from_h2(err); + Ok(StreamProgress::Redirect) => { + self.detach(stream); + // SAFETY: re-derive — detach() invalidated the prior Unique tag. + client_mut(client_ptr).follow_redirect(); } - return finish(client); + Err(err) => self.fail(stream, err), } } } @@ -453,7 +409,7 @@ impl ClientSession { /// `AsyncHTTP`, which strictly outlives the `Stream`. All h3 callbacks run on /// the HTTP thread, so the returned `&mut` is the sole live borrow. Per the /// Stacked-Borrows notes in `deliver`/`retry_or_fail`, callers re-derive a -/// fresh `&mut` here after each `detach()` (which writes `client.h3 = None` +/// fresh `&mut` here after each `detach()` (which clears `client.carrier` /// through this same raw backref) rather than holding one across it. /// Routes through the crate-wide [`HTTPClient::from_erased_backref`] accessor /// (also used by `encode.rs` / `PendingConnect.rs` for the same backref). @@ -530,15 +486,6 @@ pub(super) fn session_mut<'a>(p: *mut ClientSession) -> &'a mut ClientSession { unsafe { &mut *p } } -fn finish(client: &mut HTTPClient) { - if let Some(cl) = client.state.content_length { - if client.state.total_body_received != cl { - return client.fail_from_h2(crate::Error::HTTP3ContentLengthMismatch); - } - } - client.progress_update_h3(); -} - impl Drop for ClientSession { fn drop(&mut self) { debug_assert!(self.pending.is_empty()); 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..6a9801f68db4 100644 --- a/src/http/h3_client/callbacks.rs +++ b/src/http/h3_client/callbacks.rs @@ -16,9 +16,7 @@ use super::client_context::ClientContext; use super::client_session::{ClientSession, session_mut, stream_mut, stream_ref}; use super::encode; use super::stream::Stream; -use crate::h2_client::dispatch::{ - is_malformed_response_field, is_malformed_response_value, trim_response_value, -}; +use crate::h2_client::dispatch::{is_malformed_response_field, trim_response_value}; use crate::h3_client as H3; use bun_picohttp as picohttp; @@ -144,6 +142,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. @@ -210,7 +211,7 @@ extern "C" fn on_stream_open(s: *mut quic::Stream, is_client: c_int) { // priority iterator, which serves the crypto stream first. This mirrors // lsquic's reference `bin/http_client.c`, whose `on_new_stream` only // calls `lsquic_stream_wantwrite(stream, 1)`. - s.want_write(true); + s.want_write(); } extern "C" fn on_stream_headers(s: *mut quic::Stream) { @@ -226,6 +227,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,14 +238,17 @@ 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; } - if stream.status_code == 0 - && (is_malformed_response_field(name) || is_malformed_response_value(value)) - { + if stream.status_code == 0 && is_malformed_response_field(name, value) { session.fail(stream, crate::Error::HTTP3ProtocolError); return; } @@ -278,17 +283,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 +322,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 352cb2a97486..ce572a9ab233 100644 --- a/src/http/h3_client/encode.rs +++ b/src/http/h3_client/encode.rs @@ -43,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( @@ -138,14 +143,15 @@ pub(crate) fn write_request( // For streaming bodies the JS sink waits for can_stream to start // pumping; report progress now so it begins. if is_streaming { - client.progress_update_h3(); + // Nothing of the response is here to decode, so this can't fail. + let _ = client.send_progress(); } Ok(()) } /// Push as much of the request body onto `qs` as flow control allows. Called /// from `write_request`, `callbacks.on_stream_writable`, and -/// `ClientSession.stream_body_by_http_id` (when the JS sink delivers more bytes). +/// `ClientSession::stream_request_body` (when the JS sink delivers more bytes). pub(crate) fn drain_send_body(stream: &mut Stream, qs: &mut quic::Stream) { if stream.request_body_done { return; @@ -185,7 +191,7 @@ pub(crate) fn drain_send_body(stream: &mut Stream, qs: &mut quic::Stream) { qs.shutdown(); client.state.request_stage = HTTPStage::Done; } else if !drained { - qs.want_write(true); + qs.want_write(); } else if data_len > 0 { locked.report_drain(); } @@ -211,6 +217,6 @@ pub(crate) fn drain_send_body(stream: &mut Stream, qs: &mut quic::Stream) { qs.shutdown(); client.state.request_stage = HTTPStage::Done; } else { - qs.want_write(true); + qs.want_write(); } } diff --git a/src/http/lib.rs b/src/http/lib.rs index 8e7908f7135f..865bcd36539b 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -12,7 +12,6 @@ pub mod compress_body; pub mod decompressor; #[path = "H2Client.rs"] pub mod h2_client; -pub use bun_http_types::h2 as h2_frame_parser; #[path = "H3Client.rs"] pub mod h3_client; #[path = "HeaderBuilder.rs"] @@ -85,7 +84,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)] @@ -236,7 +235,8 @@ pub struct Flags { pub(crate) protocol: Protocol, pub forced_protocol: Option, pub(crate) h3_retried: bool, - pub is_node_http_client: 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 redirect_credentials: RedirectCredentialsPolicy, /// While all connections are taken, this request lets the ones without the flag go first. pub yields_to_other_requests: bool, @@ -262,7 +262,7 @@ impl Default for Flags { protocol: Protocol::Http1_1, forced_protocol: None, h3_retried: false, - is_node_http_client: false, + h3_alt_svc_failed: false, redirect_credentials: RedirectCredentialsPolicy::SameOrigin, yields_to_other_requests: false, } @@ -280,8 +280,6 @@ pub static EXPERIMENTAL_HTTP2_CLIENT_FROM_CLI: AtomicBool = AtomicBool::new(fals /// rules as the http2 flag. pub static EXPERIMENTAL_HTTP3_CLIENT_FROM_CLI: AtomicBool = AtomicBool::new(false); -const MAX_REDIRECT_URL_LENGTH: usize = 128 * 1024; - /// The static is exported to /// C++ via `BUN_DEFAULT_MAX_HTTP_HEADER_SIZE`; `AtomicUsize` has the same /// size/alignment as `usize` so the symbol layout is unchanged. @@ -327,8 +325,6 @@ pub static OVERRIDDEN_DEFAULT_USER_AGENT: std::sync::OnceLock<&'static [u8]> = /// absolute deadline for the header block to complete (undici `headersTimeout` /// semantics). 0 disables the timer (matching `disable_timeout = true`). /// Overridable via `BUN_CONFIG_HTTP_IDLE_TIMEOUT`. Default is 5 minutes. -/// `HTTPThread::on_start` stores it padded for the timer-wheel sweep (see -/// [`normalize_idle_timeout_seconds`]). pub(crate) static IDLE_TIMEOUT_SECONDS: AtomicU32 = AtomicU32::new(300); /// Safe accessor for [`IDLE_TIMEOUT_SECONDS`]. @@ -337,33 +333,39 @@ pub(crate) fn idle_timeout_seconds() -> c_uint { IDLE_TIMEOUT_SECONDS.load(Ordering::Relaxed) } -/// Normalise an idle timeout (seconds) for uSockets' timer wheels. The sweep -/// phase is unrelated to when a socket arms its timer, so a timer armed for N -/// ticks can fire up to one period (4s short wheel, 60s long wheel) before -/// the requested duration (#39952). Pad by one period so it never fires -/// early, and clamp so the padded value stays at the long wheel's 239 min -/// maximum. 0 = disabled. -#[inline] -pub fn normalize_idle_timeout_seconds(raw: u64) -> c_uint { - if raw == 0 { - return 0; - } - /// `LIBUS_TIMEOUT_GRANULARITY` (packages/bun-usockets/src/libusockets.h). - const SHORT_WHEEL_PERIOD_SECONDS: u64 = 4; - const LONG_WHEEL_PERIOD_SECONDS: u64 = 60; - /// `SocketTimeout::set_timeout` routes values above this to the long wheel. - const SHORT_WHEEL_MAX_SECONDS: u64 = 240; - /// The long counter wraps `% 240` minutes; one minute of pad stays below. - const MAX_RAW_SECONDS: u64 = 238 * LONG_WHEEL_PERIOD_SECONDS; - let raw = raw.min(MAX_RAW_SECONDS); - (if raw + SHORT_WHEEL_PERIOD_SECONDS > SHORT_WHEEL_MAX_SECONDS { - (raw.div_ceil(LONG_WHEEL_PERIOD_SECONDS) + 1) * LONG_WHEEL_PERIOD_SECONDS - } else { - raw + SHORT_WHEEL_PERIOD_SECONDS - }) as c_uint +pub const END_OF_CHUNKED_HTTP1_1_ENCODING_RESPONSE_BODY: &[u8] = b"0\r\n\r\n"; + +/// Drops what a peer can send between two responses without losing framing: a repeated or late last chunk, or one empty line. +fn skip_between_responses(bytes: &[u8]) -> &[u8] { + bytes + .strip_prefix(END_OF_CHUNKED_HTTP1_1_ENCODING_RESPONSE_BODY) + .or_else(|| bytes.strip_prefix(b"\r\n")) + .unwrap_or(bytes) } -pub const END_OF_CHUNKED_HTTP1_1_ENCODING_RESPONSE_BODY: &[u8] = b"0\r\n\r\n"; +/// Stop reusing a connection this long before the origin's `Keep-Alive: timeout=N` runs out (undici's `keepAliveTimeoutThreshold`). +const KEEPALIVE_TIMEOUT_MARGIN_MS: u64 = 2000; + +/// 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 +} /// HTTP-thread-only scratch buffer for building NUL-terminated hostnames. pub(crate) static TEMP_HOSTNAME: bun_core::RacyCell<[u8; 8192]> = @@ -397,60 +399,82 @@ pub(crate) fn h3_alt_svc_enabled() -> bool { .unwrap_or(false) } -#[derive(Copy, Clone, PartialEq, Eq)] -enum ShouldContinue { - ContinueStreaming, - Finished, -} - -/// Return of `apply_headers` in the h2/h3 client sessions: did the headers -/// terminate the response (HEAD, 204/304, END_STREAM) or is a body expected? +/// What `handle_response_metadata` read from a response head. #[derive(Copy, Clone, Eq, PartialEq)] -pub(crate) enum HeaderResult { +enum HeaderResult { HasBody, + /// HEAD, 204, 304, `Content-Length: 0`: the head is the whole response. Finished, + /// `url`, `method` and the headers are the next hop's already. The caller lets go of the request and follows. + Redirect, +} + +/// What a session does with a stream after [`HTTPClient::receive_from_stream`]. +pub(crate) enum StreamProgress { + /// The response goes on. With `report`, call `send_progress`. + Open { report: bool }, + /// The last byte has arrived. Detach the stream, then call `send_progress`. + Complete, + /// Detach the stream, then call `follow_redirect`. + Redirect, } impl HTTPClient<'_> { - /// Shared body of `apply_headers` for the h2/h3 client sessions: hand a - /// pre-decoded multiplexed response (HPACK / QPACK) to the HTTP/1.1 - /// metadata pipeline (`handle_response_metadata` + `clone_metadata`), then - /// undo the HTTP/1.1-specific framing decisions that don't apply when the - /// transport delimits the body (h2 DATA frames / h3 STREAM frames). - /// - /// Returns the (possibly-mutated) response so the caller can pass it to - /// `clone_metadata` once the redirect decision has been made; the borrow of - /// `headers` flows through, so the deep copy is checked by the compiler - /// rather than by a call-ordering contract. - #[inline] - fn apply_multiplexed_headers<'h>( + /// Takes what an HTTP/2 or HTTP/3 stream received since the last call: the final response head (once), DATA, and the end of the stream. + pub(crate) fn receive_from_stream( &mut self, - status_code: u32, - headers: &'h [picohttp::Header], - ) -> crate::Result<(HeaderResult, picohttp::Response<'h>)> { - let mut response = picohttp::Response { - minor_version: 0, - status_code, - status: b"", - headers: picohttp::HeaderList { list: headers }, - bytes_read: 0, - }; - let should_continue = self.handle_response_metadata(&mut response)?; - // h2/h3 framing delimits the body; chunked transfer-encoding and the - // HTTP/1.x persistence rules (no Content-Length ⇒ no keep-alive, and the - // HTTP/1.0 default that the synthetic `minor_version: 0` above trips) - // don't apply. - self.state.transfer_encoding = Encoding::Identity; - if self.state.response_stage == ResponseStage::BodyChunk { + head: Option<(u32, &[picohttp::Header])>, + body: &[u8], + ended: bool, + ) -> crate::Result { + let mut report = false; + if let Some((status_code, headers)) = head { + let mut response = picohttp::Response { + minor_version: 0, + status_code, + status: b"", + headers: picohttp::HeaderList { list: headers }, + bytes_read: 0, + }; + let result = self.handle_response_metadata(&mut response)?; + if result == HeaderResult::Redirect { + return Ok(StreamProgress::Redirect); + } + // The stream's frames delimit the body, so HTTP/1's chunked coding and persistence rules don't apply. + self.state.transfer_encoding = Encoding::Identity; self.state.response_stage = ResponseStage::Body; + self.state.flags.allow_keepalive = true; + self.clone_metadata(&response); + if result == HeaderResult::Finished { + // HEAD, 204, 304: no body is expected, whatever Content-Length says. + self.state.content_length = Some(0); + } + report = self.signals.get(signals::Field::HeaderProgress); + } + debug_assert!(self.state.response_stage == ResponseStage::Body); + + // RFC 9113 §8.1.1, RFC 9114 §4.1.2: DATA that doesn't add up to Content-Length is malformed. + if self.state.content_length.is_some_and(|length| { + let left = length.saturating_sub(self.state.total_body_received); + body.len() > left || (ended && body.len() < left) + }) { + return Err(if self.flags.protocol == Protocol::Http3 { + crate::Error::HTTP3ContentLengthMismatch + } else { + crate::Error::HTTP2ContentLengthMismatch + }); } - self.state.flags.allow_keepalive = true; - let result = if should_continue == ShouldContinue::Finished { - HeaderResult::Finished + if !body.is_empty() { + self.state.flags.received_last_chunk = ended; + report |= self.handle_response_body(body, false)?; + } else if ended { + self.end_body()?; + } + Ok(if self.state.is_done() { + StreamProgress::Complete } else { - HeaderResult::HasBody - }; - Ok((result, response)) + StreamProgress::Open { report } + }) } } @@ -479,6 +503,10 @@ pub struct HTTPClientResult<'a> { /// Set once ALPN selected h2 so the JS side writes raw bytes into the /// streaming-body buffer instead of chunked-encoding them. pub is_http2: bool, + /// The client decodes the body, so `Content-Encoding` no longer describes it. + pub did_decompress: bool, + /// Nothing about the response changed: another request waits for the slot this one holds with its unread body. + pub slot_wanted: bool, pub fail: Option, /// Raw `getaddrinfo(3)` return code when `fail` is `DNSResolveFailed`; @@ -500,8 +528,8 @@ pub struct HTTPClientResult<'a> { /// If is not chunked encoded and Content-Length is not provided this will be unknown pub body_size: BodySize, pub certificate_info: Option, - /// `errno` of the failed `connect(2)` when `fail` is `ConnectionRefused`; 0 otherwise. - pub connect_errno: i32, + /// `errno` of the failed `connect(2)` when `fail` is `ConnectionRefused`. + pub connect_errno: Option, /// The proxy's reply to CONNECT when `fail` is `ProxyConnectFailed`. Kept /// apart from `metadata`, which is only ever the origin's response head. /// Boxed: it is large and rare, and every result is moved and dropped @@ -515,10 +543,19 @@ pub struct HTTPClientResult<'a> { #[derive(Clone, Copy, Default, PartialEq, Eq)] pub struct PoolOptions { pub id: u64, - pub idle_timeout_seconds: u32, + pub idle_timeout_ms: u32, pub max_idle_sockets: u16, } +impl PoolOptions { + pub(crate) fn idle_timeout_ms_or_default(self) -> u32 { + match self.idle_timeout_ms { + 0 => 5 * 60 * 1000, + ms => ms, + } + } +} + impl<'a> HTTPClientResult<'a> { pub fn abort_reason(&self) -> Option { if self.is_timeout() { @@ -655,13 +692,6 @@ impl<'a> ThreadlocalAsyncHTTP<'a> { } } -/// `socket: anytype` in `set_timeout` — minimal trait for what the body calls. -trait SocketTimeout { - /// Seconds-granularity idle timer. Values >240s are routed onto uSockets' - /// minute-granularity long-timeout wheel; ≤240s use the short-tick timer. - fn set_timeout(&self, seconds: core::ffi::c_uint); -} - // lowercase hash header names so that we can be sure pub(crate) fn hash_header_name(name: &[u8]) -> u64 { // Uses the std Wyhash algorithm; safe — @@ -750,6 +780,24 @@ impl ProxySettings { } } +/// What connects a started request to its peer. `HTTPThread` routes the JS thread's messages by it. +#[derive(Copy, Clone)] +pub(crate) enum Carrier { + /// Not dialed yet, or the response has ended and its body waits for the consumer. + None, + /// HTTP/1.1, from the dial on. Also the first request of an HTTP/2 connection until ALPN answers. + Tcp(http_context::HTTPSocket), + Tls(http_context::HTTPSocket), + /// In `PendingConnect::waiters`, until another request's handshake tells whether the origin speaks HTTP/2. + H2Connect, + /// In the session's `pending_attach`. + H2Parked(NonNull), + /// The session owns the stream, and detaches it before the request's final result. + H2(NonNull), + /// Same for an HTTP/3 session. + H3(NonNull), +} + // TODO: reduce the size of this struct // Many of these fields can be moved to a packed struct and use less space // @@ -760,7 +808,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>, @@ -777,7 +825,7 @@ pub struct HTTPClient<'a> { pub(crate) redirect: Vec, /// The previous hop's `redirect` buffer, parked by `handle_response_metadata` /// when it overwrites `redirect`. `connected_url` may still borrow from it - /// until `do_redirect` has released the socket, so it is freed there rather + /// until `follow_redirect`, after the socket's release, so it is freed there rather /// than at the assignment site. Also freed in `Drop` for error paths. pub(crate) prev_redirect: Vec, // Non-owning back-reference to a `Progress::Node` owned by the caller; @@ -787,8 +835,7 @@ pub struct HTTPClient<'a> { pub flags: Flags, /// Per-request override of the global [`IDLE_TIMEOUT_SECONDS`], set from - /// `fetch(url, { timeout: })`. Already normalised (see - /// [`normalize_idle_timeout_seconds`]). `None` = use the global default. + /// `fetch(url, { timeout: })`. `None` = use the global default. pub(crate) idle_timeout_seconds: Option, pub(crate) state: InternalState<'a>, @@ -815,19 +862,13 @@ pub struct HTTPClient<'a> { /// moved to the keep-alive pool with the socket. The pointee is also /// recovered raw from the SSLWrapper callback `ctx`, hence intrusive. pub(crate) proxy_tunnel: Option>, - /// Set when this request is bound to a stream on an HTTP/2 session. - /// Owned by the session; cleared by the session when the stream completes. - pub(crate) h2: Option>, - /// Set when this request is bound to an HTTP/3 stream. Owned by the H3 - /// session; cleared by the session when the stream completes. - pub(crate) h3: Option>, + pub(crate) carrier: Carrier, /// Set while this request is the leader of a fresh TLS connect that other /// h2-capable requests have coalesced onto. Resolved (and freed) once ALPN /// is known or the connect fails. Backref into the owning /// `HTTPContext.pending_h2_connects` Vec — not an owned Box. pub(crate) pending_h2: Option>, pub(crate) signals: Signals, - pub(crate) async_http_id: u32, pub(crate) unix_socket_path: &'a [u8], /// `fetch({ compress })` — when set, the body is compressed lazily at /// write time (h1: `send_initial_request_payload`; h2/h3: at attach) so @@ -881,10 +922,7 @@ impl<'a> HTTPClient<'a> { impl Drop for HTTPClient<'_> { fn drop(&mut self) { - self.close_proxy_tunnel(false); - // The session detaches `h2` before any terminal callback, so this should - // be None by the time the result callback's deinit path runs. - debug_assert!(self.h2.is_none()); + debug_assert!(matches!(self.carrier, Carrier::None)); } } @@ -929,12 +967,6 @@ pub(crate) fn http_thread_mut() -> &'static mut HTTPThread { http_thread() } -// TODO: this needs to be freed when Worker Threads are implemented -// HTTP-thread-only; `RacyCell` is the alias-safe static cell. -pub(crate) static SOCKET_ASYNC_HTTP_ABORT_TRACKER: bun_core::RacyCell< - Option>, -> = bun_core::RacyCell::new(None); - // ═══════════════════════════════════════════════════════════════════════ // Prelude: imports, constants, helper fns, and bridge impls the // `impl HTTPClient` state machine needs. Kept separate from the head/tail @@ -945,11 +977,10 @@ use core::ffi::c_uint; use bstr::BStr; use bun_boringssl as boringssl; -use bun_collections::{ArrayHashMap, VecExt}; -use bun_core::StringBuilder; +use bun_collections::VecExt; use bun_core::fmt::{EscapeControlChars, escape_control_chars}; use bun_core::{FeatureFlags, Global, Output}; -use bun_core::{StringView, Tag as BunStringTag, strings}; +use bun_core::{StringView, strings}; use bun_http_types::ETag::StringPointer; use bun_uws as uws; // the std Wyhash algorithm, not Wyhash11. @@ -1079,13 +1110,17 @@ impl RedirectHop { // 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>> = + bun_core::RacyCell::new(None); // the first packet for Transfer-Encoding: chunked // is usually pretty small or sometimes even just a length @@ -1103,14 +1138,16 @@ 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] { // SAFETY: see module-level INVARIANT. - unsafe { &mut *SHARED_RESPONSE_HEADERS_BUF.get() } + unsafe { &mut *SHARED_RESPONSE_HEADERS_BUF.get() }.get_or_insert_with(|| { + vec![picohttp::Header::ZERO; MAX_RESPONSE_HEADERS].into_boxed_slice() + }) } #[inline] pub(super) fn single_packet_small_buffer() -> &'static mut [u8; 16 * 1024] { @@ -1178,48 +1215,6 @@ pub fn configure_http_client_with_alpn( // ── EntryList column accessors ────────────────────────────────────────── use bun_http_types::ETag::HeaderEntryColumns; -impl SocketTimeout for HttpSocket { - fn set_timeout(&self, seconds: c_uint) { - uws::NewSocketHandler::::set_timeout(self, seconds) - } -} - -/// Borrow the HTTP-thread abort tracker. PORTING.md §Global mutable state: -/// HTTP-thread-only, so the `&'static mut` is the unique live borrow at every -/// call site. Callers must not hold the result across a call that re-enters -/// this accessor (per-statement reborrow shape — same contract the prior -/// `*mut` API imposed, now centralized here so 5 call sites drop their -/// `unsafe` block). -#[inline] -fn abort_tracker() -> &'static mut ArrayHashMap { - // SAFETY: same single-thread invariant as http_thread(). Every call site - // is a per-statement reborrow (audited in r3); no two `&mut` overlap. - unsafe { (*SOCKET_ASYNC_HTTP_ABORT_TRACKER.get()).get_or_insert_with(ArrayHashMap::new) } -} - -/// Remove every abort-tracker entry whose stored socket is `socket`. -/// -/// Backstop for the per-client `unregister_abort_tracker()` calls: when -/// `Handler::on_close` fires on a socket whose ext has already been retagged -/// to `DeadSocket`/`PooledSocket`, the client/session dispatch is skipped and -/// any stale entry would survive into `us_internal_free_closed_sockets`, -/// leaving `drain_queued_shutdowns` to dereference freed memory on a later -/// abort. O(n) over live abortable requests; no-op on a `Detached` socket. -fn unregister_abort_tracker_for_socket(socket: uws::InternalSocket) { - if socket.is_detached() { - return; - } - let tracker = abort_tracker(); - let mut i = 0usize; - while i < tracker.count() { - if *tracker.values()[i].socket() == socket { - let _ = tracker.swap_remove_at(i); - } else { - i += 1; - } - } -} - /// Returns the hostname to use for TLS SNI and certificate verification. /// Priority: tls_props.server_name > client.url.hostname. The Host request /// header is an HTTP field only and never selects the TLS peer identity. @@ -1300,15 +1295,7 @@ fn write_proxy_auth_and_headers( } } -fn validate_request_target(target: &[u8]) -> crate::Result<()> { - if target.iter().any(|&byte| byte <= 0x20 || byte == 0x7f) { - return Err(crate::Error::InvalidURL); - } - Ok(()) -} - -fn write_proxy_connect(writer: &mut Vec, client: &HTTPClient) -> crate::Result<()> { - validate_request_target(client.url.href)?; +fn write_proxy_connect(writer: &mut Vec, client: &HTTPClient) { let port: &[u8] = if client.url.get_port().is_some() { client.url.port } else if client.url.is_https() { @@ -1323,7 +1310,6 @@ fn write_proxy_connect(writer: &mut Vec, client: &HTTPClient) -> crate::Resu client.proxy_authorization.as_deref(), client.proxy_headers.as_ref(), ); - Ok(()) } /// Writes the proxy CONNECT request. Shared by `fetch` and the WebSocket client. @@ -1360,12 +1346,7 @@ pub fn write_connect_request( writer.extend_from_slice(b"\r\n"); } -fn write_proxy_request( - writer: &mut Vec, - request: &picohttp::Request<'_>, - client: &HTTPClient, -) -> crate::Result<()> { - validate_request_target(client.url.href)?; +fn write_proxy_request(writer: &mut Vec, request: &picohttp::Request<'_>, client: &HTTPClient) { writer.extend_from_slice(request.method); // will always be http:// here, https:// needs CONNECT tunnel writer.extend_from_slice(b" http://"); @@ -1396,11 +1377,9 @@ fn write_proxy_request( } writer.extend_from_slice(b"\r\n"); - Ok(()) } -fn write_request(writer: &mut Vec, request: &picohttp::Request<'_>) -> crate::Result<()> { - validate_request_target(request.path)?; +fn write_request(writer: &mut Vec, request: &picohttp::Request<'_>) { writer.extend_from_slice(request.method); writer.extend_from_slice(b" "); writer.extend_from_slice(request.path); @@ -1414,7 +1393,6 @@ fn write_request(writer: &mut Vec, request: &picohttp::Request<'_>) -> crate } writer.extend_from_slice(b"\r\n"); - Ok(()) } #[cold] @@ -1430,9 +1408,7 @@ pub(crate) fn print_request( let request_ = picohttp::Request { method: request.method, path: url, - minor_version: request.minor_version, headers: request.headers, - bytes_read: request.bytes_read, }; bun_core::pretty_errorln!("{}", request_.curl(ignore_insecure, body)); } @@ -1442,8 +1418,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)), @@ -1456,12 +1435,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(); @@ -1650,20 +1630,18 @@ impl<'a> HTTPClient<'a> { pub(crate) fn proxy_tunnel_ptr(&self) -> Option> { self.proxy_tunnel.as_ref().map(|p| p.as_non_null()) } - /// Detach the proxy tunnel, if one is attached, and drop this client's ref + /// Shut the proxy tunnel down, if one is attached, and drop this client's ref /// on it. #[inline] - pub(crate) fn close_proxy_tunnel(&mut self, shutdown: bool) { + fn close_proxy_tunnel(&mut self) { if let Some(t) = self.proxy_tunnel.take() { - if shutdown { - proxy_tunnel::ProxyTunnel::shutdown(t.as_non_null()); - } - proxy_tunnel::raw_as_mut(t.as_ptr()).detach_socket(); + proxy_tunnel::ProxyTunnel::shutdown(t.as_non_null()); } } - /// Common tail of `fail` / `fail_from_h2` / `complete_connecting_process`: + /// Common tail of `fail` / `complete_connecting_process`: /// build the result, reset request state, and dispatch the callback. - fn dispatch_result_and_reset(&mut self, clear_proxy_tunneling: bool) { + fn dispatch_result_and_reset(&mut self) { + self.report_body_ahead_of_failure(); let callback = self.result_callback; let result = self.to_result(); self.state.reset(); @@ -1675,16 +1653,26 @@ impl<'a> HTTPClient<'a> { // on a still-reachable reference (socket tag, timer, tracker entry) // hits the `stage != Done && stage != Fail` guards and becomes a // no-op instead of delivering a second final result into freed - // memory. The success path (`send_progress_update_without_stage_check`) + // memory. The success path (`send_result`) // already restores `Stage::Done` the same way after its reset. self.state.request_stage = RequestStage::Fail; self.state.response_stage = ResponseStage::Fail; self.state.stage = Stage::Fail; - if clear_proxy_tunneling { - self.flags.proxy_tunneling = false; - } + self.flags.proxy_tunneling = false; 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 @@ -1707,6 +1695,84 @@ impl<'a> HTTPClient<'a> { } } +/// Messages from the JS thread. They take the pointer because a session re-derives `&mut HTTPClient` from its own backref. +impl HTTPClient<'static> { + pub(crate) fn abort(this: NonNull) { + let client = Self::from_erased_backref(this); + match client.carrier { + Carrier::Tcp(socket) => client.close_and_abort::(socket), + Carrier::Tls(socket) => client.close_and_abort::(socket), + Carrier::H2Connect => { + Self::ssl_ctx_mut(client.get_ssl_ctx::()).remove_pending_h2_waiter(this); + client.fail(crate::Error::Aborted); + } + Carrier::H2Parked(session) => h2::ClientSession::abort_parked(session, this), + Carrier::H2(stream) => h2::ClientSession::abort_stream(stream), + Carrier::H3(stream) => h3::ClientSession::abort_stream(stream), + Carrier::None => client.fail(crate::Error::Aborted), + } + } + + /// The producer of the request's stream body has more bytes, or ended it. + pub(crate) fn request_stream_message( + this: NonNull, + message: http_thread::WriteMessageType, + ) { + let client = Self::from_erased_backref(this); + match client.carrier { + Carrier::Tcp(socket) => client.on_request_stream_message::(message, socket), + Carrier::Tls(socket) => client.on_request_stream_message::(message, socket), + Carrier::H2(stream) => h2::ClientSession::stream_request_body(stream, message), + Carrier::H3(stream) => h3::ClientSession::stream_request_body(stream, message), + Carrier::None | Carrier::H2Connect | Carrier::H2Parked(_) => {} + } + } + + /// The consumer of the response body takes bytes again. + pub(crate) fn receive_resumed(this: NonNull) { + let client = Self::from_erased_backref(this); + match client.carrier { + Carrier::Tcp(socket) => client.resume_receive::(socket), + Carrier::Tls(socket) => client.resume_receive::(socket), + Carrier::H2(stream) => h2::ClientSession::resume_receive(stream), + Carrier::H3(stream) => h3::ClientSession::resume_receive(stream), + Carrier::None => { + if let Err(err) = client.send_held_body() { + client.fail(err); + } + } + Carrier::H2Connect | Carrier::H2Parked(_) => {} + } + } + + /// Tells the owner of a response whose body nobody is taking that a request waits for its slot. + pub(crate) fn report_slot_wanted(this: NonNull) { + let client = Self::from_erased_backref(this); + let in_body = matches!( + client.state.response_stage, + ResponseStage::Body | ResponseStage::BodyChunk + ); + if !in_body || !client.signals.is_body_unread() { + return; + } + let mut result = client.to_result(); + result.slot_wanted = true; + client + .result_callback + .run(client.parent_async_http(), result); + } + + /// The JS `checkServerIdentity` callback approved the certificate. + pub(crate) fn cert_check_passed(this: NonNull) { + let client = Self::from_erased_backref(this); + match client.carrier { + Carrier::Tcp(socket) => client.resume_after_cert_check::(socket), + Carrier::Tls(socket) => client.resume_after_cert_check::(socket), + _ => {} + } + } +} + // ───────────────────────────── impl HTTPClient ───────────────────────────── impl<'a> HTTPClient<'a> { @@ -1752,7 +1818,7 @@ impl<'a> HTTPClient<'a> { } /// `PooledSocket::target_hostname` for a TLS unix entry. Read from - /// `connected_url`: `do_redirect` releases after `url` has moved on. + /// `connected_url`: a redirect releases after `url` has moved on. pub(crate) fn unix_tls_hostname(&self) -> &[u8] { if IS_SSL && !self.unix_socket_path.is_empty() { self.connected_url.hostname @@ -1823,8 +1889,7 @@ impl<'a> HTTPClient<'a> { self.state.flags.is_waiting_for_cert_check = true; // we inform the user that the cert is invalid - let ctx = self.get_ssl_ctx::(); - self.progress_update::(ctx, socket); + self.progress_update::(socket); // continue until we are aborted or not return true; } else if !enforce { @@ -1849,26 +1914,21 @@ impl<'a> HTTPClient<'a> { true } - pub(crate) fn register_abort_tracker( - &mut self, - socket: HttpSocket, - ) { - if self.signals.aborted.is_some() { - let any = if IS_SSL { - uws::AnySocket::SocketTls(uws::SocketTLS::from_any(socket.socket)) - } else { - uws::AnySocket::SocketTcp(uws::SocketTCP::from_any(socket.socket)) - }; - // SAFETY: HTTP-thread only; per-statement reborrow. - let _ = abort_tracker().put(self.async_http_id, any); - } + /// The request has an `Upgrade` header that isn't for HTTP/2, so a 101 hands the connection to the caller. + pub fn expect_upgrade(&mut self) { + self.flags.upgrade_state = HTTPUpgradeState::Pending; } - pub(crate) fn unregister_abort_tracker(&mut self) { - if self.signals.aborted.is_some() { - // SAFETY: HTTP-thread only; per-statement reborrow. - let _ = abort_tracker().swap_remove(&self.async_http_id); - } + pub(crate) fn set_socket(&mut self, socket: HttpSocket) { + self.carrier = if IS_SSL { + Carrier::Tls(uws::SocketTLS::from_any(socket.socket)) + } else { + Carrier::Tcp(uws::SocketTCP::from_any(socket.socket)) + }; + } + + fn is_on_stream(&self) -> bool { + matches!(self.carrier, Carrier::H2(_) | Carrier::H3(_)) } /// Runs once per request: for a new connection via [`Self::on_connect`], @@ -1884,7 +1944,7 @@ impl<'a> HTTPClient<'a> { debug_assert!(IS_SSL == self.url.is_https()); } } - self.register_abort_tracker::(socket); + self.set_socket::(socket); bun_core::scoped_log!(fetch, "Connected {} \n", BStr::new(self.url.href)); // Arm the idle timer immediately so a stalled TLS handshake (server @@ -2106,15 +2166,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) { @@ -2177,10 +2238,47 @@ impl<'a> HTTPClient<'a> { } pub(crate) fn retry_from_h2(&mut self) { - debug_assert!(self.h2.is_none()); - self.unregister_abort_tracker(); - self.flags.protocol = Protocol::Http1_1; + debug_assert!(!self.is_on_stream()); 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.is_on_stream()); + 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.is_on_stream()); + if self.flags.forced_protocol == Some(Protocol::Http3) + || self.state.request_stage != RequestStage::Pending + { + return self.fail(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.carrier = Carrier::None; + self.flags.protocol = Protocol::Http1_1; let body = core::mem::replace( &mut self.state.original_request_body, HTTPRequestBody::Bytes(b""), @@ -2189,32 +2287,8 @@ impl<'a> HTTPClient<'a> { self.start(body); } - /// Called by the HTTP/2 session for stream-level termination (RST_STREAM, - /// GOAWAY, abort, decode error). The socket stays up for sibling streams, so - /// only the request fails. - pub(crate) fn fail_from_h2(&mut self, err: crate::Error) { - debug_assert!(self.h2.is_none()); - debug_assert!(self.h3.is_none()); - self.unregister_abort_tracker(); - if self.state.stage != Stage::Done && self.state.stage != Stage::Fail { - self.state.request_stage = RequestStage::Fail; - self.state.response_stage = ResponseStage::Fail; - self.state.fail = Some(err); - self.state.stage = Stage::Fail; - if self - .flags - .defer_terminal_dispatch_until_connecting_is_complete - { - return; - } - self.dispatch_result_and_reset(false); - } - } - fn is_in_progress(&self) -> bool { - self.state.stage != Stage::Done - && self.state.stage != Stage::Fail - && !self.state.flags.is_redirect_pending + self.state.stage != Stage::Done && self.state.stage != Stage::Fail } /// Whether `on_close` sends the request again: it went out on a reused @@ -2227,50 +2301,41 @@ impl<'a> HTTPClient<'a> { self.is_in_progress() && self.allow_retry && self.method.is_idempotent() - // Only a Bytes body can be rebuilt from `original_request_body`. - // Stream/Sendfile bodies are consumed as they are written, so a - // retry would silently replay a truncated request. - && matches!(self.state.original_request_body, HTTPRequestBody::Bytes(_)) + && self.request_body_from_start().is_some() && self.state.response_stage != ResponseStage::Body && self.state.response_stage != ResponseStage::BodyChunk } - pub(crate) fn on_close(&mut self, socket: HttpSocket) { + pub(crate) fn on_close(&mut self) { bun_core::scoped_log!(fetch, "Closed {}\n", BStr::new(self.url.href)); - // the socket is closed, we need to unregister the abort tracker - self.unregister_abort_tracker(); + self.carrier = Carrier::None; + // `start_` is on the stack. It finds the socket closed and calls this again. + if self + .flags + .defer_terminal_dispatch_until_connecting_is_complete + { + return; + } if self.signals.get(signals::Field::Aborted) { self.fail(crate::Error::Aborted); return; } - self.close_proxy_tunnel(true); + self.close_proxy_tunnel(); let in_progress = self.is_in_progress(); - if self.state.flags.is_redirect_pending { - // if the connection is closed and we are pending redirect just do the redirect - // in this case we will re-connect or go to a different socket if needed - let ctx = self.get_ssl_ctx::(); - self.do_redirect::(ctx, socket); - return; - } if in_progress && self.state.is_body_complete_on_close() { - if let Err(err) = self.state.finalize_body_on_eof() { + if let Err(err) = self.end_body().and_then(|()| self.send_progress()) { self.fail(err); - return; } - let ctx = self.get_ssl_ctx::(); - self.progress_update::(ctx, socket); return; } - if self.retries_on_close() { + if self.retries_on_close() + && let Some(body) = self.request_body_from_start() + { self.allow_retry = false; // we need to retry the request, clean up the response message buffer and start again self.state.response_message_buffer = MutableString::default(); - let body = core::mem::replace( - &mut self.state.original_request_body, - HTTPRequestBody::Bytes(b""), - ); self.start(body); return; } @@ -2284,11 +2349,6 @@ impl<'a> HTTPClient<'a> { if self.flags.disable_timeout { return; } - // A fully received body that waits on its consumer expects nothing from the socket. - if self.state.has_pending_compressed() && self.state.is_done() { - socket.set_timeout(0); - return; - } bun_core::scoped_log!(fetch, "Timeout {}\n", BStr::new(self.url.href)); // Terminate (mark dead + close) BEFORE failing, matching // `close_and_fail`: `fail()` dispatches the final result, which frees @@ -2306,7 +2366,7 @@ impl<'a> HTTPClient<'a> { pub(crate) fn record_socket_open_errno(&mut self) { #[cfg(not(windows))] { - self.state.connect_errno = bun_sys::last_errno(); + self.state.connect_errno = uws::SocketGroup::errno(bun_sys::last_errno()); } } @@ -2329,18 +2389,7 @@ impl<'a> HTTPClient<'a> { self.fail(crate::Error::DNSResolveFailed); return; } - // Windows: only a WSA code names a cause. Below that range is the CRT's - // `ECONNREFUSED` uSockets stores when every address of a name failed, - // a different number in `SystemErrno`; and WSAENOTCONN is its recv() - // probe finding the socket unconnected after SO_ERROR was consumed. - const WSA_FIRST: i32 = 10000; - const WSAENOTCONN: i32 = 10057; - self.state.connect_errno = - if cfg!(windows) && (connect_errno < WSA_FIRST || connect_errno == WSAENOTCONN) { - 0 - } else { - connect_errno - }; + self.state.connect_errno = uws::SocketGroup::errno(connect_errno); self.fail(crate::Error::ConnectionRefused); } @@ -2383,6 +2432,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_ms) = self.state.keepalive_reuse_ms else { + return self.pool; + }; + PoolOptions { + idle_timeout_ms: self.pool.idle_timeout_ms_or_default().min(reuse_ms), + ..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 @@ -2456,8 +2516,7 @@ impl<'a> HTTPClient<'a> { } /// Upgrade a `*mut GenHttpContext` (the value [`get_ssl_ctx`] - /// produces and that `progress_update`/`do_redirect`/`on_data` thread - /// through as a parameter) to `&mut`. + /// produces) to `&mut`. /// /// INVARIANT: every value reaching here is one of two thread-owned, /// set-once non-null pointers — `&raw mut http_thread().http(s)_context` @@ -2499,13 +2558,30 @@ impl<'a> HTTPClient<'a> { &buf[ptr.offset as usize..end] } - /// The headers live in the HTTP thread's scratch buffer until the next call on any client. - pub(crate) fn build_request(&mut self, body_len: usize) -> picohttp::Request<'a> { + /// `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; @@ -2514,39 +2590,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") => { @@ -2555,31 +2617,16 @@ 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; - } - } - 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; - } - } + override_accept_encoding = true; } h if h == hash_header_const(CHUNKED_ENCODED_HEADER.name()) => { // Framing is ours: a computed Content-Length, or what the `Stream` carries. @@ -2588,11 +2635,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])); @@ -2625,7 +2667,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 { @@ -2658,8 +2701,7 @@ impl<'a> HTTPClient<'a> { header_count += 1; } } else if let Some(content_length) = original_content_length - && (self.flags.is_node_http_client - || matches!(bun_core::parse_unsigned::(content_length, 10), Ok(0))) + && matches!(bun_core::parse_unsigned::(content_length, 10), Ok(0)) { request_headers_buf[header_count] = picohttp::Header::new(CONTENT_LENGTH_HEADER_NAME, content_length); @@ -2667,23 +2709,19 @@ impl<'a> HTTPClient<'a> { } 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), + }, path: self.url.pathname, - minor_version: 1, - headers: &request_headers_buf[0..header_count], - bytes_read: 0, + headers: &request_headers_buf[..header_count], } } - pub(crate) fn do_redirect( - &mut self, - ctx: *mut GenHttpContext, - socket: HttpSocket, - ) { - if self.flags.protocol != Protocol::Http1_1 { - return self.do_redirect_multiplexed(); - } - bun_core::scoped_log!(fetch, "doRedirect"); + /// Starts the request again at the redirect's target. Its carrier has let go of it. + pub(crate) fn follow_redirect(&mut self) { + bun_core::scoped_log!(fetch, "followRedirect"); + debug_assert!(matches!(self.carrier, Carrier::None)); if matches!(self.state.original_request_body, HTTPRequestBody::Stream(_)) { // handleResponseMetadata already rejected every non-303 status with a // stream body (RequestBodyNotReusable). Reaching here means the @@ -2693,60 +2731,18 @@ 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(); self.remaining_redirect_count = self.remaining_redirect_count.saturating_sub(1); self.flags.redirected = true; debug_assert!(self.redirect_type == FetchRedirect::Follow); - self.unregister_abort_tracker(); - - // By the time doRedirect runs, handleResponseMetadata has already mutated - // this.url to the redirect destination. Pooling the tunnel here would - // store it under the WRONG target hostname — a follow-up request to the - // redirect destination could then reuse a TLS session negotiated with the - // original host. Close the tunnel on redirect; only pool the raw socket. - if self.proxy_tunnel.is_some() { - bun_core::scoped_log!(fetch, "close the tunnel"); - self.close_proxy_tunnel(true); - GenHttpContext::::close_socket(socket); - } else if self.is_keep_alive_possible() - && self.is_request_fully_sent() - && !socket.is_closed_or_has_error() - { - bun_core::scoped_log!(fetch, "Keep-Alive release in redirect"); - debug_assert!(!self.connected_url.hostname.is_empty()); - Self::ssl_ctx_mut(ctx).release_socket( - socket, - self.flags.did_have_handshaking_error && !self.flags.reject_unauthorized, - self.pooled_socket_verification(), - self.connected_url.hostname, - self.connected_url.get_port_auto(), - self.tls_props.as_ref(), - None, - self.unix_tls_hostname::(), - 0, - 0, - None, - self.unix_socket_path, - self.pool, - ); - } else { - GenHttpContext::::close_socket(socket); + + // 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""; } - // Cleared after `release_socket` above, which keys the pool entry on it. - 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). @@ -2759,14 +2755,33 @@ impl<'a> HTTPClient<'a> { return; } self.state.reset(); - bun_core::scoped_log!(fetch, "doRedirect state reset"); // also reset proxy to redirect 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); + } + + /// The request body as it was before any of it was written, or `None` for a `Stream`, which is consumed as it's written. + fn request_body_from_start(&self) -> Option> { + match self.state.original_request_body { + HTTPRequestBody::Bytes(bytes) => Some(HTTPRequestBody::Bytes(bytes)), + HTTPRequestBody::Sendfile(mut sendfile) => { + sendfile.rewind(); + Some(HTTPRequestBody::Sendfile(sendfile)) + } + HTTPRequestBody::Stream(_) => None, + } + } + + /// A `Stream` body only gets here on a 303, which is already a bodiless GET. + fn request_body_for_redirect(&self) -> HTTPRequestBody<'a> { + (self.state.flags.resend_request_body_on_redirect) + .then(|| self.request_body_from_start()) + .flatten() + .unwrap_or(HTTPRequestBody::Bytes(b"")) } /// Re-resolve `http_proxy` against the post-redirect `self.url`. The @@ -2823,8 +2838,14 @@ impl<'a> HTTPClient<'a> { } } + /// While this is on the stack, `fail` and `on_close` only record: its end dispatches the result or starts the next attempt, once. fn start_(&mut self) { - self.unregister_abort_tracker(); + debug_assert!( + !self + .flags + .defer_terminal_dispatch_until_connecting_is_complete + ); + self.carrier = Carrier::None; // Mark that we are connecting: a terminal result reached inside this // function (synchronous failure, or preconnect completing on a pooled @@ -2844,6 +2865,13 @@ impl<'a> HTTPClient<'a> { return; } + // No byte of the URL can end the request line, a field, or the C string the hostname is dialed as. + if (self.url.href.iter()).any(|&byte| byte <= 0x20 || byte == 0x7f) { + self.fail(crate::Error::InvalidURL); + self.complete_connecting_process(); + return; + } + // protocol: "http2" is documented as HTTPS-only (h2c is out of scope). // Every h2 consumer is gated on the SSL const-generic, so without this // an http:// request would silently fall through to HTTP/1.1. @@ -2861,7 +2889,7 @@ impl<'a> HTTPClient<'a> { // `flags.forced_protocol` — that's the user's explicit `protocol:"http3"` // choice and persists across redirects, whereas an Alt-Svc upgrade is // per-origin and a cross-origin redirect must re-evaluate from h1. - // `doRedirectMultiplexed` resets `flags.protocol`, so the redirected + // `follow_redirect` resets `flags.protocol`, so the redirected // request lands back here with `forced_protocol` still `None` and // consults the cache for the new origin. if self.flags.forced_protocol != Some(Protocol::Http3) && self.can_try_h3_alt_svc() { @@ -2869,17 +2897,19 @@ impl<'a> HTTPClient<'a> { h3::alt_svc::lookup(self.url.hostname, self.url.get_port_auto()) { if let Some(ctx) = h3::ClientContext::get_or_create() { - 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 } } } @@ -2949,18 +2979,18 @@ impl<'a> HTTPClient<'a> { } }; - if socket.is_closed() - && (self.state.response_stage != ResponseStage::Done - && self.state.response_stage != ResponseStage::Fail) - { + if socket.is_closed() { GenHttpContext::::mark_socket_as_dead(socket); - self.fail(crate::Error::ConnectionClosed); + let in_progress = self.is_in_progress(); self.complete_connecting_process(); + if in_progress { + self.on_close(); + } return; } // If we haven't already called onOpen(), then that means we need to - // register the abort tracker. We need to do this in cases where the + // record the connecting socket for an abort. We need to do this in cases where the // connection takes a long time to happen such as when it's not routable. // See test/js/bun/io/fetch/fetch-abort-slow-connect.test.ts. // @@ -2968,7 +2998,7 @@ impl<'a> HTTPClient<'a> { // synchronously, then this socket is on longer valid and the pointer points // to invalid memory. if self.state.request_stage == RequestStage::Pending { - self.register_abort_tracker::(socket); + self.set_socket::(socket); } self.complete_connecting_process(); } @@ -3066,23 +3096,23 @@ 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() { bun_core::scoped_log!(fetch, "start proxy tunneling (https proxy)"); // DO the tunneling! self.flags.proxy_tunneling = true; - write_proxy_connect(writer, self)?; + write_proxy_connect(writer, self); } else { bun_core::scoped_log!(fetch, "start proxy request (http proxy)"); // HTTP do not need tunneling with CONNECT just a slightly different version of the request - write_proxy_request(writer, &request, self)?; + write_proxy_request(writer, &request, self); } } else { bun_core::scoped_log!(fetch, "normal request"); - validate_request_target(self.url.host)?; - write_request(writer, &request)?; + write_request(writer, &request); } let headers_len = temporary_send_buffer.len(); @@ -3167,6 +3197,9 @@ impl<'a> HTTPClient<'a> { message: http_thread::WriteMessageType, socket: HttpSocket, ) { + if socket.is_closed() || socket.is_shutdown() { + return; + } let HTTPRequestBody::Stream(stream) = &mut self.state.original_request_body else { return; }; @@ -3260,7 +3293,7 @@ impl<'a> HTTPClient<'a> { ) { bun_core::scoped_log!(fetch, "flushStream"); // Never write body bytes before the request headers: drain_queued_writes can - // reach this via the not-yet-opened socket start_() puts in the abort tracker, + // reach this via the not-yet-opened socket start_() records as the carrier, // and request_sent_len still indexes headers. on_writable's Body arm re-flushes. if !matches!( self.state.request_stage, @@ -3377,7 +3410,7 @@ impl<'a> HTTPClient<'a> { } if let Some(proxy) = self.proxy_tunnel_ptr() { - ProxyTunnel::on_writable::(proxy, socket); + ProxyTunnel::on_writable(proxy); // ProxyTunnel::on_writable → SSLWrapper::flush → handle_traffic // may process a TLS alert or close_notify that was buffered // alongside the handshake flight, firing on_close → @@ -3431,8 +3464,7 @@ impl<'a> HTTPClient<'a> { self.state.request_stage = RequestStage::Body; if self.flags.is_streaming_request_body { // lets signal to start streaming the body - let ctx = self.get_ssl_ctx::(); - self.progress_update::(ctx, socket); + self.progress_update::(socket); } } debug_assert!( @@ -3486,30 +3518,33 @@ 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))] + // A short `socket.write()` already polls for writable. + crate::send_file::Status::Again if copy => {} + // A peer that is gone is left to the close event, as `socket.write()` leaves it. + crate::send_file::Status::Again + | crate::send_file::Status::Err(crate::Error::Sys( + bun_errno::SystemErrno::EPIPE | bun_errno::SystemErrno::ECONNRESET, + )) => { + uws::SocketTCP::from_any(socket.socket) + .mark_needs_more_for_sendfile(); + } crate::send_file::Status::Err(err) => { self.close_and_fail::(err, socket); return; } - 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(); - } } } } @@ -3521,10 +3556,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 { @@ -3543,10 +3579,35 @@ 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 + .wrapper + .as_ref() + .is_some_and(|w| w.refused_ciphertext() > 0) + { + 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 => {} + } } } } @@ -3568,11 +3629,10 @@ 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()); - if let Err(e) = write_request(writer, &request) { - self.close_and_fail::(e, socket); - return; - } + let mut header_overflow = Vec::new(); + let request = + self.build_request(self.body_len_for_send(), &mut header_overflow); + write_request(writer, &request); let headers_len = temporary_send_buffer.len(); if !self.request_body().is_empty() @@ -3637,8 +3697,7 @@ impl<'a> HTTPClient<'a> { self.state.request_stage = RequestStage::ProxyBody; if self.flags.is_streaming_request_body { // lets signal to start streaming the body - let ctx = self.get_ssl_ctx::(); - self.progress_update::(ctx, socket); + self.progress_update::(socket); } debug_assert!( // leftover bytes OR stream/sendfile (whose body @@ -3672,7 +3731,8 @@ impl<'a> HTTPClient<'a> { &mut self, socket: HttpSocket, ) { - if !self.state.flags.is_waiting_for_cert_check { + if !self.state.flags.is_waiting_for_cert_check || socket.is_closed() || socket.is_shutdown() + { // Never parked, or already resumed/reset by a redirect or failure. return; } @@ -3701,8 +3761,6 @@ impl<'a> HTTPClient<'a> { start_payload: &[u8], ) { bun_core::scoped_log!(fetch, "startProxyHandshake"); - // if we have options we pass them (ca, reject_unauthorized, etc) otherwise use the default - let ssl_options = self.tls_props.clone(); // The sole caller (`handle_on_data_headers`) has already moved // `response_message_buffer` into a local, so the CONNECT envelope is // gone from `self` and `start_payload` borrows that caller local (or @@ -3712,14 +3770,13 @@ impl<'a> HTTPClient<'a> { // synchronously fires on_close) that call close_and_fail -> fail -> the // result callback, which can free the AsyncHTTP that embeds `*self`. debug_assert!(self.state.response_message_buffer.list.capacity() == 0); - ProxyTunnel::start::(self, socket, ssl_options.as_deref(), start_payload); + ProxyTunnel::start::(self, socket, start_payload); // Must not reference `self` past this point — see comment above. } pub(crate) fn handle_on_data_headers( &mut self, incoming_data: &[u8], - ctx: *mut GenHttpContext, socket: HttpSocket, ) { bun_core::scoped_log!( @@ -3741,6 +3798,10 @@ impl<'a> HTTPClient<'a> { let _ = buffer.append_slice_exact(incoming_data); buffer.list.as_slice() }; + // The late bytes of the previous response on a reused connection can arrive after this request went out. + if self.allow_retry { + to_read = skip_between_responses(to_read); + } // Persist the unparsed tail for the next `on_data`. When `needs_move`, // `to_read` is a suffix of `incoming_data` and is copied into the @@ -3845,7 +3906,7 @@ impl<'a> HTTPClient<'a> { break parsed; }; - let should_continue = match self.handle_response_metadata(&mut response) { + let head = match self.handle_response_metadata(&mut response) { Ok(s) => s, Err(err) => { self.close_and_fail::(err, socket); @@ -3855,20 +3916,13 @@ 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() { + if head != HeaderResult::HasBody { + if !skip_between_responses(to_read).is_empty() { self.state.flags.allow_keepalive = false; } - if self.state.flags.is_redirect_pending { - self.do_redirect::(ctx, socket); + if head == HeaderResult::Redirect { + self.release_socket::(socket, true); + self.follow_redirect(); return; } // this means that the request ended @@ -3878,7 +3932,7 @@ impl<'a> HTTPClient<'a> { self.state.flags.received_last_chunk = true; // if is not we ignore the content_length self.state.content_length = Some(0); - self.progress_update::(ctx, socket); + self.progress_update::(socket); return; } @@ -3894,7 +3948,7 @@ impl<'a> HTTPClient<'a> { if to_read.is_empty() { // no body data yet, but we can report the headers if self.signals.get(signals::Field::HeaderProgress) { - self.progress_update::(ctx, socket); + self.progress_update::(socket); } return; } @@ -3909,7 +3963,7 @@ impl<'a> HTTPClient<'a> { }; if report_progress { - self.progress_update::(ctx, socket); + self.progress_update::(socket); return; } } else if self.state.response_stage == ResponseStage::BodyChunk { @@ -3922,14 +3976,14 @@ impl<'a> HTTPClient<'a> { }; if report_progress { - self.progress_update::(ctx, socket); + self.progress_update::(socket); return; } } // if not reported we report partially now if self.signals.get(signals::Field::HeaderProgress) { - self.progress_update::(ctx, socket); + self.progress_update::(socket); return; } } @@ -3937,7 +3991,6 @@ impl<'a> HTTPClient<'a> { pub(crate) fn on_data( &mut self, incoming_data: &[u8], - ctx: *mut GenHttpContext, socket: HttpSocket, ) { bun_core::scoped_log!(fetch, "onData {}", incoming_data.len()); @@ -3971,7 +4024,7 @@ impl<'a> HTTPClient<'a> { match self.state.response_stage { ResponseStage::Pending | ResponseStage::Headers => { - self.handle_on_data_headers::(incoming_data, ctx, socket); + self.handle_on_data_headers::(incoming_data, socket); } ResponseStage::Body => { if !self.state.flags.receive_paused { @@ -3987,7 +4040,7 @@ impl<'a> HTTPClient<'a> { }; if report_progress { - self.progress_update::(ctx, socket); + self.progress_update::(socket); return; } self.maybe_pause_receive(socket); @@ -4007,7 +4060,7 @@ impl<'a> HTTPClient<'a> { }; if report_progress { - self.progress_update::(ctx, socket); + self.progress_update::(socket); return; } self.maybe_pause_receive(socket); @@ -4032,7 +4085,7 @@ impl<'a> HTTPClient<'a> { self.flags .defer_terminal_dispatch_until_connecting_is_complete = false; if self.state.stage == Stage::Fail { - self.dispatch_result_and_reset(true); + self.dispatch_result_and_reset(); } else if self.flags.is_preconnect_only && self.state.stage == Stage::Done { // Deferred preconnect success (see `on_preconnect`). self.dispatch_preconnect_result(); @@ -4059,6 +4112,7 @@ impl<'a> HTTPClient<'a> { for waiter_ptr in pc.waiters.iter().copied() { let waiter = h2::PendingConnect::waiter_mut(waiter_ptr); + waiter.carrier = Carrier::None; if waiter.signals.get(signals::Field::Aborted) { waiter.fail(crate::Error::Aborted); continue; @@ -4088,11 +4142,13 @@ impl<'a> HTTPClient<'a> { } } - fn fail(&mut self, err: crate::Error) { - self.unregister_abort_tracker(); + /// Ends the request with `err`. A session detaches the request's stream first. + pub(crate) fn fail(&mut self, err: crate::Error) { + debug_assert!(!self.is_on_stream()); + self.carrier = Carrier::None; self.resolve_pending_h2(PendingH2Resolution::LeaderFailed); - self.close_proxy_tunnel(true); + self.close_proxy_tunnel(); if self.state.stage != Stage::Done && self.state.stage != Stage::Fail { self.state.request_stage = RequestStage::Fail; self.state.response_stage = ResponseStage::Fail; @@ -4103,7 +4159,7 @@ impl<'a> HTTPClient<'a> { .flags .defer_terminal_dispatch_until_connecting_is_complete { - self.dispatch_result_and_reset(true); + self.dispatch_result_and_reset(); } } } @@ -4142,8 +4198,7 @@ impl<'a> HTTPClient<'a> { /// The idle timeout to arm for this request, in seconds (0 = disabled): /// the per-request `fetch({ timeout })` override when present, otherwise - /// the global `BUN_CONFIG_HTTP_IDLE_TIMEOUT` default. Both are already - /// normalised (see [`normalize_idle_timeout_seconds`]). + /// the global `BUN_CONFIG_HTTP_IDLE_TIMEOUT` default. #[inline] pub(crate) fn effective_idle_timeout_seconds(&self) -> c_uint { if self.flags.disable_timeout { @@ -4153,26 +4208,34 @@ impl<'a> HTTPClient<'a> { .unwrap_or_else(idle_timeout_seconds) } - pub(crate) fn set_timeout(&self, socket: &S) { - // Values are pre-normalised (global: `HTTPThread::on_start`; - // per-request: `AsyncHTTP::init`) so this is a plain pass-through. - // `socket.set_timeout` picks the short-tick timer for values ≤ 240s - // and the minute-granularity long timer above that. + pub(crate) fn set_timeout(&self, socket: &HttpSocket) { socket.set_timeout(self.effective_idle_timeout_seconds()); } - /// Output budget of one decode pass. h1 only: h2/h3 detach before held input could drain. + /// Output budget of one decode pass. #[inline] fn decompress_output_cap(&self) -> usize { - if self.flags.protocol == Protocol::Http1_1 && self.signals.is_demand_driven() { + if self.signals.is_demand_driven() { signals::BODY_HIGH_WATER_MARK } else { usize::MAX } } + /// Returns whether nobody will read the body. What is held of it goes, and the framing has already counted what arrived. + fn drop_abandoned_body(&mut self) -> bool { + let abandoned = self.signals.is_body_abandoned(); + if abandoned { + self.state.drop_body(); + } + abandoned + } + /// Decodes what has arrived under the consumer's budget. Returns whether to report bytes. fn process_received_body(&mut self, is_final_chunk: bool) -> crate::Result { + if self.drop_abandoned_body() { + return Ok(false); + } let mut max_output = self.decompress_output_cap(); if max_output != usize::MAX && self.state.encoding.is_compressed() { // Nothing is decoded for a paused consumer (a tunnelled socket keeps reading anyway). @@ -4208,240 +4271,91 @@ impl<'a> HTTPClient<'a> { self.state.flags.receive_paused = true; socket.set_timeout(0); let _ = socket.pause_stream(); - bun_core::scoped_log!(fetch, "pause receive {}", self.async_http_id); + bun_core::scoped_log!(fetch, "pause receive"); } - pub(crate) fn resume_receive(&mut self, socket: HttpSocket) { - if !self.state.flags.receive_paused || self.signals.is_receive_paused() { - return; - } - self.state.flags.receive_paused = false; - if socket.is_closed() { - return; + /// The consumer of a response that's still arriving on `socket` takes bytes again. + fn resume_receive(&mut self, socket: HttpSocket) { + if self.state.flags.receive_paused && !self.signals.is_receive_paused() { + self.state.flags.receive_paused = false; + // Even with an error or a shutdown latched: a FIN or RST that landed while the poll was paused only shows through the poll. + if !socket.is_closed() { + let _ = socket.resume_stream(); + bun_core::scoped_log!(fetch, "resume receive"); + self.set_timeout(&socket); + } } - // A FIN/RST/error that landed while the read poll was paused is only - // observable through the poll. Re-arm even when the socket already has - // an error or shutdown latched so the regular readable/EOF/error - // dispatch surfaces it; bailing here would strand the request with its - // timeout disabled and the body promise pending forever. - let _ = socket.resume_stream(); - bun_core::scoped_log!(fetch, "resume receive {}", self.async_http_id); - self.set_timeout(&socket); + let sent = self.send_held_body(); + self.after_progress::(sent, Some(socket)); } - pub(crate) fn drain_response_body(&mut self, socket: HttpSocket) { - if self.pump_held_body::(socket) { - let ctx = self.get_ssl_ctx::(); - self.send_progress_update_without_stage_check::(ctx, socket); - } + /// The carrier ended the body with no bytes to go with it: a FIN, a tunnel's close, END_STREAM. + pub(crate) fn end_body(&mut self) -> crate::Result<()> { + self.state.flags.received_last_chunk = true; + self.process_received_body(true).map(drop) } /// Decodes the next piece of a held body. Returns whether there is an update to send. - fn pump_held_body(&mut self, socket: HttpSocket) -> bool { - // Find out if we should not send any update. - match self.state.stage { - Stage::Done | Stage::Fail => return false, - _ => {} - } - - if self.state.fail.is_some() { - // If there's any error at all, do not drain. - return false; - } - - // If there's a pending redirect, then don't bother to send a response body - // as that wouldn't make sense and I want to defensively avoid edgecases - // from that. - if self.state.flags.is_redirect_pending { - return false; + fn decode_held_body(&mut self) -> crate::Result { + if !self.is_in_progress() { + return Ok(false); } // A consumer that paused again gets another resume when it unpauses. let pumped = self.state.has_pending_compressed() && !self.signals.is_receive_paused(); if pumped { let is_final = self.state.is_done(); - if let Err(err) = self.process_received_body(is_final) { - self.close_and_fail::(err, socket); - return false; - } + self.process_received_body(is_final)?; } // A pump that ends the body has to say so even with no bytes (a stream trailer alone). let ended = pumped && self.state.is_done() && !self.state.has_pending_compressed(); - !self.state.decoded_body.list.is_empty() || ended + Ok(!self.state.decoded_body.list.is_empty() || ended) } - fn send_progress_update_without_stage_check( - &mut self, - ctx: *mut GenHttpContext, - socket: HttpSocket, - ) { - if self.flags.protocol != Protocol::Http1_1 { - return self.send_progress_update_multiplexed(); - } - // A loop, not a call back into `drain_response_body`: a consumer that never pauses - // (`BufferAll`, or an S3 error body that is collected whole) takes one pass per turn. - while self.send_one_progress_update::(ctx, socket) - && self.pump_held_body::(socket) - {} + /// The consumer takes bytes again. Returns what [`Self::send_progress`] does. + pub(crate) fn send_held_body(&mut self) -> crate::Result { + Ok(self.decode_held_body()? && self.send_progress()?) } - /// Returns whether a held body is left that its consumer will not ask for. - fn send_one_progress_update( - &mut self, - ctx: *mut GenHttpContext, - socket: HttpSocket, - ) -> bool { - let callback = self.result_callback; - - let mut result = self.to_result(); - let has_more = result.has_more; - let is_done = !has_more; - - bun_core::scoped_log!(fetch, "progressUpdate {}", is_done); - - if is_done { - self.unregister_abort_tracker(); - // is_done is response-driven. A server can reply early (HTTP 413) - // with keep-alive while request_stage is still .proxy_body or the - // tunnel still has buffered encrypted writes. Pooling that tunnel - // would leave the connection mid-request on the inner TLS stream; - // adopt() resetting write_buffer doesn't restore a clean HTTP/1.1 - // boundary. Only pool a tunnel whose request side is fully drained. - // - // Also check wrapper liveness: a close-delimited body (no - // Content-Length, no Transfer-Encoding — RFC 7230 §3.3.3 rule 7) - // ends on inner-TLS close; ProxyTunnel.onClose fires but the outer - // socket is still alive. Pooling that dead wrapper would hang the - // next request (proxy.write() → error.ConnectionClosed, swallowed). - let tunnel_poolable = if let Some(t) = self.proxy_tunnel.as_deref() { - t.write_buffer.is_empty() - && t.wrapper - .as_ref() - .map(|w| { - !w.is_shutdown() && !w.flags.fatal_error() && !w.has_pending_data() - }) - .unwrap_or(false) - } else { - true - }; - - // The uSockets paused bit survives `state.reset()`; never hand a - // paused socket back to the pool. - if core::mem::take(&mut self.state.flags.receive_paused) - && !socket.is_closed_or_has_error() - { - let _ = socket.resume_stream(); - } - - if self.is_request_fully_sent() - && self.is_keep_alive_possible() - && !socket.is_closed_or_has_error() - && tunnel_poolable - { - bun_core::scoped_log!(fetch, "release socket"); - // Hand the client's strong ref straight to the pool: `release_socket` - // either stores this `RefPtr` in the parked `PooledSocket` or - // dereffs it if pooling fails. - let tunnel = self.proxy_tunnel.take(); - if let Some(t) = &tunnel { - proxy_tunnel::raw_as_mut(t.as_ptr()).detach_owner(&*self); - } - let had_tunnel = tunnel.is_some(); - // target_hostname = url.hostname (the CONNECT TCP target at - // writeProxyConnect line 346). - Self::ssl_ctx_mut(ctx).release_socket( - socket, - self.flags.did_have_handshaking_error && !self.flags.reject_unauthorized, - self.pooled_socket_verification(), - self.connected_url.hostname, - self.connected_url.get_port_auto(), - self.tls_props.as_ref(), - tunnel, - if had_tunnel { - self.url.hostname - } else { - self.unix_tls_hostname::() - }, - if had_tunnel { - self.url.get_port_auto() - } else { - 0 - }, - if had_tunnel { - self.proxy_auth_hash() - } else { - 0 - }, - None, - self.unix_socket_path, - self.pool, - ); - } else { - if self.proxy_tunnel.is_some() { - bun_core::scoped_log!(fetch, "close the tunnel"); - self.close_proxy_tunnel(true); - } - GenHttpContext::::close_socket(socket); - } - - self.state.reset(); - self.state.response_stage = ResponseStage::Done; - self.state.request_stage = RequestStage::Done; - self.state.stage = Stage::Done; - self.flags.proxy_tunneling = false; - bun_core::scoped_log!(fetch, "done"); + /// Reports what has arrived, then a held body until its consumer pauses. True = the final result went out and freed `self`. `Err` = the caller fails the request. + pub(crate) fn send_progress(&mut self) -> crate::Result { + if !self.is_in_progress() { + return Ok(true); } - - // Move the body bytes out of `self.state` before `callback.run`, since - // `on_async_http_callback_raw` on the terminal callback drops - // `self.state` and deallocates the embedding `ThreadlocalAsyncHTTP` - // (making `self` dangle) before dispatching to the user callback. - let mut decoded_body = core::mem::take(&mut self.state.decoded_body); - let parent = self.parent_async_http(); - if has_more { - result.body = decoded_body.list.as_slice(); - callback.run(parent, result); - if decoded_body.list.capacity() <= DECODED_BODY_RETAIN_CAP { - decoded_body.list.clear(); - self.state.decoded_body = decoded_body; + // A loop: a consumer that never pauses (`BufferAll`, an S3 error body) sends no resume. + while !self.send_result() { + if !self.decode_held_body()? { + return Ok(false); } - self.maybe_pause_receive(socket); - // Only a paused consumer asks for the rest. - self.state.has_pending_compressed() && !self.signals.is_receive_paused() - } else { - result.body_owned = decoded_body.list; - callback.run(parent, result); - false } + Ok(true) } - /// `send_progress_update_without_stage_check` minus the per-request TCP socket - /// release/close. Used by HTTP/2 and HTTP/3, whose session owns the - /// transport, so there is no `ctx`/`socket` to hand back to the pool here. - fn send_progress_update_multiplexed(&mut self) { - debug_assert!(self.flags.protocol != Protocol::Http1_1); + /// Returns true if the result was the final one, which frees `self`. + fn send_result(&mut self) -> bool { let callback = self.result_callback; let mut result = self.to_result(); let is_done = !result.has_more; bun_core::scoped_log!(fetch, "progressUpdate {}", is_done); if is_done { - self.unregister_abort_tracker(); + // Every carrier lets go of the request when the last byte of the response arrives. + debug_assert!(matches!(self.carrier, Carrier::None)); self.state.reset(); self.state.response_stage = ResponseStage::Done; self.state.request_stage = RequestStage::Done; self.state.stage = Stage::Done; self.flags.proxy_tunneling = false; } - // See `send_progress_update_without_stage_check`: move the body out of - // `self.state` before the terminal callback's `self` teardown. + + // The final callback drops `self.state` and frees `self` before the consumer reads the body, so the bytes move out first. let mut decoded_body = core::mem::take(&mut self.state.decoded_body); let parent = self.parent_async_http(); if is_done { result.body_owned = decoded_body.list; callback.run(parent, result); - return; + return true; } result.body = decoded_body.list.as_slice(); callback.run(parent, result); @@ -4449,85 +4363,108 @@ impl<'a> HTTPClient<'a> { decoded_body.list.clear(); self.state.decoded_body = decoded_body; } + false } - /// `do_redirect` minus the per-request socket release/close. The session - /// detached the stream before calling this; `start()` re-enters the normal - /// connect path for the redirect target. - fn do_redirect_multiplexed(&mut self) { - debug_assert!(self.flags.protocol != Protocol::Http1_1); - bun_core::scoped_log!(fetch, "doRedirectMultiplexed"); - if matches!(self.state.original_request_body, HTTPRequestBody::Stream(_)) { - 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"" - }; - self.state.response_message_buffer = MutableString::default(); - self.remaining_redirect_count = self.remaining_redirect_count.saturating_sub(1); - self.flags.redirected = true; - debug_assert!(self.redirect_type == FetchRedirect::Follow); - self.unregister_abort_tracker(); - self.connected_url = URL::default(); - self.prev_redirect = Vec::new(); - if self.remaining_redirect_count == 0 { - self.fail(crate::Error::TooManyRedirects); - return; + /// Reports progress of a request on an HTTP/1.1 connection. + pub(crate) fn progress_update(&mut self, socket: HttpSocket) { + // The connection is free for the next request, however long the consumer takes. + let released = self.state.is_done() && self.is_in_progress(); + if released { + self.release_socket::(socket, false); } - self.state.reset(); - self.flags.proxy_tunneling = false; - self.flags.protocol = Protocol::Http1_1; - self.reevaluate_proxy_for_redirect(); - self.start(HTTPRequestBody::Bytes(request_body)); + let sent = self.send_progress(); + self.after_progress::(sent, (!released).then_some(socket)); } - pub(crate) fn progress_update_h3(&mut self) { - debug_assert!(self.flags.protocol == Protocol::Http3); - if self.state.stage == Stage::Done || self.state.stage == Stage::Fail { - return; - } - if self.state.flags.is_redirect_pending && self.state.fail.is_none() { - if self.state.is_done() { - self.do_redirect_multiplexed(); - } - return; + /// `socket` is the one the request is still on. + fn after_progress( + &mut self, + sent: crate::Result, + socket: Option>, + ) { + match (sent, socket) { + (Ok(true), _) | (Ok(false), None) => {} + (Ok(false), Some(socket)) => self.maybe_pause_receive(socket), + (Err(err), Some(socket)) => self.close_and_fail::(err, socket), + (Err(err), None) => self.fail(err), } - self.send_progress_update_multiplexed(); - } - - pub(crate) fn do_redirect_h3(&mut self) { - debug_assert!(self.flags.protocol == Protocol::Http3); - self.do_redirect_multiplexed(); } - pub(crate) fn progress_update( + /// Lets go of the connection: into the keep-alive pool if another request can use it, else it's closed. + fn release_socket( &mut self, - ctx: *mut GenHttpContext, socket: HttpSocket, + redirecting: bool, ) { - if self.state.stage != Stage::Done && self.state.stage != Stage::Fail { - if self.state.flags.is_redirect_pending && self.state.fail.is_none() { - if self.state.is_done() { - self.do_redirect::(ctx, socket); - } - return; - } + self.carrier = Carrier::None; + let tunnel_poolable = match self.proxy_tunnel.as_deref() { + None => true, + // A redirect has already pointed `url` at its target, the wrong name to pool the tunnel under. + Some(_) if redirecting => false, + // An early answer (413) leaves request bytes unwritten, and a close-delimited body ends with the inner TLS session shut down. + Some(t) => t.wrapper.as_ref().is_some_and(|w| { + !w.is_shutdown() && !w.flags.fatal_error() && !w.has_pending_data() + }), + }; + // The hop reuses this socket before a FIN behind the 3xx is read, and `on_close` only retries an idempotent method. + let hop_can_retry = !redirecting || self.method.is_idempotent(); - self.send_progress_update_without_stage_check::(ctx, socket); + // The pool never gets a paused socket. + if core::mem::take(&mut self.state.flags.receive_paused) && !socket.is_closed_or_has_error() + { + let _ = socket.resume_stream(); + } + + if self.is_request_fully_sent() + && self.is_keep_alive_possible() + && !socket.is_closed_or_has_error() + && tunnel_poolable + && hop_can_retry + { + bun_core::scoped_log!(fetch, "release socket"); + // The pool takes over the client's ref on the tunnel. + let tunnel = self.proxy_tunnel.take(); + if let Some(t) = &tunnel { + proxy_tunnel::raw_as_mut(t.as_ptr()).detach_owner(&*self); + } + let had_tunnel = tunnel.is_some(); + Self::ssl_ctx_mut(self.get_ssl_ctx::()).release_socket( + socket, + self.flags.did_have_handshaking_error && !self.flags.reject_unauthorized, + self.pooled_socket_verification(), + self.connected_url.hostname, + self.connected_url.get_port_auto(), + self.tls_props.as_ref(), + tunnel, + if had_tunnel { + self.url.hostname + } else { + self.unix_tls_hostname::() + }, + if had_tunnel { + self.url.get_port_auto() + } else { + 0 + }, + if had_tunnel { + self.proxy_auth_hash() + } else { + 0 + }, + None, + self.unix_socket_path, + self.pool_for_release(), + ); + } else { + self.close_proxy_tunnel(); + GenHttpContext::::close_socket(socket); } } pub(crate) fn on_preconnect(&mut self, socket: HttpSocket) { bun_core::scoped_log!(fetch, "onPreconnect({})", BStr::new(self.url.href)); - self.unregister_abort_tracker(); + self.carrier = Carrier::None; let ctx = self.get_ssl_ctx::(); Self::ssl_ctx_mut(ctx).release_socket( socket, @@ -4606,12 +4543,6 @@ impl<'a> HTTPClient<'a> { BodySize::Unknown }; - // A followed redirect's intermediate head was only cloned to drive - // do_redirect(); on failure it must not surface as the final Response. - if self.state.flags.is_redirect_pending && self.state.fail.is_some() { - self.state.cloned_metadata = None; - } - let proxy_connect_response = if self.state.fail == Some(crate::Error::ProxyConnectFailed) { self.state.cloned_metadata.take().map(Box::new) } else { @@ -4639,6 +4570,8 @@ impl<'a> HTTPClient<'a> { || self.state.request_stage == RequestStage::ProxyBody) && self.flags.is_streaming_request_body, is_http2: self.flags.protocol != Protocol::Http1_1, + did_decompress: self.state.encoding.is_compressed(), + slot_wanted: false, }; } } @@ -4663,6 +4596,8 @@ impl<'a> HTTPClient<'a> { || self.state.request_stage == RequestStage::ProxyBody) && self.flags.is_streaming_request_body, is_http2: self.flags.protocol != Protocol::Http1_1, + did_decompress: self.state.encoding.is_compressed(), + slot_wanted: false, } } @@ -4704,8 +4639,7 @@ impl<'a> HTTPClient<'a> { self.state.total_body_received ); } - // we can ignore the body data in redirects - if !self.state.flags.is_redirect_pending { + if !self.drop_abandoned_body() { if self.state.encoding.is_compressed() { self.state .decompress_bytes(incoming_data, true, usize::MAX)?; @@ -4738,15 +4672,11 @@ impl<'a> HTTPClient<'a> { incoming_data }; - // we can ignore the body data in redirects - if !self.state.flags.is_redirect_pending { - let buffer = self.state.get_body_buffer(); - if buffer.list.is_empty() && incoming_data.len() < PREALLOCATE_MAX { - let _ = buffer.list.try_reserve_exact(incoming_data.len()); - } - - let _ = buffer.write(remainder)?; + let buffer = self.state.get_body_buffer(); + if buffer.list.is_empty() && incoming_data.len() < PREALLOCATE_MAX { + let _ = buffer.list.try_reserve_exact(incoming_data.len()); } + let _ = buffer.write(remainder)?; self.state.total_body_received += remainder.len(); bun_core::scoped_log!( @@ -4775,7 +4705,6 @@ impl<'a> HTTPClient<'a> { // Close-delimited bodies still need per-packet decompression, but // a non-streaming consumer must not see per-packet progress: the // terminal callback (on close) is the first to carry metadata. - let is_done = is_done && !self.state.has_pending_compressed(); return Ok(is_done || (processed && is_streaming)); } Ok(false) @@ -4796,6 +4725,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. + if !skip_between_responses(trailing).is_empty() { + self.state.flags.allow_keepalive = false; + } + } + fn handle_response_body_chunked_encoding_from_multiple_packets( &mut self, incoming_data: &[u8], @@ -4842,7 +4792,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); @@ -4859,13 +4809,12 @@ impl<'a> HTTPClient<'a> { } // Done _ => { - self.state.flags.received_last_chunk = true; - let processed = self.process_received_body(true)?; + self.on_last_chunk(incoming_data, pret); + self.process_received_body(true)?; self.report_progress(buffer_len); - // A held body ends when `drain_response_body` has pumped it dry, not here. - return Ok(processed || !self.state.has_pending_compressed()); + return Ok(true); } } } @@ -4911,7 +4860,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()); @@ -4931,7 +4883,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()); @@ -4944,7 +4896,7 @@ impl<'a> HTTPClient<'a> { pub(crate) fn handle_response_metadata( &mut self, response: &mut picohttp::Response, - ) -> crate::Result { + ) -> crate::Result { let is_connect_reply = self.is_reading_connect_reply(); // RFC 9110 §9.3.6: a 2xx reply to CONNECT is the tunnel. Its header // fields frame nothing (Content-Length and Transfer-Encoding MUST be @@ -4954,14 +4906,14 @@ impl<'a> HTTPClient<'a> { if self.verbose != HTTPVerboseLevel::None { print_response(response); } - return Ok(ShouldContinue::ContinueStreaming); + return Ok(HeaderResult::HasBody); } let mut location: &[u8] = b""; let mut pretend_304 = false; 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 @@ -5002,13 +4954,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; } } @@ -5029,6 +4979,22 @@ impl<'a> HTTPClient<'a> { None => {} } } + h if h == hash_header_const(b"Keep-Alive") => { + if let Some(timeout) = parse_keepalive_timeout(header.value()) { + let timeout_ms = u64::from(timeout) * 1000; + // The margin is at most half the timeout, so back-to-back requests share a connection for any N above 0. + let reuse_ms = timeout_ms + .saturating_sub(KEEPALIVE_TIMEOUT_MARGIN_MS) + .max(timeout_ms / 2); + match u32::try_from(reuse_ms).unwrap_or(u32::MAX) { + 0 => self.state.flags.allow_keepalive = false, + ms => { + let reuse = &mut self.state.keepalive_reuse_ms; + *reuse = Some(reuse.map_or(ms, |prev| prev.min(ms))); + } + } + } + } h if h == hash_header_const(b"Last-Modified") => { pretend_304 = self.flags.force_last_modified && response.status_code > 199 @@ -5062,6 +5028,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: @@ -5077,6 +5045,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. @@ -5125,6 +5095,7 @@ impl<'a> HTTPClient<'a> { // if is no redirect or if is redirect == "manual" just proceed // https://fetch.spec.whatwg.org/#redirect-status let is_redirect = matches!(status_code, 301 | 302 | 303 | 307 | 308); + let mut follows_redirect = false; if is_redirect { if self.redirect_type == FetchRedirect::Follow && !location.is_empty() @@ -5141,150 +5112,27 @@ impl<'a> HTTPClient<'a> { { return Err(crate::Error::RequestBodyNotReusable); } - let hop: RedirectHop; - - { - if let Some(i) = strings::index_of(location, b"://") { - let mut string_builder = StringBuilder::default(); - - let is_protocol_relative = i == 0; - let protocol_name: &[u8] = if is_protocol_relative { - self.url.display_protocol() - } else { - &location[0..i] - }; - let is_http = - strings::eql_case_insensitive_ascii(protocol_name, b"http", true); - if is_http - || strings::eql_case_insensitive_ascii(protocol_name, b"https", true) - { - } else { - return Err(crate::Error::UnsupportedRedirectProtocol); - } - - if (protocol_name.len() * usize::from(is_protocol_relative)) - + location.len() - > MAX_REDIRECT_URL_LENGTH - { - return Err(crate::Error::RedirectURLTooLong); - } - - string_builder.count(location); - - if is_protocol_relative { - if is_http { - string_builder.count(b"http"); - } else { - string_builder.count(b"https"); - } - } - - string_builder.allocate()?; - - if is_protocol_relative { - if is_http { - let _ = string_builder.append(b"http"); - } else { - let _ = string_builder.append(b"https"); - } - } - - let _ = string_builder.append(location); - - debug_assert!(string_builder.cap == string_builder.len); - - let input = StringView::borrow_utf8(string_builder.allocated_slice()); - let normalized_url = bun_url::href_from_string(&input); - if normalized_url.tag() == BunStringTag::Dead { - // URL__getHref failed, dont pass dead tagged string to toOwnedSlice. - return Err(crate::Error::RedirectURLInvalid); - } - let normalized_url_str = normalized_url.to_owned_slice(); - - // SAFETY: self-borrow — `normalized_url_str` is moved into - // `self.redirect` below, which lives as long as `self` (≥ `'a`). - let new_url: URL<'a> = - unsafe { URL::parse(&normalized_url_str).erase_lifetime() }; - hop = RedirectHop::between(&self.url, &new_url); - self.url = new_url; - // connected_url still borrows from the previous hop's buffer - // until doRedirect releases the socket, so park it in - // prev_redirect for doRedirect to free instead of leaking it. - debug_assert!(self.prev_redirect.is_empty()); - self.prev_redirect = - core::mem::replace(&mut self.redirect, normalized_url_str); - } else if location.starts_with(b"//") { - let mut string_builder = StringBuilder::default(); - - let protocol_name = self.url.display_protocol(); - - if protocol_name.len() + 1 + location.len() > MAX_REDIRECT_URL_LENGTH { - return Err(crate::Error::RedirectURLTooLong); - } - - let is_http = - strings::eql_case_insensitive_ascii(protocol_name, b"http", true); - - if is_http { - string_builder.count(b"http:"); - } else { - string_builder.count(b"https:"); - } - - string_builder.count(location); - - string_builder.allocate()?; - - if is_http { - let _ = string_builder.append(b"http:"); - } else { - let _ = string_builder.append(b"https:"); - } - - let _ = string_builder.append(location); - - debug_assert!(string_builder.cap == string_builder.len); - - let input = StringView::borrow_utf8(string_builder.allocated_slice()); - let normalized_url = bun_url::href_from_string(&input); - if normalized_url.tag() == BunStringTag::Dead { - return Err(crate::Error::RedirectURLInvalid); - } - let normalized_url_str = normalized_url.to_owned_slice(); - - // SAFETY: self-borrow — `normalized_url_str` is moved into - // `self.redirect` below, which lives as long as `self` (≥ `'a`). - let new_url: URL<'a> = - unsafe { URL::parse(&normalized_url_str).erase_lifetime() }; - hop = RedirectHop::between(&self.url, &new_url); - self.url = new_url; - debug_assert!(self.prev_redirect.is_empty()); - self.prev_redirect = - core::mem::replace(&mut self.redirect, normalized_url_str); - } else { - let original_url = self.url.clone(); - - let base = StringView::borrow_utf8(original_url.href); - let rel = StringView::borrow_utf8(location); - let new_url_ = bun_url::join(&base, &rel); - - if new_url_.is_empty() { - return Err(crate::Error::InvalidRedirectURL); - } - - let new_url = new_url_.to_owned_slice(); - let parsed_url = URL::parse(&new_url); - if !parsed_url.has_http_like_protocol() { - return Err(crate::Error::UnsupportedRedirectProtocol); - } - // SAFETY: self-borrow — `new_url` is moved into `self.redirect` - // below, which lives as long as `self` (≥ `'a`). - self.url = unsafe { parsed_url.erase_lifetime() }; - hop = RedirectHop::between(&original_url, &self.url); - debug_assert!(self.prev_redirect.is_empty()); - self.prev_redirect = core::mem::replace(&mut self.redirect, new_url); - } + // https://fetch.spec.whatwg.org/#concept-response-location-url: the URL parser decides, with the current URL as its base. + let original_url = self.url.clone(); + let new_url = bun_url::join_without_fragment( + &StringView::borrow_utf8(original_url.href), + &StringView::borrow_utf8(location), + ); + if new_url.is_empty() { + return Err(crate::Error::RedirectURLInvalid); + } + let new_url = new_url.to_owned_slice(); + let parsed_url = URL::parse(&new_url); + if !parsed_url.has_http_like_protocol() { + return Err(crate::Error::UnsupportedRedirectProtocol); } + // SAFETY: self-borrow — `new_url` is moved into `self.redirect` + // below, which lives as long as `self` (≥ `'a`). + self.url = unsafe { parsed_url.erase_lifetime() }; + let hop = RedirectHop::between(&original_url, &self.url); + // `connected_url` borrows the previous hop's buffer until `follow_redirect` has released the socket. + debug_assert!(self.prev_redirect.is_empty()); + self.prev_redirect = core::mem::replace(&mut self.redirect, new_url); // If one of the following is true // - internalResponse's status is 301 or 302 and request's method is `POST` @@ -5296,7 +5144,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 { @@ -5340,7 +5188,7 @@ impl<'a> HTTPClient<'a> { } } } - self.state.flags.is_redirect_pending = true; + follows_redirect = true; if self.method.has_request_body() { self.state.flags.resend_request_body_on_redirect = true; } @@ -5373,7 +5221,7 @@ impl<'a> HTTPClient<'a> { if self.flags.upgrade_state == HTTPUpgradeState::Upgraded { self.state.content_length = None; self.state.flags.allow_keepalive = false; - return Ok(ShouldContinue::ContinueStreaming); + return Ok(HeaderResult::HasBody); } // RFC 9112 §6.3: framing comes from Transfer-Encoding and Content-Length @@ -5385,16 +5233,18 @@ impl<'a> HTTPClient<'a> { || self.state.transfer_encoding == Encoding::Chunked || is_server_sent_events) { - if self.state.flags.is_redirect_pending { + if follows_redirect { // WHATWG HTTP-redirect fetch runs on the response head; the 3xx // body is discarded, not awaited. The socket still carries // undrained body bytes so it must be closed, not pooled. self.state.flags.allow_keepalive = false; - return Ok(ShouldContinue::Finished); + return Ok(HeaderResult::Redirect); } - Ok(ShouldContinue::ContinueStreaming) + Ok(HeaderResult::HasBody) + } else if follows_redirect { + Ok(HeaderResult::Redirect) } else { - Ok(ShouldContinue::Finished) + Ok(HeaderResult::Finished) } } } // impl HTTPClient diff --git a/src/http/lshpack.rs b/src/http/lshpack.rs index d6af265cef42..591626306f7e 100644 --- a/src/http/lshpack.rs +++ b/src/http/lshpack.rs @@ -1,4 +1,4 @@ -use core::ffi::{c_int, c_uint, c_void}; +use core::ffi::{c_uint, c_void}; #[repr(C)] struct lshpack_header { @@ -7,7 +7,6 @@ struct lshpack_header { value: *const u8, value_len: usize, never_index: bool, - hpack_index: u16, } impl Default for lshpack_header { @@ -18,7 +17,6 @@ impl Default for lshpack_header { value: core::ptr::null(), value_len: 0, never_index: false, - hpack_index: 255, } } } @@ -33,7 +31,6 @@ pub struct DecodeResult<'a> { pub name: &'a [u8], pub value: &'a [u8], pub never_index: bool, - pub well_know: u16, /// offset of the next header position in src pub next: usize, } @@ -48,6 +45,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 &[name_len, value_len] in self.fields.as_chunks::<2>().0 { + let (field_name, after_name) = rest.split_at((name_len & !Self::NEVER_INDEX) as usize); + let (value, after_value) = after_name.split_at(value_len 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. @@ -81,51 +165,57 @@ impl HPACK { value, next: offset, never_index: header.never_index, - well_know: header.hpack_index, }) } - /// 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( + /// Appends the encoding of all of `block` to `dst`. `Err` appends nothing and leaves the table unchanged. + pub fn encode_block( &mut self, - name: &[u8], - value: &[u8], - never_index: bool, - dst_buffer: &mut [u8], - dst_buffer_offset: usize, - ) -> Result { - // SAFETY: genuine FFI — the three (ptr,len) pairs come straight from - // live borrowed slices so the C side's reads/writes are in-bounds. The - // `self` precondition is type-discharged via `&mut` in the extern - // signature; the ptr+len contracts cannot be encoded over the C ABI. - let offset = unsafe { - lshpack_wrapper_encode( + 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, - name.as_ptr(), - name.len(), - value.as_ptr(), - value.len(), - never_index as c_int, - dst_buffer.as_mut_ptr(), - dst_buffer.len(), - dst_buffer_offset, + block.bytes.as_ptr(), + block.fields.as_ptr(), + block.len(), + out.as_mut_ptr(), + out.len(), ) }; - if offset == 0 { + // 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(offset) + Ok(()) } /// Adjust the encoder's dynamic-table capacity after init. Evicts entries /// to fit; the caller is responsible for emitting the RFC 7541 §6.3 /// Dynamic Table Size Update opcode at the start of the next header block /// so the peer's decoder evicts in lockstep. - pub fn set_encoder_max_capacity(&mut self, max_capacity: u32) { + fn set_encoder_max_capacity(&mut self, max_capacity: u32) { 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. + 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`. @@ -185,6 +275,103 @@ impl Drop for HpackHandle { } } +/// Two RFC 7541 §6.3 size updates (the minimum, then the final size) of up to 6 bytes each. +pub 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`. +const MAX_ENCODER_TABLE_SIZE: u32 = 4096; + +/// One connection's HPACK encoder and decoder, kept in step with both sides' SETTINGS_HEADER_TABLE_SIZE. +pub struct Coder { + hpack: HpackHandle, + enc_capacity: u32, + /// The smallest encoder capacity since the last header block, until a block announces it (RFC 7541 §4.2). + unannounced_min: Option, +} + +impl Default for Coder { + fn default() -> Self { + Coder { + hpack: HpackHandle::new(DEFAULT_HEADER_TABLE_SIZE), + enc_capacity: DEFAULT_HEADER_TABLE_SIZE, + unannounced_min: None, + } + } +} + +impl Coder { + /// Call for every SETTINGS_HEADER_TABLE_SIZE entry the peer sends, in wire order. + pub 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.hpack.set_encoder_max_capacity(capacity); + self.enc_capacity = capacity; + self.unannounced_min = Some( + self.unannounced_min + .map_or(capacity, |min| min.min(capacity)), + ); + } + + /// Call when the peer ACKs our SETTINGS. Until then its encoder still uses the previous size. + pub 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. + fn take_pending_size_update(&mut self, block: &mut Vec) { + let Some(min) = self.unannounced_min.take() else { + return; + }; + 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 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 + } + + /// Decodes one field. The result aliases a shared buffer, so copy it before the next call. + #[inline] + pub fn decode<'a>(&'a mut self, src: &[u8]) -> Result, HpackError> { + self.hpack.decode(src) + } +} + +/// RFC 7541 §5.1 and §6.3: `value` as a 5-bit-prefix integer behind the `001` pattern. +fn write_table_size_update(block: &mut Vec, value: u32) { + if value < 31 { + block.push(0x20 | value as u8); + return; + } + block.push(0x20 | 31); + let mut rest = value - 31; + while rest >= 128 { + block.push((rest as u8) | 0x80); + rest >>= 7; + } + block.push(rest as u8); +} + // SAFETY: the C wrapper has no thread affinity; lshpack state is not accessed // concurrently (callers serialize on the owning `H2FrameParser`). unsafe impl Send for HpackHandle {} @@ -208,6 +395,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); @@ -221,18 +409,13 @@ unsafe extern "C" { src_len: usize, output: &mut lshpack_header, ) -> usize; - // `self_` tightened to a reference; the three (ptr,len) pairs carry - // in-bounds read/write contracts that the C ABI cannot encode, so the - // declaration stays unsafe. - fn lshpack_wrapper_encode( + // `fields` holds `field_count` pairs of u32 that describe `bytes` (see `HeaderBlock`). + fn lshpack_wrapper_encode_block( self_: &mut HPACK, - name: *const u8, - name_len: usize, - value: *const u8, - value_len: usize, - never_index: c_int, - buffer: *mut u8, - buffer_len: usize, - buffer_offset: usize, + bytes: *const u8, + fields: *const u32, + field_count: usize, + dst: *mut u8, + dst_len: usize, ) -> usize; } diff --git a/src/http/session_cache.rs b/src/http/session_cache.rs index b113f3dfc1a4..dc6bc1aa864b 100644 --- a/src/http/session_cache.rs +++ b/src/http/session_cache.rs @@ -132,13 +132,11 @@ impl Drop for SessionSink { } } -/// `us_dispatch_new_session` for an HTTP client socket. Runs inside `SSL_read`/`SSL_do_handshake`. -pub fn on_new_session(socket: &mut bun_uws::us_socket_t, session: *mut SSL_SESSION) { +/// BoringSSL's new-session callback. Runs inside `SSL_read`/`SSL_do_handshake`. +extern "C" fn on_new_session(owner: *mut c_void, session: *mut SSL_SESSION) { let Some(session) = NonNull::new(session) else { return; }; - // SAFETY: `socket` is live for this call. - let owner = unsafe { us_socket_session_sink(socket) }; let Some(owner) = NonNull::new(owner.cast::()) else { return; }; @@ -181,6 +179,7 @@ unsafe extern "C" { fn us_socket_set_session_sink( s: *mut bun_uws::us_socket_t, sink: *mut c_void, + on_session: Option, on_free: Option, ); fn us_socket_session_sink(s: *mut bun_uws::us_socket_t) -> *mut c_void; @@ -237,6 +236,7 @@ pub(crate) unsafe fn install( us_socket_set_session_sink( socket, bun_core::heap::into_raw(sink).cast::(), + Some(on_new_session), Some(sink_on_free), ); } diff --git a/src/http/ssl_config.rs b/src/http/ssl_config.rs index 0a85d66ecddc..0f222524fb81 100644 --- a/src/http/ssl_config.rs +++ b/src/http/ssl_config.rs @@ -1,6 +1,4 @@ -//! MOVE-IN: ssl_config (MOVE_DOWN bun_runtime::socket::SSLConfig → bun_http) -//! JSC-dependent constructors (from_js / from_generated / read_from_blob / -//! handle_path / handle_file*) stay in bun_runtime (tier-6, Pass C). +//! Parsing a `tls` object needs JSC, so that half is `bun_runtime::socket::ssl_config`. use core::ffi::{CStr, c_char}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -32,6 +30,8 @@ pub struct SSLConfig { pub key: CStrSlice, pub cert: CStrSlice, pub ca: CStrSlice, + /// `ca` is the `tls.setDefaultCACertificates()` list. A server trusts it without naming it to clients, as in Node. + pub ca_is_default: bool, pub crl: CStrSlice, pub secure_options: u32, @@ -49,7 +49,6 @@ pub struct SSLConfig { pub client_renegotiation_limit: u32, pub client_renegotiation_window: u32, pub requires_custom_request_ctx: bool, - pub is_using_default_ciphers: bool, pub low_memory_mode: bool, /// Memoized `content_hash()`. Interior-mutable because it's lazily filled /// through `Arc` (shared ref) by the intern registry's hash @@ -57,9 +56,6 @@ pub struct SSLConfig { pub(crate) cached_hash: AtomicU64, } -/// Casing alias for callers that snake_cased the type name. -pub type SslConfig = SSLConfig; - /// Atomic shared pointer with weak support. Refcounting and allocation are /// managed non-intrusively by `Arc`; the `SSLConfig` struct itself has no /// refcount field. @@ -92,8 +88,72 @@ impl core::ops::Deref for SharedPtr { } } +/// The TLS options a caller gave. `VirtualMachine::tls_config` fills in the rest, and is the only way to an [`SSLConfig`]. +pub struct Builder { + config: SSLConfig, + pub reject_unauthorized: Option, +} + +impl Default for Builder { + fn default() -> Self { + Self { + config: SSLConfig::ZERO, + reject_unauthorized: None, + } + } +} + +impl core::ops::Deref for Builder { + type Target = SSLConfig; + #[inline] + fn deref(&self) -> &SSLConfig { + &self.config + } +} + +impl core::ops::DerefMut for Builder { + #[inline] + fn deref_mut(&mut self) -> &mut SSLConfig { + &mut self.config + } +} + +impl Builder { + /// No `ciphers` means `default_ciphers`, and neither `ca` nor `caFile` means `default_ca`, as in Node's `configSecureContext`. + pub fn build( + self, + reject_unauthorized: bool, + default_ciphers: Option<&[u8]>, + default_ca: Option<&[Box<[u8]>]>, + ) -> SSLConfig { + let mut config = self.config; + config.reject_unauthorized = i32::from(reject_unauthorized); + if config.ssl_ciphers.is_null() + && let Some(ciphers) = default_ciphers + { + config.ssl_ciphers = bun_core::dupe_z(ciphers); + config.requires_custom_request_ctx = true; + // An empty TLS 1.2 list would leave BoringSSL's built-in one in effect. + if ciphers.is_empty() { + config.ssl_min_version = config + .ssl_min_version + .max(i32::from(bun_boringssl_sys::TLS1_3_VERSION)); + } + } + if config.ca.is_none() + && config.ca_file_name.is_null() + && let Some(certs) = default_ca + { + config.ca = Some(certs.iter().map(|cert| bun_core::dupe_z(cert)).collect()); + config.ca_is_default = true; + config.requires_custom_request_ctx = true; + } + config + } +} + impl SSLConfig { - pub(crate) const ZERO: SSLConfig = SSLConfig { + const ZERO: SSLConfig = SSLConfig { server_name: core::ptr::null(), key_file_name: core::ptr::null(), cert_file_name: core::ptr::null(), @@ -103,6 +163,7 @@ impl SSLConfig { key: None, cert: None, ca: None, + ca_is_default: false, crl: None, secure_options: 0, session_timeout: 0, @@ -118,28 +179,10 @@ impl SSLConfig { client_renegotiation_limit: 0, client_renegotiation_window: 0, requires_custom_request_ctx: false, - is_using_default_ciphers: true, low_memory_mode: false, cached_hash: AtomicU64::new(0), }; - #[inline] - pub fn zero() -> Self { - Self::default() - } - - /// 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); - self.requires_custom_request_ctx = true; - // An empty TLS 1.2 list would leave BoringSSL's built-in one in effect. - if ciphers.is_empty() { - self.ssl_min_version = self - .ssl_min_version - .max(i32::from(bun_boringssl_sys::TLS1_3_VERSION)); - } - } - /// Borrow `server_name` as a `&CStr` (None if null). Convenience accessor /// for callers that previously pattern-matched `Option`. #[inline] @@ -172,6 +215,12 @@ impl SSLConfig { } } + /// The `ca` certificates. `None` means the built-in store, and an empty list trusts nothing. + pub fn ca_bytes(&self) -> Option> { + let ca = self.ca.as_ref()?; + Some(ca.iter().map(|cert| cstr_bytes(*cert))) + } + /// Extract the raw `*const SSLConfig` from an optional shared handle for /// pointer-equality comparison (interned configs have stable addresses). #[inline] @@ -227,6 +276,7 @@ impl SSLConfig { ctx_opts.client_renegotiation_window = self.client_renegotiation_window; ctx_opts.session_timeout = self.session_timeout; ctx_opts.allow_partial_trust_chain = i32::from(self.allow_partial_trust_chain); + ctx_opts.ca_is_default = i32::from(self.ca_is_default); if !self.sigalgs.is_null() { ctx_opts.sigalgs = self.sigalgs; } @@ -253,16 +303,6 @@ impl SSLConfig { opts } - /// Returns a copy of this config for client-side TLS with manual verification. - /// Sets request_cert=1 (to receive server cert) and reject_unauthorized=0 - /// (to handle verification manually in handshake callback). - pub fn for_client_verification(&self) -> SSLConfig { - let mut copy = self.clone(); - copy.request_cert = 1; - copy.reject_unauthorized = 0; - copy - } - pub(crate) fn is_same(&self, other: &SSLConfig) -> bool { macro_rules! eq_cstr { ($f:ident) => { @@ -308,6 +348,9 @@ impl SSLConfig { if self.allow_partial_trust_chain != other.allow_partial_trust_chain { return false; } + if self.ca_is_default != other.ca_is_default { + return false; + } eq_cstr!(sigalgs); eq_cstr!(ecdh_curve); if self.ssl_min_version != other.ssl_min_version { @@ -333,9 +376,6 @@ impl SSLConfig { if self.requires_custom_request_ctx != other.requires_custom_request_ctx { return false; } - if self.is_using_default_ciphers != other.is_using_default_ciphers { - return false; - } if self.low_memory_mode != other.low_memory_mode { return false; } @@ -361,6 +401,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)); @@ -383,6 +425,7 @@ impl SSLConfig { hasher.update(&self.secure_options.to_ne_bytes()); hasher.update(&self.session_timeout.to_ne_bytes()); hasher.update(&[self.allow_partial_trust_chain as u8]); + hasher.update(&[self.ca_is_default as u8]); hash_cstr!(sigalgs); hash_cstr!(ecdh_curve); hasher.update(&self.ssl_min_version.to_ne_bytes()); @@ -394,7 +437,6 @@ impl SSLConfig { hasher.update(&self.client_renegotiation_limit.to_ne_bytes()); hasher.update(&self.client_renegotiation_window.to_ne_bytes()); hasher.update(&[u8::from(self.requires_custom_request_ctx)]); - hasher.update(&[u8::from(self.is_using_default_ciphers)]); hasher.update(&[u8::from(self.low_memory_mode)]); let hash = hasher.final_(); // Avoid 0 since it's the sentinel for "not computed" @@ -460,12 +502,6 @@ impl SSLConfig { } } -impl Default for SSLConfig { - fn default() -> Self { - Self::ZERO - } -} - impl Clone for SSLConfig { fn clone(&self) -> Self { Self { @@ -478,6 +514,7 @@ impl Clone for SSLConfig { key: clone_strings(&self.key), cert: clone_strings(&self.cert), ca: clone_strings(&self.ca), + ca_is_default: self.ca_is_default, crl: clone_strings(&self.crl), secure_options: self.secure_options, session_timeout: self.session_timeout, @@ -493,7 +530,6 @@ impl Clone for SSLConfig { client_renegotiation_limit: self.client_renegotiation_limit, client_renegotiation_window: self.client_renegotiation_window, requires_custom_request_ctx: self.requires_custom_request_ctx, - is_using_default_ciphers: self.is_using_default_ciphers, low_memory_mode: self.low_memory_mode, cached_hash: AtomicU64::new(0), } diff --git a/src/http_jsc/error.rs b/src/http_jsc/error.rs index bea4ac23886a..31c1a8dca124 100644 --- a/src/http_jsc/error.rs +++ b/src/http_jsc/error.rs @@ -1,7 +1,5 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum Error { - #[error("InvalidOptions")] - InvalidOptions, #[error("ConnectionClosed")] ConnectionClosed, } @@ -10,7 +8,6 @@ impl Error { #[allow(clippy::trivially_copy_pass_by_ref)] pub(crate) fn name(&self) -> &'static str { match self { - Self::InvalidOptions => "InvalidOptions", Self::ConnectionClosed => "ConnectionClosed", } } 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.rs b/src/http_jsc/websocket_client.rs index d2644d51c009..f5ee8dbe8419 100644 --- a/src/http_jsc/websocket_client.rs +++ b/src/http_jsc/websocket_client.rs @@ -64,11 +64,9 @@ const AUTO_PONG_BACKPRESSURE_LIMIT: usize = 16 * 1024 * 1024; /// Seconds to wait after sending our Close frame before dropping the connection (`closeTimeout` in `ws`). #[inline] fn close_timeout_seconds() -> core::ffi::c_uint { - bun_http::normalize_idle_timeout_seconds( - bun_core::env_var::BUN_CONFIG_WS_CLOSE_TIMEOUT - .get() - .unwrap_or(30), - ) + bun_core::env_var::BUN_CONFIG_WS_CLOSE_TIMEOUT + .get() + .map_or(30, |seconds| u32::try_from(seconds).unwrap_or(u32::MAX)) } /// Matches `WebSocketConnectedOptions` (WebSocketDeflate.h). diff --git a/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs b/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs index 165be2bf17e8..2f3cb1e959a1 100644 --- a/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs +++ b/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs @@ -16,7 +16,7 @@ //! synchronously re-enters this struct through the `ctx` backref via //! `on_open`/`on_data`/`on_handshake`/`on_close`/`write_encrypted`. //! -//! All state mutated after construction lives in `Cell`/`JsCell` fields, and +//! All state mutated after construction lives in `Cell` fields, and //! `wrapper` is a write-once `OnceCell` set in `start()` before it is first //! driven, so every access — from a driving entry or from a re-entered //! callback — is a short shared borrow; no `&mut Self` is ever formed. @@ -25,14 +25,13 @@ use core::cell::{Cell, OnceCell}; -use bun_io::StreamBuffer; -use bun_ptr::{BackRef, JsCell, RefPtr, Root, ThisPtr}; -use bun_uws::ssl_wrapper::{Handlers as SslHandlers, SslWrapper}; +use bun_ptr::{BackRef, RefPtr, Root, ThisPtr}; +use bun_uws::ssl_wrapper::{Handlers as SslHandlers, SSLWrapper as SslWrapper}; use bun_uws::{NewSocketHandler, us_bun_verify_error_t}; use crate::websocket_client::{CloseMode, ErrorCode, remote_address_of}; -use bun_http::ssl_config::SslConfig; +use bun_boringssl::c::OwnedSslCtx; bun_core::declare_scope!(WebSocketProxyTunnel, visible); @@ -157,8 +156,6 @@ pub struct WebSocketProxyTunnel { wrapper: OnceCell, /// The proxy connection, until the upgrade client that owns it lets go. socket: Cell, - /// Write buffer for encrypted data (maintains TLS record ordering) - write_buffer: JsCell, /// Hostname for SNI (Server Name Indication) sni_hostname: Option>, /// Set by `on_handshake`, for `receive` to retry the writes that were refused during the renegotiation. @@ -176,21 +173,12 @@ impl WebSocketProxyTunnel { socket: NewSocketHandler, sni_hostname: &[u8], ) -> RefPtr { - // `assume_ssl`/`assume_tcp` rebuild the handler around the same - // `InternalSocket`. - let socket = if SSL { - SocketUnion::Ssl(socket.assume_ssl()) - } else { - SocketUnion::Tcp(socket.assume_tcp()) - }; - RefPtr::new(WebSocketProxyTunnel { ref_count: Cell::new(1), upgrade_client: Cell::new(Some(upgrade_client)), connected_websocket: Cell::new(None), wrapper: OnceCell::new(), - socket: Cell::new(socket), - write_buffer: JsCell::new(StreamBuffer::default()), + socket: Cell::new(SocketUnion::from_generic(socket)), sni_hostname: Some(Box::<[u8]>::from(bun_http::strip_ipv6_brackets( sni_hostname, ))), @@ -199,27 +187,18 @@ impl WebSocketProxyTunnel { } /// Start TLS handshake inside the tunnel - /// The ssl_options should contain all TLS configuration including CA certificates. /// /// Takes `ThisPtr` because `start*()` synchronously invokes /// `on_open(ctx)` / `on_handshake(ctx)`, which may terminate the upgrade /// client. pub(crate) fn start( this: ThisPtr, - ssl_options: &SslConfig, + ctx: OwnedSslCtx, reject_unauthorized: bool, initial_data: &[u8], - ) -> crate::Result<()> { - // Allow handshake to complete so we can access peer certificate for manual - // hostname verification in onHandshake(). The actual reject_unauthorized - // check is the WebSocket's: `CppWebSocket::accepts_tls_peer`. - let options = ssl_options.for_client_verification(); - - // tier-neutral `init_from_options` takes the lowered - // `BunSocketContextOptions` (= what `SSLConfig.asUSockets()` produces); - // the `SSLConfig`-taking `init` lives in bun_runtime. - let wrapper = SslWrapperType::init_from_options( - &options.as_usockets(), + ) { + let wrapper = SslWrapperType::init_with_ctx( + ctx, true, SslHandlers { ctx: this, @@ -228,17 +207,12 @@ impl WebSocketProxyTunnel { on_handshake: Self::on_handshake, on_close: Self::on_close, write: Self::write_encrypted, - // No JS TLSSocket fronts the tunnel; opting out keeps the - // SSL off the parked session/keylog queues entirely. - on_session: None, - on_keylog: None, - server_identity: Some(Self::server_identity), }, - ) - .map_err(|_| crate::Error::InvalidOptions)?; + ); debug_assert!(this.wrapper.get().is_none(), "start() called twice"); let wrapper = this.wrapper.get_or_init(|| wrapper); + wrapper.set_server_identity_check(Self::server_identity); // The inner connection's form of the `set_inline_reject` call in // `WebSocketUpgradeClient::handle_open`. if reject_unauthorized { @@ -275,13 +249,12 @@ impl WebSocketProxyTunnel { } // `start*()` synchronously fires `on_open(ctx)` / `write_encrypted(ctx)` - // / etc.; those callbacks mutate only `Cell`/`JsCell` fields. + // / etc.; those callbacks mutate only `Cell` fields. if !initial_data.is_empty() { wrapper.start_with_payload(initial_data); } else { wrapper.start(); } - Ok(()) } /// SSLWrapper callback: the name check inside the handshake. The WebSocket decides. @@ -422,35 +395,15 @@ impl WebSocketProxyTunnel { } /// SSLWrapper callback: Called with encrypted data to send to network - fn write_encrypted(this: ThisPtr, encrypted_data: &[u8]) { + fn write_encrypted(this: ThisPtr, encrypted_data: &[u8]) -> usize { bun_core::scoped_log!( WebSocketProxyTunnel, "writeEncrypted: {} bytes", encrypted_data.len() ); - // If data is already buffered, queue this to maintain TLS record ordering - if this.write_buffer.get().is_not_empty() { - bun_core::handle_oom(this.write_buffer.with_mut(|b| b.write(encrypted_data))); - return; - } - - // Try direct write to socket - let written = this.socket.get().write(encrypted_data); - if written < 0 { - // Write failed - buffer data for retry when socket becomes writable - bun_core::handle_oom(this.write_buffer.with_mut(|b| b.write(encrypted_data))); - return; - } - - // Buffer remaining data - let written_usize = usize::try_from(written).expect("int cast"); - if written_usize < encrypted_data.len() { - bun_core::handle_oom( - this.write_buffer - .with_mut(|b| b.write(&encrypted_data[written_usize..])), - ); - } + // A failed write took nothing: the wrapper tries again when the socket is writable. + usize::try_from(this.socket.get().write(encrypted_data)).unwrap_or(0) } /// Called when the socket becomes writable - flush buffered encrypted data @@ -461,34 +414,11 @@ impl WebSocketProxyTunnel { pub(crate) fn on_writable(this: ThisPtr) { let _guard = RefPtr::from_this(this); - // Flush the SSL state machine; no borrow of `*this` other than - // `wrapper` spans the synchronous `write_encrypted` re-entry. + // Sends what the socket refused. No borrow of `*this` other than `wrapper` spans the `write_encrypted` re-entry. if let Some(w) = this.wrapper.get() { - let _ = w.flush(); + w.flush(); } - - // Send buffered encrypted data (`write_encrypted` above may have - // appended to it). A raw uws socket write does not re-enter the tunnel. - let still_backpressured = this.write_buffer.with_mut(|buf| { - let to_send = buf.slice(); - if to_send.is_empty() { - return false; - } - let to_send_len = to_send.len(); - let written = this.socket.get().write(to_send); - if written < 0 { - return true; - } - let written = usize::try_from(written).expect("int cast"); - if written == to_send_len { - buf.reset(); - false - } else { - buf.wrote(written); - true - } - }); - if still_backpressured { + if this.has_backpressure() { if let Some(ws) = this.connected_websocket.get() { ws.this_ptr().restart_close_timeout_while_sending(); } @@ -584,11 +514,11 @@ impl WebSocketProxyTunnel { /// Check if the tunnel has backpressure pub(crate) fn has_backpressure(&self) -> bool { - self.write_buffer.get().is_not_empty() + self.buffered_amount() > 0 } pub(crate) fn buffered_amount(&self) -> usize { - self.write_buffer.get().size() + self.wrapper.get().map_or(0, |w| w.refused_ciphertext()) } pub(crate) fn pause_stream(&self) -> bool { diff --git a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs index 3d0321bc9481..6698dbf36555 100644 --- a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs +++ b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs @@ -47,27 +47,17 @@ use super::websocket_proxy::WebSocketProxy; use super::websocket_proxy_tunnel::WebSocketProxyTunnel; use crate::websocket_client::ErrorCode; -// LAYERING: SSLConfig was MOVE_DOWN'd from bun_runtime::api::server_config → -// bun_http::ssl_config (data + as_usockets/for_client_verification). The -// JSC-dependent `from_js` constructor stays in bun_runtime; the C-ABI -// `Bun__WebSocket__parseSSLConfig` export therefore lives in -// bun_runtime::socket::SSLConfig and bridges to this lower-tier type via -// `into_http()`. use bun_http::ssl_config::SSLConfig; bun_core::define_scoped_log!(log, WebSocketUpgradeClient, visible); bun_core::declare_scope!(alloc, hidden); -/// Opening-handshake timeout in seconds, normalised for uSockets' timer -/// wheel (short-timeout counter wraps at 240 ticks; `set_timeout` routes -/// larger values onto the minute-granularity long timer). 0 disables. +/// Opening-handshake timeout in seconds. 0 disables. #[inline] fn handshake_timeout_seconds() -> core::ffi::c_uint { - bun_http::normalize_idle_timeout_seconds( - bun_core::env_var::BUN_CONFIG_WS_HANDSHAKE_TIMEOUT - .get() - .unwrap_or(120), - ) + bun_core::env_var::BUN_CONFIG_WS_HANDSHAKE_TIMEOUT + .get() + .map_or(120, |seconds| u32::try_from(seconds).unwrap_or(u32::MAX)) } /// Max raw size of a response head. `--max-http-header-size` only counts header names, values and @@ -494,7 +484,6 @@ where secure_ptr, path.slice(), client.as_ptr(), - false, ), None => Socket::::connect_group( group, @@ -503,7 +492,6 @@ where display_host, c_int::from(connect_port), client.as_ptr(), - false, ), } .inspect_err(|_| *connect_error_code = last_connect_error_code()) @@ -887,9 +875,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)]) => @@ -1169,34 +1158,28 @@ where return; }; - // Use ssl_config if available, otherwise use defaults - let ssl_options: SSLConfig = match this.ssl_config.get().as_deref() { - Some(config) => config.clone(), - None => { - let mut c = SSLConfig::default(); - c.reject_unauthorized = 0; // We verify manually - c.request_cert = 1; - let vm = bun_jsc::virtual_machine::VirtualMachine::get(); - if let Some(ciphers) = vm.tls_default_ciphers() { - c.set_default_ciphers(ciphers); - } - c - } + // The handshake completes whatever the chain is: `CppWebSocket::accepts_tls_peer` judges it. + let vm = bun_jsc::virtual_machine::VirtualMachine::get(); + let mut err = uws::create_bun_socket_error_t::none; + let ctx = match this.ssl_config.get().as_deref() { + Some(config) => vm.ssl_ctx_cache_get_or_create( + &config.as_usockets_for_client_verification(), + &mut err, + ), + None => vm.ssl_ctx_cache_get_or_create( + &vm.tls_config(Default::default(), false) + .as_usockets_for_client_verification(), + &mut err, + ), }; // Start TLS handshake - if WebSocketProxyTunnel::start( - tunnel.this_ptr(), - &ssl_options, - reject_unauthorized, - initial_data, - ) - .is_err() - { + let Some(ctx) = ctx else { drop(tunnel); Self::terminate(this, ErrorCode::ProxyTunnelFailed); return; - } + }; + WebSocketProxyTunnel::start(tunnel.this_ptr(), ctx, reject_unauthorized, initial_data); // Re-check proxy state: `start` dispatches SSLWrapper callbacks that // can fail the client and take `proxy`. @@ -1674,17 +1657,15 @@ impl Drop for HTTPClient { } } -/// On Windows, only WSA codes (>= 10000) are real socket errors, and WSAENOTCONN comes from -/// uSockets' recv() probe, not from the actual failure. Both return `None`. fn socket_errno_name(errno: c_int) -> Option<&'static str> { - if errno == 0 || (cfg!(windows) && (errno < 10000 || errno == 10057)) { - return None; - } - bun_errno::SystemErrno::init(i64::from(errno)).map(Into::into) + uws::SocketGroup::errno(errno).map(Into::into) } -/// Errno name after `socket(2)` / `connect(2)` failed synchronously. +/// Errno name after `socket(2)` / `connect(2)` failed synchronously. Winsock doesn't set `errno`. fn last_connect_error_code() -> BunString { + if cfg!(windows) { + return BunString::EMPTY; + } socket_errno_name(bun_core::ffi::errno()).map_or(BunString::EMPTY, BunString::static_) } @@ -1903,7 +1884,7 @@ fn identity_name<'a>( // LAYERING: `Bun__WebSocket__parseSSLConfig` / `Bun__WebSocket__freeSSLConfig` // live in `bun_runtime::socket::ssl_config` (src/runtime/socket/SSLConfig.rs). -// `SSLConfig::from_js` walks Blob/JSCArrayBuffer/node_fs values (tier-6) and +// `ParsedTls::from_js` walks Blob/JSCArrayBuffer/node_fs values (tier-6) and // `bun_runtime → bun_http_jsc`, so the C-ABI export is hosted upstream where // `from_js` is defined. The result is bridged to `bun_http::ssl_config::SSLConfig` // (the type `connect()` consumes) via `into_http()` before boxing. C++ links by 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..f53cabaa025c 100644 --- a/src/http_types/Method.rs +++ b/src/http_types/Method.rs @@ -1,5 +1,9 @@ +use bun_core::StringPointer; use enumset::EnumSet; +use crate::ETag::Headers; +use crate::is_token; + #[allow(non_camel_case_types)] #[repr(u8)] #[derive(enumset::EnumSetType, Debug)] @@ -173,8 +177,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 +229,122 @@ impl Method { } } +/// 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. + #[inline] + 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/MimeType.rs b/src/http_types/MimeType.rs index 390d9c777112..8e50e2f6bb6d 100644 --- a/src/http_types/MimeType.rs +++ b/src/http_types/MimeType.rs @@ -1,7 +1,10 @@ use std::borrow::Cow; +use bun_collections::CaseInsensitiveAsciiStringArrayHashMap; use bun_core::strings; +use crate::is_token; + // ─────────────────────────────────────────────────────────────────────────── // `Table` (= `mime_type_list_enum::MimeTypeList`). Hand-maintained `&'static // str` newtype derived from `mime_type_list.txt`; see the note at the top of @@ -300,7 +303,7 @@ impl MimeType { } } - pub fn init(str_: &[u8], dupe: bool, allocated: Option<&mut bool>) -> MimeType { + pub fn init(str_: &[u8], dupe: bool) -> MimeType { let mut str = str_; if let Some(slash) = strings::index_of_char_usize(str, b'/') { let category_ = &str[0..slash]; @@ -330,12 +333,6 @@ impl MimeType { if str == b"wasm" { return WASM; } - - if let Some(a) = allocated { - if dupe { - *a = true; - } - } return MimeType { value: Self::maybe_dupe(str_, dupe), category: Category::Application, @@ -343,11 +340,6 @@ impl MimeType { } len if len == b"font".len() => { if strings::eql_comptime_ignore_len(category_, b"font") { - if let Some(a) = allocated { - if dupe { - *a = true; - } - } return MimeType { value: Self::maybe_dupe(str_, dupe), category: Category::Font, @@ -370,12 +362,6 @@ impl MimeType { if str == b"plain" { return TEXT; } - - if let Some(a) = allocated { - if dupe { - *a = true; - } - } return MimeType { value: Self::maybe_dupe(str_, dupe), category: Category::Text, @@ -384,11 +370,6 @@ impl MimeType { } len if len == b"image".len() => { if strings::eql_comptime_ignore_len(category_, b"image") { - if let Some(a) = allocated { - if dupe { - *a = true; - } - } return MimeType { value: Self::maybe_dupe(str_, dupe), category: Category::Image, @@ -396,11 +377,6 @@ impl MimeType { } if strings::eql_comptime_ignore_len(category_, b"audio") { - if let Some(a) = allocated { - if dupe { - *a = true; - } - } return MimeType { value: Self::maybe_dupe(str_, dupe), category: Category::Audio, @@ -408,11 +384,6 @@ impl MimeType { } if strings::eql_comptime_ignore_len(category_, b"video") { - if let Some(a) = allocated { - if dupe { - *a = true; - } - } return MimeType { value: Self::maybe_dupe(str_, dupe), category: Category::Video, @@ -422,12 +393,6 @@ impl MimeType { _ => {} } } - - if let Some(a) = allocated { - if dupe { - *a = true; - } - } MimeType { value: Self::maybe_dupe(str_, dupe), category: Category::Other, @@ -457,7 +422,7 @@ pub fn by_extension_no_default(ext_without_leading_dot: &[u8]) -> Option MimeType { - MimeType::init(name, false, None) + MimeType::init(name, false) } // Duplicate keys are rejected at compile time. The original table @@ -1679,3 +1644,120 @@ pub fn sniff(bytes: &[u8]) -> Option { } None } + +/// https://fetch.spec.whatwg.org/#http-whitespace +const HTTP_WHITESPACE: &[u8] = b"\t\n\r "; + +/// 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 +} + +/// 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`. +pub 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 !is_token(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 !is_token(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 = CaseInsensitiveAsciiStringArrayHashMap::<()>::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 = &input[name_start..pos]; + + if pos >= input.len() || input[pos] == b';' { + continue; + } + pos += 1; // consume '=' + + let value: Cow<'_, [u8]> = 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; + } + Cow::Owned(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; + } + Cow::Borrowed(v) + }; + + if !is_token(name) + || !value.iter().all(|&c| is_http_quoted_string_token(c)) + || bun_core::handle_oom(seen.get_or_put(name)).found_existing + { + continue; + } + + out.push(b';'); + out.extend(name.iter().map(u8::to_ascii_lowercase)); + out.push(b'='); + if is_token(&value) { + out.extend_from_slice(&value); + } else { + out.push(b'"'); + for &c in value.iter() { + if c == b'"' || c == b'\\' { + out.push(b'\\'); + } + out.push(c); + } + out.push(b'"'); + } + } + + Some(out) +} diff --git a/src/http_types/h2.rs b/src/http_types/h2.rs index 4c4419a8ea63..2addeb9220f2 100644 --- a/src/http_types/h2.rs +++ b/src/http_types/h2.rs @@ -1,215 +1,8 @@ -//! HTTP/2 wire-format types (RFC 7540 / 9113). Pure-data tier-1 module — -//! zero JSC, socket, or io dependencies. Shared by: -//! • `bun_http` (fetch() HTTP/2 client) — re-exported as `h2_frame_parser` -//! • `bun_runtime` (node:http2 bindings) — `pub use`d into its -//! `h2_frame_parser` module, which layers `WireWriter`-based `write()` -//! and `to_js()` on top as local extension traits. -#![allow(non_camel_case_types)] - -// ─── connection / sizing constants ────────────── - -pub const CLIENT_PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"; - -pub const MAX_WINDOW_SIZE: u32 = i32::MAX as u32; -pub const MAX_STREAM_ID: u32 = i32::MAX as u32; -/// Maximum 24-bit value. -pub const MAX_FRAME_SIZE: u32 = 0x00FF_FFFF; -pub const DEFAULT_WINDOW_SIZE: u32 = u16::MAX as u32; -pub const DEFAULT_MAX_FRAME_SIZE: u32 = 16384; - -// ─── frame type / flags ───────────────────────── -// -// Frame types are non-exhaustive on the wire (any u8 is a valid value). A -// `#[repr(u8)]` Rust enum is UB for unknown discriminants received off the -// wire, so callers dispatch on the raw `u8` (`FrameHeader.type_`) and only -// ever use this enum for *outbound* frame construction (`X as u8`). - -#[repr(u8)] -#[derive(Copy, Clone, PartialEq, Eq)] -pub enum FrameType { - HTTP_FRAME_DATA = 0x00, - HTTP_FRAME_HEADERS = 0x01, - HTTP_FRAME_PRIORITY = 0x02, - HTTP_FRAME_RST_STREAM = 0x03, - HTTP_FRAME_SETTINGS = 0x04, - HTTP_FRAME_PUSH_PROMISE = 0x05, - HTTP_FRAME_PING = 0x06, - HTTP_FRAME_GOAWAY = 0x07, - HTTP_FRAME_WINDOW_UPDATE = 0x08, - /// RFC 7540 §6.10: continues a header block fragment. - HTTP_FRAME_CONTINUATION = 0x09, - /// - HTTP_FRAME_ALTSVC = 0x0A, - /// - HTTP_FRAME_ORIGIN = 0x0C, -} - -#[repr(u8)] -#[derive(Copy, Clone, PartialEq, Eq)] -pub enum PingFrameFlags { - ACK = 0x1, -} - -#[repr(u8)] -#[derive(Copy, Clone, PartialEq, Eq)] -pub enum DataFrameFlags { - END_STREAM = 0x1, - PADDED = 0x8, -} - -#[repr(u8)] -#[derive(Copy, Clone, PartialEq, Eq)] -pub enum HeadersFrameFlags { - END_STREAM = 0x1, - END_HEADERS = 0x4, - PADDED = 0x8, - PRIORITY = 0x20, -} - -#[repr(u8)] -#[derive(Copy, Clone, PartialEq, Eq)] -pub enum SettingsFlags { - ACK = 0x1, -} - -// ─── error / setting codes ────────────────────── -// -// Newtype-over-int instead of -// `#[repr]` enums so any value off the wire is well-defined; consumers match -// on `.0` or the associated consts. - -#[repr(transparent)] -#[derive(Copy, Clone, PartialEq, Eq)] -pub struct ErrorCode(pub u32); -impl ErrorCode { - pub const NO_ERROR: Self = Self(0x0); - pub const PROTOCOL_ERROR: Self = Self(0x1); - pub const INTERNAL_ERROR: Self = Self(0x2); - pub const FLOW_CONTROL_ERROR: Self = Self(0x3); - pub const FRAME_SIZE_ERROR: Self = Self(0x6); - pub const REFUSED_STREAM: Self = Self(0x7); - pub const CANCEL: Self = Self(0x8); - pub const COMPRESSION_ERROR: Self = Self(0x9); - pub const ENHANCE_YOUR_CALM: Self = Self(0xb); -} - -#[repr(transparent)] -#[derive(Copy, Clone, PartialEq, Eq)] -pub struct SettingsType(pub u16); -impl SettingsType { - pub const SETTINGS_HEADER_TABLE_SIZE: Self = Self(0x1); - pub const SETTINGS_ENABLE_PUSH: Self = Self(0x2); - pub const SETTINGS_MAX_CONCURRENT_STREAMS: Self = Self(0x3); - pub const SETTINGS_INITIAL_WINDOW_SIZE: Self = Self(0x4); - pub const SETTINGS_MAX_FRAME_SIZE: Self = Self(0x5); - pub const SETTINGS_MAX_HEADER_LIST_SIZE: Self = Self(0x6); -} - -// ─── wire helpers ─────────────────────────────── - -#[inline] -pub fn u32_from_bytes(src: &[u8]) -> u32 { - debug_assert!(src.len() == 4); - u32::from_be_bytes([src[0], src[1], src[2], src[3]]) -} - -/// A 31-bit value with a reserved high bit. The RFC 7540 §6.3 wire format -/// wants the reserved/E bit at bit 31, so the layout is the RFC-compliant -/// `(reserved << 31) | uint31`, which matches -/// `from_bytes`/`encode_into` and the on-wire `StreamPriority.stream_identifier`. -#[repr(transparent)] -#[derive(Copy, Clone, Default, PartialEq, Eq)] -pub struct UInt31WithReserved(u32); - -impl UInt31WithReserved { - #[inline] - pub const fn uint31(self) -> u32 { - self.0 & 0x7fff_ffff - } - #[inline] - pub const fn from(value: u32) -> Self { - Self(value) - } - #[inline] - pub fn from_bytes(src: &[u8]) -> Self { - Self(u32_from_bytes(src)) - } -} - -// ─── packed wire structs ──────────────────────── - -/// 5-byte PRIORITY payload: BE stream identifier + weight. -#[repr(C, packed)] -#[derive(Copy, Clone, Default)] -pub struct StreamPriority { - pub(crate) stream_identifier: u32, - pub(crate) weight: u8, -} -const _: () = assert!(core::mem::size_of::() == StreamPriority::BYTE_SIZE); - -impl StreamPriority { - pub const BYTE_SIZE: usize = 5; -} - -/// NOT `#[repr(packed)]` — the `u24` length is widened to a native `u32` -/// in-memory; wire encoding is handled in `decode()`/`encode_into()` instead -/// of by punning the struct bytes. Callers assemble the 9 raw wire bytes on -/// the stack and hand them to `decode()`. -#[derive(Copy, Clone)] -pub struct FrameHeader { - /// `u24` on the wire. - pub length: u32, - pub type_: u8, - pub flags: u8, - pub stream_identifier: u32, -} -impl Default for FrameHeader { - fn default() -> Self { - Self { - length: 0, - type_: FrameType::HTTP_FRAME_SETTINGS as u8, - flags: 0, - stream_identifier: 0, - } - } -} -impl FrameHeader { - pub const BYTE_SIZE: usize = 9; - - /// Decode a complete 9-byte big-endian frame header. - #[inline] - pub fn decode(raw: &[u8; Self::BYTE_SIZE]) -> Self { - Self { - length: ((raw[0] as u32) << 16) | ((raw[1] as u32) << 8) | (raw[2] as u32), - type_: raw[3], - flags: raw[4], - stream_identifier: u32::from_be_bytes([raw[5], raw[6], raw[7], raw[8]]), - } - } -} - -/// 6-byte SETTINGS entry: BE u16 type + BE u32 value. -#[repr(C, packed)] -#[derive(Copy, Clone, Default)] -pub struct SettingsPayloadUnit { - pub type_: u16, - pub value: u32, -} -bun_core::impl_pod!(SettingsPayloadUnit); -const _: () = - assert!(core::mem::size_of::() == SettingsPayloadUnit::BYTE_SIZE); - -impl SettingsPayloadUnit { - pub const BYTE_SIZE: usize = 6; - - #[inline] - pub fn from(dst: &mut SettingsPayloadUnit, src: &[u8], offset: usize) { - let bytes = bun_core::bytes_of_mut(dst); - bytes[offset..src.len() + offset].copy_from_slice(src); - if END { - // Byte-swap each field. - dst.type_ = u16::swap_bytes(dst.type_); - dst.value = u32::swap_bytes(dst.value); - } - } -} +//! HTTP/2 (RFC 9113) without sockets or JS: what fetch's client and node:http2 both build on. + +pub mod fields; +pub mod flow_control; +pub mod frame; +pub mod header_block; +pub mod settings; +pub mod stream; diff --git a/src/http_types/h2/fields.rs b/src/http_types/h2/fields.rs new file mode 100644 index 000000000000..b6bf1159f360 --- /dev/null +++ b/src/http_types/h2/fields.rs @@ -0,0 +1,67 @@ +//! Checks on the fields of an inbound HTTP/2 header block (RFC 9113 §8.2). + +/// Returns true unless `name` is lowercase token characters, after the ":" of a pseudo-header (§8.2.1). +pub fn is_malformed_name(name: &[u8]) -> bool { + let rest = name.strip_prefix(b":").unwrap_or(name); + rest.is_empty() + || !rest + .iter() + .all(|&c| crate::is_token_char(c) && !c.is_ascii_uppercase()) +} + +/// Returns true if `value` has a NUL, CR or LF (§8.2.1). +#[inline] +pub fn is_malformed_value(value: &[u8]) -> bool { + bun_core::strings::contains_any(value, b"\0\r\n") +} + +/// Returns true if `value` has a control byte other than HTAB (RFC 9110 §5.5). +#[inline] +pub fn has_control_byte(value: &[u8]) -> bool { + value.iter().any(|&c| (c < b' ' && c != b'\t') || c == 0x7f) +} + +/// Returns true if `value` starts or ends with SP or HTAB (§8.2.1). +#[inline] +pub fn has_edge_whitespace(value: &[u8]) -> bool { + matches!(value.first(), Some(b' ' | b'\t')) || matches!(value.last(), Some(b' ' | b'\t')) +} + +/// Returns true for the connection-specific fields that make a block malformed (§8.2.2). `te` depends on its value. +#[inline] +pub fn is_connection_specific(name: &[u8]) -> bool { + matches!( + name, + b"connection" | b"keep-alive" | b"proxy-connection" | b"transfer-encoding" | b"upgrade" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names() { + for name in [&b"accept"[..], b":path", b"x-1_2", b"a"] { + assert!(!is_malformed_name(name), "{name:?}"); + } + for name in [&b""[..], b":", b"Accept", b"a b", b"a:b", b"::a", b"a\0"] { + assert!(is_malformed_name(name), "{name:?}"); + } + } + + #[test] + fn values() { + assert!(!is_malformed_value(b"a\tb \x7f")); + assert!(is_malformed_value(b"a\r")); + assert!(is_malformed_value(b"\n")); + assert!(is_malformed_value(b"a\0b")); + assert!(has_control_byte(b"a\x7f")); + assert!(has_control_byte(b"\x01")); + assert!(!has_control_byte(b"a\tb")); + assert!(has_edge_whitespace(b" a")); + assert!(has_edge_whitespace(b"a\t")); + assert!(!has_edge_whitespace(b"a b")); + assert!(!has_edge_whitespace(b"")); + } +} diff --git a/src/http_types/h2/flow_control.rs b/src/http_types/h2/flow_control.rs new file mode 100644 index 000000000000..1accafafbe7b --- /dev/null +++ b/src/http_types/h2/flow_control.rs @@ -0,0 +1,135 @@ +//! HTTP/2 flow-control windows (RFC 9113 §6.9), per connection and per stream. + +use super::frame::ErrorCode; + +pub const DEFAULT_WINDOW_SIZE: u32 = 65_535; +pub const MAX_WINDOW_SIZE: u32 = 0x7fff_ffff; + +/// How much DATA we may still send. Negative after the peer lowers SETTINGS_INITIAL_WINDOW_SIZE (§6.9.2). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct SendWindow(i64); + +impl SendWindow { + pub fn new(initial: u32) -> Self { + SendWindow(i64::from(initial)) + } + + #[inline] + pub fn available(self) -> usize { + self.0.max(0) as usize + } + + #[inline] + pub fn consume(&mut self, octets: usize) { + self.0 -= octets as i64; + } + + /// Applies a WINDOW_UPDATE. A window past 2^31-1 is a FLOW_CONTROL_ERROR and stays as it was (§6.9.1). + pub fn increase(&mut self, increment: u32) -> Result<(), ErrorCode> { + self.shift(i64::from(increment)) + } + + /// Applies a change of the peer's SETTINGS_INITIAL_WINDOW_SIZE, under the same limit (§6.9.2). + pub fn shift(&mut self, delta: i64) -> Result<(), ErrorCode> { + let next = self.0 + delta; + if next > i64::from(MAX_WINDOW_SIZE) { + return Err(ErrorCode::FLOW_CONTROL_ERROR); + } + self.0 = next; + Ok(()) + } +} + +/// How much DATA the peer may still send: the advertised size and what it has used of it. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct RecvWindow { + size: i64, + consumed: i64, +} + +impl RecvWindow { + pub fn new(initial: u32) -> Self { + RecvWindow { + size: i64::from(initial), + consumed: 0, + } + } + + /// The advertised size. + #[inline] + pub fn size(&self) -> i64 { + self.size + } + + #[inline] + pub fn on_data(&mut self, octets: usize) { + self.consumed += octets as i64; + } + + /// Returns true if the peer overran the window. `floor` is a larger size it may still go by (§6.5.3). + #[inline] + pub fn is_overflowed(&self, floor: u32) -> bool { + self.consumed > self.size.max(i64::from(floor)) + } + + /// Returns true once half the window is used. + #[inline] + pub fn needs_update(&self) -> bool { + self.consumed > 0 && self.consumed >= self.size / 2 + } + + /// The increment of the next WINDOW_UPDATE, or 0. Gives those octets back to the peer. + pub fn take_update(&mut self) -> u32 { + let increment = self.consumed.clamp(0, i64::from(MAX_WINDOW_SIZE)); + self.consumed -= increment; + increment as u32 + } + + #[inline] + pub fn grow(&mut self, delta: i64) { + self.size += delta; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn send_window_stops_at_the_maximum() { + let mut window = SendWindow::new(MAX_WINDOW_SIZE); + assert_eq!(window.increase(1), Err(ErrorCode::FLOW_CONTROL_ERROR)); + assert_eq!(window.available(), MAX_WINDOW_SIZE as usize); + window.consume(1); + assert_eq!(window.increase(1), Ok(())); + } + + #[test] + fn send_window_goes_negative_and_recovers() { + let mut window = SendWindow::new(100); + window.consume(80); + assert_eq!(window.shift(-50), Ok(())); + assert_eq!(window.available(), 0); + assert_eq!(window.increase(40), Ok(())); + assert_eq!(window.available(), 10); + } + + #[test] + fn recv_window_replenishes_at_half() { + let mut window = RecvWindow::new(100); + window.on_data(49); + assert!(!window.needs_update()); + window.on_data(11); + assert!(window.needs_update()); + assert_eq!(window.take_update(), 60); + assert_eq!(window.take_update(), 0); + } + + #[test] + fn recv_window_overflow_honors_the_floor() { + let mut window = RecvWindow::new(10); + window.on_data(11); + assert!(window.is_overflowed(0)); + assert!(!window.is_overflowed(65_535)); + } +} diff --git a/src/http_types/h2/frame.rs b/src/http_types/h2/frame.rs new file mode 100644 index 000000000000..c233c0e2bd30 --- /dev/null +++ b/src/http_types/h2/frame.rs @@ -0,0 +1,586 @@ +//! HTTP/2 frames (RFC 9113 §4 and §6): the header, the registries, and the checks a frame's own bytes decide. + +use core::ops::Range; + +use super::settings::Entries; + +pub const CONNECTION_PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"; +pub const FRAME_HEADER_SIZE: usize = 9; +pub const MAX_STREAM_ID: u32 = 0x7fff_ffff; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[repr(u8)] +pub enum FrameType { + Data = 0x0, + Headers = 0x1, + Priority = 0x2, + RstStream = 0x3, + Settings = 0x4, + PushPromise = 0x5, + Ping = 0x6, + GoAway = 0x7, + WindowUpdate = 0x8, + Continuation = 0x9, + /// RFC 7838 §4 + AltSvc = 0xa, + /// RFC 8336 §2 + Origin = 0xc, +} + +impl FrameType { + /// `None` for a type outside the registry, which §4.1 says to ignore. + pub fn from_u8(value: u8) -> Option { + Some(match value { + 0x0 => FrameType::Data, + 0x1 => FrameType::Headers, + 0x2 => FrameType::Priority, + 0x3 => FrameType::RstStream, + 0x4 => FrameType::Settings, + 0x5 => FrameType::PushPromise, + 0x6 => FrameType::Ping, + 0x7 => FrameType::GoAway, + 0x8 => FrameType::WindowUpdate, + 0x9 => FrameType::Continuation, + 0xa => FrameType::AltSvc, + 0xc => FrameType::Origin, + _ => return None, + }) + } +} + +/// Frame types reuse the same bits, so the names are per meaning. +pub mod flags { + pub const ACK: u8 = 0x01; + pub const END_STREAM: u8 = 0x01; + pub const END_HEADERS: u8 = 0x04; + pub const PADDED: u8 = 0x08; + pub(super) const PRIORITY: u8 = 0x20; +} + +/// RFC 9113 §7. A newtype, so a code outside the registry survives the trip from the wire. +#[repr(transparent)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct ErrorCode(pub u32); + +impl ErrorCode { + pub const NO_ERROR: Self = Self(0x0); + pub const PROTOCOL_ERROR: Self = Self(0x1); + pub const INTERNAL_ERROR: Self = Self(0x2); + pub const FLOW_CONTROL_ERROR: Self = Self(0x3); + pub const STREAM_CLOSED: Self = Self(0x5); + pub const FRAME_SIZE_ERROR: Self = Self(0x6); + pub const REFUSED_STREAM: Self = Self(0x7); + 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); +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct FrameHeader { + /// 24 bits on the wire. + pub length: u32, + pub frame_type: u8, + pub flags: u8, + /// Without the reserved bit. + pub stream_id: u32, +} + +impl FrameHeader { + pub fn new(frame_type: FrameType, flags: u8, stream_id: u32, length: usize) -> Self { + debug_assert!(length <= 0xff_ffff); + FrameHeader { + length: length.min(0xff_ffff) as u32, + frame_type: frame_type as u8, + flags, + stream_id, + } + } + + pub fn parse(raw: &[u8; FRAME_HEADER_SIZE]) -> Self { + FrameHeader { + length: u32::from_be_bytes([0, raw[0], raw[1], raw[2]]), + frame_type: raw[3], + flags: raw[4], + stream_id: read_u31([raw[5], raw[6], raw[7], raw[8]]), + } + } + + pub fn to_bytes(self) -> [u8; FRAME_HEADER_SIZE] { + let [_, l0, l1, l2] = self.length.to_be_bytes(); + let [s0, s1, s2, s3] = (self.stream_id & MAX_STREAM_ID).to_be_bytes(); + [l0, l1, l2, self.frame_type, self.flags, s0, s1, s2, s3] + } + + #[inline] + pub fn has(&self, flag: u8) -> bool { + self.flags & flag != 0 + } +} + +#[inline] +fn read_u32(payload: &[u8], at: usize) -> u32 { + u32::from_be_bytes([ + payload[at], + payload[at + 1], + payload[at + 2], + payload[at + 3], + ]) +} + +#[inline] +fn read_u31(raw: [u8; 4]) -> u32 { + u32::from_be_bytes(raw) & MAX_STREAM_ID +} + +/// A frame its receiver has to answer with GOAWAY or RST_STREAM. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct FrameError { + pub code: ErrorCode, + /// The stream to reset, or 0 for a connection error. + pub stream_id: u32, + pub reason: &'static str, +} + +impl FrameError { + pub const fn connection(code: ErrorCode, reason: &'static str) -> Self { + FrameError { + code, + stream_id: 0, + reason, + } + } +} + +pub enum NextFrame<'a> { + /// The frame header is incomplete. + NeedHeader, + /// `payload` is the part of the payload that has arrived. + Partial { + header: FrameHeader, + payload: &'a [u8], + }, + Complete { + header: FrameHeader, + payload: &'a [u8], + }, +} + +/// Splits the first frame off `input`. A length over `max_frame_size` is refused before its payload arrives (§4.2). +pub fn next_frame(input: &[u8], max_frame_size: u32) -> Result, FrameError> { + let Some((raw, rest)) = input.split_first_chunk::() else { + return Ok(NextFrame::NeedHeader); + }; + let header = FrameHeader::parse(raw); + if header.length > max_frame_size { + return Err(FrameError::connection( + ErrorCode::FRAME_SIZE_ERROR, + "frame exceeds SETTINGS_MAX_FRAME_SIZE", + )); + } + Ok(match rest.get(..header.length as usize) { + Some(payload) => NextFrame::Complete { header, payload }, + None => NextFrame::Partial { + header, + payload: rest, + }, + }) +} + +/// What a connection keeps of its read buffer after the first `parsed` bytes. A mostly spent buffer is dropped for a copy of the rest. A mostly unparsed one is kept, so a frame that comes in many reads isn't copied at each. +pub fn unparsed_rest(mut buffer: Vec, parsed: usize) -> Vec { + if (buffer.len() - parsed) * 2 < buffer.capacity() { + return buffer[parsed..].to_vec(); + } + buffer.drain(..parsed); + buffer +} + +/// The payload range between the Pad Length octet and the padding (§6.1). `pad_length` is the payload's first octet. +pub fn unpadded_range( + header: &FrameHeader, + pad_length: Option, +) -> Result, FrameError> { + let length = header.length as usize; + if !header.has(flags::PADDED) { + return Ok(0..length); + } + match pad_length { + Some(pad) if (pad as usize) < length => Ok(1..length - pad as usize), + _ => Err(FrameError::connection( + ErrorCode::PROTOCOL_ERROR, + "padding exceeds the frame payload", + )), + } +} + +fn unpadded<'a>(header: &FrameHeader, payload: &'a [u8]) -> Result<&'a [u8], FrameError> { + Ok(&payload[unpadded_range(header, payload.first().copied())?]) +} + +/// One complete inbound frame that passed every check its own bytes decide. +pub enum Frame<'a> { + Data { + data: &'a [u8], + end_stream: bool, + }, + Headers { + fragment: &'a [u8], + end_stream: bool, + end_headers: bool, + }, + Priority, + RstStream { + code: ErrorCode, + }, + Settings(Entries<'a>), + SettingsAck, + PushPromise { + promised_id: u32, + fragment: &'a [u8], + end_headers: bool, + }, + Ping { + ack: bool, + payload: [u8; 8], + }, + GoAway { + last_stream_id: u32, + code: ErrorCode, + debug: &'a [u8], + }, + /// `increment` is never 0. + WindowUpdate { + increment: u32, + }, + Continuation { + fragment: &'a [u8], + end_headers: bool, + }, + /// An extension frame. Its payload is unchecked. + AltSvc, + /// An extension frame. Its payload is unchecked. + Origin, + Unknown, +} + +impl<'a> Frame<'a> { + /// `payload` is all `header.length` octets. `max_settings` caps the entries of one SETTINGS frame. + pub fn parse( + header: &FrameHeader, + payload: &'a [u8], + max_settings: usize, + ) -> Result, FrameError> { + use FrameType as T; + debug_assert_eq!(payload.len(), header.length as usize); + let Some(frame_type) = FrameType::from_u8(header.frame_type) else { + return Ok(Frame::Unknown); + }; + let on_connection = header.stream_id == 0; + let misplaced = match frame_type { + T::Settings | T::Ping | T::GoAway => !on_connection, + T::Data + | T::Headers + | T::Priority + | T::RstStream + | T::PushPromise + | T::Continuation => on_connection, + T::WindowUpdate | T::AltSvc | T::Origin => false, + }; + if misplaced { + return Err(FrameError::connection( + ErrorCode::PROTOCOL_ERROR, + "frame on the wrong kind of stream", + )); + } + let wrong_length = match frame_type { + T::Priority => payload.len() != 5, + T::RstStream | T::WindowUpdate => payload.len() != 4, + T::Ping => payload.len() != 8, + T::GoAway => payload.len() < 8, + T::Settings if header.has(flags::ACK) => !payload.is_empty(), + _ => false, + }; + if wrong_length { + return Err(FrameError::connection( + ErrorCode::FRAME_SIZE_ERROR, + "frame has the wrong length for its type", + )); + } + let end_stream = header.has(flags::END_STREAM); + let end_headers = header.has(flags::END_HEADERS); + Ok(match frame_type { + T::Data => Frame::Data { + data: unpadded(header, payload)?, + end_stream, + }, + T::Headers => { + let mut fragment = unpadded(header, payload)?; + if header.has(flags::PRIORITY) { + let Some(after_priority) = fragment.get(5..) else { + return Err(FrameError::connection( + ErrorCode::FRAME_SIZE_ERROR, + "HEADERS priority fields are truncated", + )); + }; + fragment = after_priority; + } + Frame::Headers { + fragment, + end_stream, + end_headers, + } + } + T::Priority => Frame::Priority, + T::RstStream => Frame::RstStream { + code: ErrorCode(read_u32(payload, 0)), + }, + T::Settings if header.has(flags::ACK) => Frame::SettingsAck, + T::Settings => Frame::Settings(Entries::parse(payload, max_settings)?), + T::PushPromise => { + let Some((id, fragment)) = unpadded(header, payload)?.split_first_chunk::<4>() + else { + return Err(FrameError::connection( + ErrorCode::FRAME_SIZE_ERROR, + "PUSH_PROMISE has no promised stream id", + )); + }; + Frame::PushPromise { + promised_id: read_u31(*id), + fragment, + end_headers, + } + } + T::Ping => { + let mut opaque = [0u8; 8]; + opaque.copy_from_slice(payload); + Frame::Ping { + ack: header.has(flags::ACK), + payload: opaque, + } + } + T::GoAway => Frame::GoAway { + last_stream_id: read_u32(payload, 0) & MAX_STREAM_ID, + code: ErrorCode(read_u32(payload, 4)), + debug: &payload[8..], + }, + T::WindowUpdate => match read_u32(payload, 0) & MAX_STREAM_ID { + 0 => { + return Err(FrameError { + code: ErrorCode::PROTOCOL_ERROR, + stream_id: header.stream_id, + reason: "WINDOW_UPDATE with 0 increment", + }); + } + increment => Frame::WindowUpdate { increment }, + }, + T::Continuation => Frame::Continuation { + fragment: payload, + end_headers, + }, + T::AltSvc => Frame::AltSvc, + T::Origin => Frame::Origin, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse( + frame_type: FrameType, + flags: u8, + stream_id: u32, + payload: &[u8], + ) -> Result, FrameError> { + let header = FrameHeader::new(frame_type, flags, stream_id, payload.len()); + Frame::parse(&header, payload, 32) + } + + fn code_of(result: Result, FrameError>) -> Option<(ErrorCode, u32)> { + result.err().map(|e| (e.code, e.stream_id)) + } + + #[test] + fn a_spent_read_buffer_is_dropped() { + let rest = unparsed_rest(vec![7; 1000], 995); + assert_eq!(rest, [7; 5]); + assert!(rest.capacity() < 1000); + assert_eq!(unparsed_rest(vec![7; 1000], 1000).capacity(), 0); + } + + #[test] + fn a_frame_in_many_reads_is_copied_a_few_times() { + let mut buffer = Vec::new(); + let mut reallocations = 0; + for _ in 0..1000 { + let capacity = buffer.capacity(); + buffer.extend_from_slice(&[7; 100]); + buffer = unparsed_rest(buffer, 0); + reallocations += usize::from(buffer.capacity() != capacity); + } + assert_eq!(buffer.len(), 100_000); + assert!(reallocations < 20, "{reallocations}"); + } + + #[test] + fn header_round_trip_drops_the_reserved_bit() { + let header = FrameHeader { + length: 0x01_02_03, + frame_type: 0x4, + flags: 0x1, + stream_id: 0x7abc_def0, + }; + let mut raw = header.to_bytes(); + assert_eq!(FrameHeader::parse(&raw), header); + raw[5] |= 0x80; + assert_eq!(FrameHeader::parse(&raw), header); + } + + #[test] + fn next_frame_splits_and_refuses_oversized_frames() { + let mut input = FrameHeader::new(FrameType::Data, 0, 1, 3) + .to_bytes() + .to_vec(); + assert!(matches!( + next_frame(&input[..8], 16_384), + Ok(NextFrame::NeedHeader) + )); + input.extend_from_slice(b"ab"); + assert!(matches!( + next_frame(&input, 16_384), + Ok(NextFrame::Partial { payload: b"ab", .. }) + )); + input.extend_from_slice(b"cd"); + assert!(matches!( + next_frame(&input, 16_384), + Ok(NextFrame::Complete { + payload: b"abc", + .. + }) + )); + assert_eq!( + next_frame(&input, 2).err().map(|e| e.code), + Some(ErrorCode::FRAME_SIZE_ERROR) + ); + } + + #[test] + fn stream_id_rules() { + let connection = |code| Some((code, 0)); + assert_eq!( + code_of(parse(FrameType::Settings, 0, 1, &[])), + connection(ErrorCode::PROTOCOL_ERROR) + ); + assert_eq!( + code_of(parse(FrameType::Data, 0, 0, b"abc")), + connection(ErrorCode::PROTOCOL_ERROR) + ); + assert!(parse(FrameType::WindowUpdate, 0, 0, &[0, 0, 0, 1]).is_ok()); + assert!(parse(FrameType::WindowUpdate, 0, 3, &[0, 0, 0, 1]).is_ok()); + } + + #[test] + fn fixed_lengths() { + let size_error = Some((ErrorCode::FRAME_SIZE_ERROR, 0)); + assert_eq!(code_of(parse(FrameType::Ping, 0, 0, &[0; 6])), size_error); + assert_eq!( + code_of(parse(FrameType::Priority, 0, 1, &[0; 4])), + size_error + ); + assert_eq!( + code_of(parse(FrameType::RstStream, 0, 1, &[0; 5])), + size_error + ); + assert_eq!(code_of(parse(FrameType::GoAway, 0, 0, &[0; 7])), size_error); + assert_eq!( + code_of(parse(FrameType::Settings, 0, 0, &[0; 5])), + size_error + ); + assert_eq!( + code_of(parse(FrameType::Settings, flags::ACK, 0, &[0; 6])), + size_error + ); + } + + #[test] + fn padding() { + let protocol_error = Some((ErrorCode::PROTOCOL_ERROR, 0)); + assert!(matches!( + parse(FrameType::Data, flags::PADDED, 1, &[2, b'h', b'i', 0, 0]), + Ok(Frame::Data { data: b"hi", .. }) + )); + assert!(matches!( + parse(FrameType::Data, flags::PADDED, 1, &[1, 0]), + Ok(Frame::Data { data: b"", .. }) + )); + assert_eq!( + code_of(parse(FrameType::Data, flags::PADDED, 1, &[2, 0])), + protocol_error + ); + assert_eq!( + code_of(parse(FrameType::Headers, flags::PADDED, 1, &[])), + protocol_error + ); + } + + #[test] + fn headers_priority_fields_are_skipped() { + assert!(matches!( + parse( + FrameType::Headers, + flags::PRIORITY, + 1, + &[0, 0, 0, 0, 16, 0x82] + ), + Ok(Frame::Headers { + fragment: &[0x82], + .. + }) + )); + assert_eq!( + code_of(parse(FrameType::Headers, flags::PRIORITY, 1, &[0; 3])), + Some((ErrorCode::FRAME_SIZE_ERROR, 0)) + ); + } + + #[test] + fn window_update_of_zero_is_scoped_to_its_stream() { + assert_eq!( + code_of(parse(FrameType::WindowUpdate, 0, 0, &[0; 4])), + Some((ErrorCode::PROTOCOL_ERROR, 0)) + ); + assert_eq!( + code_of(parse(FrameType::WindowUpdate, 0, 5, &[0x80, 0, 0, 0])), + Some((ErrorCode::PROTOCOL_ERROR, 5)) + ); + } + + #[test] + fn push_promise_needs_a_promised_id() { + assert!(matches!( + parse(FrameType::PushPromise, 0, 1, &[0x80, 0, 0, 2, 0x82]), + Ok(Frame::PushPromise { + promised_id: 2, + fragment: &[0x82], + .. + }) + )); + assert_eq!( + code_of(parse(FrameType::PushPromise, 0, 1, &[0; 3])), + Some((ErrorCode::FRAME_SIZE_ERROR, 0)) + ); + } + + #[test] + fn unknown_types_are_not_errors() { + let header = FrameHeader { + length: 0, + frame_type: 0xfe, + flags: 0xff, + stream_id: 0, + }; + assert!(matches!(Frame::parse(&header, &[], 32), Ok(Frame::Unknown))); + } +} diff --git a/src/http_types/h2/header_block.rs b/src/http_types/h2/header_block.rs new file mode 100644 index 000000000000..5d7522b52186 --- /dev/null +++ b/src/http_types/h2/header_block.rs @@ -0,0 +1,149 @@ +//! Reassembly of a header block from HEADERS or PUSH_PROMISE and its CONTINUATION frames (RFC 9113 §4.3, §6.10). + +use super::frame::{FrameHeader, FrameType}; +use super::settings::DEFAULT_MAX_FRAME_SIZE; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum HeaderBlockError { + TooManyContinuations, + TooLarge, +} + +/// At most one block is open per connection. A small buffer keeps its capacity between blocks. +#[derive(Default)] +pub struct HeaderBlockAssembler { + block: Vec, + /// The stream whose CONTINUATION has to come next, or 0. + open_on: u32, + continuations: u32, +} + +impl HeaderBlockAssembler { + /// Returns false for a frame that can't come next. Call it for every frame, before any other check of it. + pub fn admits(&self, header: &FrameHeader) -> bool { + let is_continuation = header.frame_type == FrameType::Continuation as u8; + if self.open_on == 0 { + !is_continuation + } else { + is_continuation && header.stream_id == self.open_on + } + } + + /// Returns true between a frame without END_HEADERS and the CONTINUATION that has it. + pub fn is_open(&self) -> bool { + self.open_on != 0 + } + + /// Starts a block with the fragment of a HEADERS or PUSH_PROMISE on `stream_id`. Returns true if it's complete. + pub fn begin(&mut self, stream_id: u32, fragment: &[u8], end_headers: bool) -> bool { + debug_assert_eq!(self.open_on, 0); + self.block.clear(); + self.block.extend_from_slice(fragment); + self.continuations = 0; + self.open_on = if end_headers { 0 } else { stream_id }; + end_headers + } + + /// Appends the fragment of a CONTINUATION that `admits` let through. Returns true if the block is complete. + pub fn push( + &mut self, + fragment: &[u8], + end_headers: bool, + max_continuations: u32, + max_len: usize, + ) -> Result { + self.continuations += 1; + if self.continuations > max_continuations { + return Err(HeaderBlockError::TooManyContinuations); + } + if self.block.len().saturating_add(fragment.len()) > max_len { + return Err(HeaderBlockError::TooLarge); + } + self.block.extend_from_slice(fragment); + if end_headers { + self.open_on = 0; + } + Ok(end_headers) + } + + pub fn capacity(&self) -> usize { + self.block.capacity() + } + + /// Takes the complete block. Hand the buffer back with `recycle`. + pub fn take(&mut self) -> Vec { + debug_assert_eq!(self.open_on, 0); + core::mem::take(&mut self.block) + } + + /// Drops a buffer that outgrew one frame of the default size, so a large block doesn't keep its size for the life of the connection. + pub fn recycle(&mut self, mut block: Vec) { + if block.capacity() <= DEFAULT_MAX_FRAME_SIZE as usize { + block.clear(); + self.block = block; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn header(frame_type: FrameType, stream_id: u32) -> FrameHeader { + FrameHeader::new(frame_type, 0, stream_id, 0) + } + + #[test] + fn a_block_in_one_frame() { + let mut assembler = HeaderBlockAssembler::default(); + assert!(assembler.admits(&header(FrameType::Headers, 1))); + assert!(assembler.begin(1, b"ab", true)); + assert!(assembler.admits(&header(FrameType::Data, 1))); + assert_eq!(assembler.take(), b"ab"); + } + + #[test] + fn only_its_continuation_follows_an_open_block() { + let mut assembler = HeaderBlockAssembler::default(); + assert!(!assembler.admits(&header(FrameType::Continuation, 1))); + assert!(!assembler.begin(1, b"ab", false)); + assert!(!assembler.admits(&header(FrameType::Headers, 1))); + assert!(!assembler.admits(&header(FrameType::Ping, 0))); + assert!(!assembler.admits(&header(FrameType::Continuation, 3))); + assert!(assembler.admits(&header(FrameType::Continuation, 1))); + assert_eq!(assembler.push(b"cd", false, 8, 100), Ok(false)); + assert_eq!(assembler.push(b"e", true, 8, 100), Ok(true)); + assert!(!assembler.admits(&header(FrameType::Continuation, 1))); + assert_eq!(assembler.take(), b"abcde"); + } + + #[test] + fn only_a_small_buffer_is_kept() { + let mut assembler = HeaderBlockAssembler::default(); + assembler.begin(1, &[0; 100], true); + let block = assembler.take(); + assembler.recycle(block); + assert!(assembler.capacity() >= 100); + assembler.begin(3, &[0; DEFAULT_MAX_FRAME_SIZE as usize + 1], true); + let block = assembler.take(); + assembler.recycle(block); + assert_eq!(assembler.capacity(), 0); + } + + #[test] + fn caps() { + let mut assembler = HeaderBlockAssembler::default(); + assembler.begin(1, b"ab", false); + assert_eq!(assembler.push(b"", false, 1, 100), Ok(false)); + assert_eq!( + assembler.push(b"", false, 1, 100), + Err(HeaderBlockError::TooManyContinuations) + ); + assembler = HeaderBlockAssembler::default(); + assembler.begin(1, b"ab", false); + assert_eq!( + assembler.push(b"c", true, 8, 2), + Err(HeaderBlockError::TooLarge) + ); + } +} diff --git a/src/http_types/h2/settings.rs b/src/http_types/h2/settings.rs new file mode 100644 index 000000000000..87f5b98a4a89 --- /dev/null +++ b/src/http_types/h2/settings.rs @@ -0,0 +1,195 @@ +//! HTTP/2 SETTINGS (RFC 9113 §6.5). + +use super::flow_control::MAX_WINDOW_SIZE; +use super::frame::{ErrorCode, FrameError}; + +pub const ENTRY_SIZE: usize = 6; +pub const DEFAULT_MAX_FRAME_SIZE: u32 = 16_384; +const MAX_MAX_FRAME_SIZE: u32 = 0xff_ffff; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[repr(u16)] +pub enum SettingId { + HeaderTableSize = 0x1, + EnablePush = 0x2, + MaxConcurrentStreams = 0x3, + InitialWindowSize = 0x4, + MaxFrameSize = 0x5, + MaxHeaderListSize = 0x6, + /// RFC 8441 §3 + EnableConnectProtocol = 0x8, + /// RFC 9218 §2.1 + NoRfc7540Priorities = 0x9, +} + +impl SettingId { + /// `None` for an id outside the registry, which §6.5.2 says to ignore. + pub fn from_u16(value: u16) -> Option { + Some(match value { + 0x1 => SettingId::HeaderTableSize, + 0x2 => SettingId::EnablePush, + 0x3 => SettingId::MaxConcurrentStreams, + 0x4 => SettingId::InitialWindowSize, + 0x5 => SettingId::MaxFrameSize, + 0x6 => SettingId::MaxHeaderListSize, + 0x8 => SettingId::EnableConnectProtocol, + 0x9 => SettingId::NoRfc7540Priorities, + _ => return None, + }) + } +} + +/// One endpoint's settings. The defaults are Node's `getDefaultSettings()`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Settings { + pub header_table_size: u32, + pub enable_push: bool, + pub max_concurrent_streams: u32, + pub initial_window_size: u32, + pub max_frame_size: u32, + pub max_header_list_size: u32, + pub enable_connect_protocol: bool, +} + +impl Default for Settings { + fn default() -> Self { + Settings { + header_table_size: 4_096, + enable_push: true, + max_concurrent_streams: u32::MAX, + initial_window_size: 65_535, + max_frame_size: DEFAULT_MAX_FRAME_SIZE, + max_header_list_size: 65_535, + enable_connect_protocol: false, + } + } +} + +impl Settings { + pub fn apply(&mut self, id: SettingId, value: u32) { + match id { + SettingId::HeaderTableSize => self.header_table_size = value, + SettingId::EnablePush => self.enable_push = value != 0, + SettingId::MaxConcurrentStreams => self.max_concurrent_streams = value, + SettingId::InitialWindowSize => self.initial_window_size = value, + SettingId::MaxFrameSize => self.max_frame_size = value, + SettingId::MaxHeaderListSize => self.max_header_list_size = value, + SettingId::EnableConnectProtocol => self.enable_connect_protocol = value != 0, + SettingId::NoRfc7540Priorities => {} + } + } +} + +/// The connection error for a value outside its range (§6.5.2). +fn range_error(id: u16, value: u32) -> Option { + use SettingId as Id; + match SettingId::from_u16(id)? { + Id::EnablePush | Id::EnableConnectProtocol | Id::NoRfc7540Priorities if value > 1 => { + Some(ErrorCode::PROTOCOL_ERROR) + } + Id::InitialWindowSize if value > MAX_WINDOW_SIZE => Some(ErrorCode::FLOW_CONTROL_ERROR), + Id::MaxFrameSize if !(DEFAULT_MAX_FRAME_SIZE..=MAX_MAX_FRAME_SIZE).contains(&value) => { + Some(ErrorCode::PROTOCOL_ERROR) + } + _ => None, + } +} + +pub fn entry(id: u16, value: u32) -> [u8; ENTRY_SIZE] { + let [i0, i1] = id.to_be_bytes(); + let [v0, v1, v2, v3] = value.to_be_bytes(); + [i0, i1, v0, v1, v2, v3] +} + +/// The `(id, value)` entries of a SETTINGS payload, in wire order. Every value is in range. +#[derive(Clone, Copy)] +pub struct Entries<'a>(&'a [[u8; ENTRY_SIZE]]); + +impl<'a> Entries<'a> { + pub(super) fn parse(payload: &'a [u8], max_entries: usize) -> Result { + let (entries, rest) = payload.as_chunks::(); + if !rest.is_empty() { + return Err(FrameError::connection( + ErrorCode::FRAME_SIZE_ERROR, + "SETTINGS length is not a multiple of 6", + )); + } + if entries.len() > max_entries { + return Err(FrameError::connection( + ErrorCode::ENHANCE_YOUR_CALM, + "SETTINGS: too many settings entries", + )); + } + let entries = Entries(entries); + let mut unchecked = entries; + match unchecked.find_map(|(id, value)| range_error(id, value)) { + Some(code) => Err(FrameError::connection(code, "SETTINGS value out of range")), + None => Ok(entries), + } + } +} + +impl Iterator for Entries<'_> { + type Item = (u16, u32); + + fn next(&mut self) -> Option<(u16, u32)> { + let (&[i0, i1, v0, v1, v2, v3], rest) = self.0.split_first()?; + self.0 = rest; + Some(( + u16::from_be_bytes([i0, i1]), + u32::from_be_bytes([v0, v1, v2, v3]), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(entries: &[(u16, u32)], max_entries: usize) -> Result, ErrorCode> { + let payload: Vec = entries.iter().flat_map(|&(id, v)| entry(id, v)).collect(); + match Entries::parse(&payload, max_entries) { + Ok(parsed) => Ok(parsed.collect()), + Err(e) => Err(e.code), + } + } + + #[test] + fn entries_round_trip_in_wire_order() { + let entries = [(0x4, 1), (0x1, 0), (0xf000, u32::MAX), (0x4, 2)]; + assert_eq!(parse(&entries, 4), Ok(entries.to_vec())); + } + + #[test] + fn values_out_of_range() { + assert_eq!(parse(&[(0x2, 2)], 32), Err(ErrorCode::PROTOCOL_ERROR)); + assert_eq!(parse(&[(0x8, 2)], 32), Err(ErrorCode::PROTOCOL_ERROR)); + assert_eq!( + parse(&[(0x4, 0x8000_0000)], 32), + Err(ErrorCode::FLOW_CONTROL_ERROR) + ); + assert_eq!(parse(&[(0x5, 16_383)], 32), Err(ErrorCode::PROTOCOL_ERROR)); + assert_eq!( + parse(&[(0x5, 0x100_0000)], 32), + Err(ErrorCode::PROTOCOL_ERROR) + ); + assert!(parse(&[(0x5, 16_384), (0x2, 1)], 32).is_ok()); + } + + #[test] + fn the_entry_cap_comes_before_the_values() { + assert_eq!( + parse(&[(0x1, 0), (0x2, 2)], 1), + Err(ErrorCode::ENHANCE_YOUR_CALM) + ); + } + + #[test] + fn apply_sets_the_named_setting() { + let mut settings = Settings::default(); + settings.apply(SettingId::EnablePush, 0); + settings.apply(SettingId::MaxFrameSize, 20_000); + assert!(!settings.enable_push); + assert_eq!(settings.max_frame_size, 20_000); + } +} diff --git a/src/http_types/h2/stream.rs b/src/http_types/h2/stream.rs new file mode 100644 index 000000000000..4e4570c2c18e --- /dev/null +++ b/src/http_types/h2/stream.rs @@ -0,0 +1,155 @@ +//! The HTTP/2 stream state machine (RFC 9113 §5.1). + +/// The values are nghttp2's `nghttp2_stream_proto_state`, which node:http2 reports as `stream.state.state`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[repr(u8)] +pub enum State { + Idle = 1, + Open = 2, + ReservedLocal = 3, + ReservedRemote = 4, + HalfClosedLocal = 5, + HalfClosedRemote = 6, + Closed = 7, +} + +impl State { + /// Returns true while the peer's half is open. + #[inline] + pub fn can_receive(self) -> bool { + matches!(self, State::Open | State::HalfClosedLocal) + } + + /// Returns true while our half is open. + #[inline] + pub fn can_send(self) -> bool { + matches!(self, State::Open | State::HalfClosedRemote) + } +} + +/// `Send*` is a frame we wrote, `Recv*` one the peer sent. END_STREAM is its own event after the frame that carried it. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Event { + SendHeaders, + RecvHeaders, + SendEndStream, + RecvEndStream, + SendPushPromise, + RecvPushPromise, + Reset, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum TransitionError { + /// The state doesn't allow the frame. + Protocol, + /// The half the frame belongs to is already closed. + StreamClosed, +} + +pub fn transition(state: State, event: Event) -> Result { + use Event::*; + use State::*; + Ok(match (state, event) { + (Idle, SendHeaders | RecvHeaders) => Open, + (Idle, SendPushPromise) => ReservedLocal, + (Idle, RecvPushPromise) => ReservedRemote, + (Idle, _) => return Err(TransitionError::Protocol), + + (_, Reset) => Closed, + + (ReservedLocal, SendHeaders) => HalfClosedRemote, + (ReservedRemote, RecvHeaders) => HalfClosedLocal, + (ReservedLocal | ReservedRemote, _) => return Err(TransitionError::Protocol), + + (Open, SendHeaders | RecvHeaders) => Open, + (Open, SendEndStream) => HalfClosedLocal, + (Open, RecvEndStream) => HalfClosedRemote, + + (HalfClosedLocal, RecvHeaders) => HalfClosedLocal, + (HalfClosedLocal, RecvEndStream) => Closed, + (HalfClosedRemote, SendHeaders) => HalfClosedRemote, + (HalfClosedRemote, SendEndStream) => Closed, + + (HalfClosedRemote | Closed, RecvHeaders | RecvEndStream) => { + return Err(TransitionError::StreamClosed); + } + (HalfClosedLocal | Closed, SendHeaders | SendEndStream) => { + return Err(TransitionError::StreamClosed); + } + (_, SendPushPromise | RecvPushPromise) => return Err(TransitionError::Protocol), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(events: &[Event]) -> Result { + events + .iter() + .try_fold(State::Idle, |state, &event| transition(state, event)) + } + + #[test] + fn request_and_response() { + use Event::*; + assert_eq!(run(&[RecvHeaders]), Ok(State::Open)); + assert_eq!( + run(&[RecvHeaders, RecvEndStream]), + Ok(State::HalfClosedRemote) + ); + assert_eq!( + run(&[RecvHeaders, RecvEndStream, SendHeaders, SendEndStream]), + Ok(State::Closed) + ); + assert_eq!( + run(&[ + SendHeaders, + SendEndStream, + RecvHeaders, + RecvHeaders, + RecvEndStream + ]), + Ok(State::Closed) + ); + } + + #[test] + fn push() { + use Event::*; + assert_eq!( + run(&[RecvPushPromise, RecvHeaders]), + Ok(State::HalfClosedLocal) + ); + assert_eq!( + run(&[SendPushPromise, SendHeaders]), + Ok(State::HalfClosedRemote) + ); + assert_eq!( + run(&[RecvPushPromise, RecvEndStream]), + Err(TransitionError::Protocol) + ); + assert_eq!(run(&[SendPushPromise, Reset]), Ok(State::Closed)); + } + + #[test] + fn frames_the_state_refuses() { + use Event::*; + assert_eq!(run(&[Reset]), Err(TransitionError::Protocol)); + assert_eq!(run(&[RecvEndStream]), Err(TransitionError::Protocol)); + assert_eq!( + run(&[RecvHeaders, RecvEndStream, RecvHeaders]), + Err(TransitionError::StreamClosed) + ); + assert_eq!( + run(&[RecvHeaders, Reset, RecvEndStream]), + Err(TransitionError::StreamClosed) + ); + assert_eq!( + run(&[SendHeaders, SendEndStream, SendEndStream]), + Err(TransitionError::StreamClosed) + ); + assert_eq!(run(&[RecvHeaders, Reset, Reset]), Ok(State::Closed)); + } +} diff --git a/src/http_types/lib.rs b/src/http_types/lib.rs index d557b689a5eb..fedf65a60188 100644 --- a/src/http_types/lib.rs +++ b/src/http_types/lib.rs @@ -40,3 +40,23 @@ pub fn parse_content_length_strict(value: &[u8]) -> Option { } Some(n) } + +/// RFC 9110 §5.6.2 `tchar`. +#[inline] +pub fn is_token_char(c: u8) -> bool { + c.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&c) +} + +/// RFC 9110 §5.6.2 `token`. +pub fn is_token(bytes: &[u8]) -> bool { + !bytes.is_empty() && bytes.iter().all(|&c| is_token_char(c)) +} + +/// 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/install/NetworkTask.rs b/src/install/NetworkTask.rs index 640220c9fc54..761003b4bdb4 100644 --- a/src/install/NetworkTask.rs +++ b/src/install/NetworkTask.rs @@ -944,7 +944,7 @@ impl NetworkTask { // // Only wire up the one signal we need; `Signals.Store.to()` // would also publish `aborted`/`cert_errors`/etc., which makes - // the HTTP client allocate an abort-tracker id and changes + // the HTTP client watch for an abort and changes // keep-alive behaviour we don't want here. self.signal_store = http::signals::Store::default(); self.signal_store diff --git a/src/install/PackageManager/runTasks.rs b/src/install/PackageManager/runTasks.rs index 5ec9351fff8b..d0d0695743e2 100644 --- a/src/install/PackageManager/runTasks.rs +++ b/src/install/PackageManager/runTasks.rs @@ -1717,14 +1717,8 @@ pub(crate) fn download_outcome( | http::Error::TooManyRedirects ) ); - // `connect_errno` is a Winsock code on Windows: WSAETIMEDOUT. - const CONNECT_TIMED_OUT: i32 = if cfg!(windows) { - 10060 - } else { - bun_errno::SystemErrno::ETIMEDOUT as i32 - }; let silence = matches!(response.fail, Some(http::Error::Timeout)) - || response.connect_errno == CONNECT_TIMED_OUT; + || response.connect_errno == Some(bun_errno::SystemErrno::ETIMEDOUT); let retry = !is_final && !(blocking && (silence || status == Some(501))) && (failed || status.is_some_and(|status| status > 499 || status == 429 || status == 408)); diff --git a/src/io/PipeReader.rs b/src/io/PipeReader.rs index 22cba74cefba..3b0165b2c4a6 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 @@ -1355,6 +1360,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/io/posix_event_loop.rs b/src/io/posix_event_loop.rs index 3bfe5f47ea45..c6952b649b8a 100644 --- a/src/io/posix_event_loop.rs +++ b/src/io/posix_event_loop.rs @@ -198,6 +198,7 @@ pub enum PollTag { ParentDeathWatchdog, LifecycleScriptSubprocessOutputReader, MemoryPressure, + FdPipe, } /// Compatibility module — call sites in `bun_runtime`/`bun_install` still spell diff --git a/src/io/write.rs b/src/io/write.rs index 9b13e110c065..dea7baaa2e48 100644 --- a/src/io/write.rs +++ b/src/io/write.rs @@ -82,18 +82,6 @@ impl FixedBufferStream { pub fn new(buffer: B) -> Self { Self { buffer, pos: 0 } } - - /// Seek to absolute position `p`. - #[inline] - pub fn seek_to(&mut self, p: usize) { - self.pos = p; - } - - /// Rewind to position 0. - #[inline] - pub fn reset(&mut self) { - self.pos = 0; - } } impl<'a> FixedBufferStream<&'a mut [u8]> { diff --git a/src/js/ahead-of-time/builtins/ProcessObjectInternals.ts b/src/js/ahead-of-time/builtins/ProcessObjectInternals.ts index ffb3ba830cae..12d7cac070f5 100644 --- a/src/js/ahead-of-time/builtins/ProcessObjectInternals.ts +++ b/src/js/ahead-of-time/builtins/ProcessObjectInternals.ts @@ -1,5 +1,5 @@ // What the compiler reads in place of src/js/builtins/ProcessObjectInternals.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 34b28730976f3729b1f08385ac19a7f622a31d5c76459e81ae77e00f2e40eba8. +// Written against that file with the SHA-256 6fec4cb7237967bff661ffd182ab83e75b7e52ab54ee17116b6b405521b947b9. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. /* @@ -155,9 +155,9 @@ export function getStdinStream(process: NodeJS.Process, fd: number, isTTY: boole // listeners, resume(), ref(), an explicit read()) may take the reader back. if (stream_reachedEof) return; $debug("ref();", reader ? "already has reader" : "getting reader"); + // Attaching a reader also undoes the pause that disown() left on the source. reader ??= native.getReader(); source.updateRef(forceUnref ? false : true); - source?.setFlowing?.(true); if (needsInternalReadRefresh) { needsInternalReadRefresh = false; @@ -165,18 +165,24 @@ export function getStdinStream(process: NodeJS.Process, fd: number, isTTY: boole } } - function disown() { + // Asked when pause() or destroy() is called, because disown() runs a tick later. + const isHeldByAnotherReader = () => !reader && native.locked; + + function disown(wasHeldByAnotherReader = false) { $debug("unref();"); - source?.setFlowing?.(false); + // Only a reader of Bun.stdin.stream() that came after the call keeps the source. Nothing else stops it. + if (reader || wasHeldByAnotherReader || !native.locked) source?.setFlowing?.(false); + if (wasHeldByAnotherReader) source?.updateRef?.(false); if (reader) { // releaseLock() rejects any in-flight internalRead() with a TypeError; that // rejection is handled there by observing that `reader` was cleared here. reader.releaseLock(); reader = undefined; $debug("released reader"); + // A paused source keeps nothing alive; its ref is for the next reader of Bun.stdin.stream(). + source?.updateRef?.(!forceUnref); } - source?.updateRef?.(false); } const ReadStream = isTTY ? require("node:tty").ReadStream : require("node:fs").ReadStream; @@ -320,11 +326,12 @@ export function getStdinStream(process: NodeJS.Process, fd: number, isTTY: boole stream._readableState.reading = false; stream.on("pause", () => { + const wasHeldByAnotherReader = isHeldByAnotherReader(); process.nextTick(() => { // Only disown if the stream is still paused (not resumed in the meantime) if (!stream.readableFlowing) { stream._readableState.reading = false; - disown(); + disown(wasHeldByAnotherReader); } }); }); @@ -340,12 +347,23 @@ export function getStdinStream(process: NodeJS.Process, fd: number, isTTY: boole } }); + // 'close' comes a tick or more after the call, and so does _destroy() while the stream isn't constructed yet. + let wasHeldWhenDestroyed: boolean | undefined; + const originalDestroy = stream.destroy; + stream.destroy = function (err, callback) { + if (!this.destroyed) wasHeldWhenDestroyed = isHeldByAnotherReader(); + return originalDestroy.$call(this, err, callback); + }; + stream.on("close", () => { if (!stream_destroyed) { stream_destroyed = true; + // Readable.prototype.destroy.call(process.stdin) doesn't pass through the function above. + const wasHeldByAnotherReader = wasHeldWhenDestroyed ?? isHeldByAnotherReader(); + wasHeldWhenDestroyed = undefined; process.nextTick(() => { stream.destroy(); - disown(); + disown(wasHeldByAnotherReader); }); } }); diff --git a/src/js/ahead-of-time/internal/http.ts b/src/js/ahead-of-time/internal/http.ts index c2ba03978ad5..d7938ac822ad 100644 --- a/src/js/ahead-of-time/internal/http.ts +++ b/src/js/ahead-of-time/internal/http.ts @@ -1,76 +1,45 @@ // What the compiler reads in place of src/js/internal/http.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 fb8173ad4b1d435f59685c97012314538ff32e2e02c603547e9fb33660589e61. +// Written against that file with the SHA-256 b303ec0a03f9807d44a295ae0168bafbf6ca315553e457928a4d1ba26cceac81. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. const { isIPv4 } = require("internal/net/isIP"); const { queueTick1, queueTick2 } = require("internal/process/next_tick_queue"); -const { setServerCustomOptions, setServerAppFlags, setServerMaxHeadersCount, drainMicrotasks } = $cpp( - "NodeHTTP.cpp", - "createNodeHTTPInternalBinding", -) as { - setServerCustomOptions: ( - server: $Node.NativeHTTPServer, - requireHostHeader: boolean, - useStrictMethodValidation: boolean, - lenientHttpFlags: number, - maxHeaderSize: number, - onClientError: ( - ssl: boolean, - socket: $Node.NodeHTTPServerSocket, - errorCode: number, - rawPacket: ArrayBuffer, - ) => void, - onConnection: (socketHandle: $Node.NodeHTTPServerSocket) => void, - httpAllowHalfOpen: boolean, - ) => void; - setServerAppFlags: ( - server: $Node.NativeHTTPServer, - requireHostHeader: boolean, - useStrictMethodValidation: boolean, - lenientHttpFlags: number, - httpAllowHalfOpen: boolean, - ) => void; - setServerMaxHeadersCount: (server: $Node.NativeHTTPServer, maxHeadersCount: number) => void; - drainMicrotasks: () => void; -}; +const queueMicrotask = globalThis.queueMicrotask; + +const { drainMicrotasks } = $cpp("NodeHTTP.cpp", "createNodeHTTPInternalBinding") as { drainMicrotasks: () => void }; const abortedSymbol = Symbol("aborted"); -const headerStateSymbol = Symbol("headerState"); const fakeSocketSymbol = Symbol("fakeSocket"); const eofInProgress = Symbol("eofInProgress"); const isTlsSymbol = Symbol("is_tls"); const kHandle = Symbol("handle"); const kMadeOverHandle = Symbol("handle"); -const kOnReadParsed = Symbol("kOnReadParsed"); +// http.Server.prototype method for the `ws` package: takes a connection out of HTTP. +const kLeaveHttp = Symbol("kLeaveHttp"); +// On a server socket: the stream that 'upgrade' got in its place, while the body of the request is incomplete. +const kUpgradeStream = Symbol("kUpgradeStream"); const kRealListen = Symbol("kRealListen"); const noBodySymbol = Symbol("noBody"); -const optionsSymbol = Symbol("options"); const tlsSymbol = Symbol("tls"); const kAbortController = Symbol.for("kAbortController"); const kInternalSocketData = Symbol.for("::bunternal::"); // One symbol under two names. Asked for twice, as in the other text: a test may have replaced Symbol.for. const serverSymbol: typeof kInternalSocketData = Symbol.for("::bunternal::") as typeof kInternalSocketData; const kPendingCallbacks = Symbol("pendingCallbacks"); -const kRequest = Symbol("request"); // IncomingMessage slot for the request's native response. null if the request was parsed in JS. const kHeaderSource = Symbol("kHeaderSource"); -// Set on a server socket at the 'connect'/'upgrade' handoff: the native response of that request. -const kHandoffResponse = Symbol("kHandoffResponse"); -// Method of a server socket. Whoever takes the connection over calls it while the connection is still open. -const kRememberPeer = Symbol("kRememberPeer"); -// Getter of a server socket: whether the native side has read bytes or the end of a tunnel's stream that it will push -// into the socket in a task. -const kHasQueuedReads = Symbol("kHasQueuedReads"); -// Property of a server socket: whether the connection closed before the kernel had taken everything that was written. -// undefined until it has closed. -const kClosedWithUnsentData = Symbol("kClosedWithUnsentData"); const kCloseCallback = Symbol("closeCallback"); const kCorked = Symbol("corked"); const kChunkedBuffer = Symbol("kChunkedBuffer"); const kChunkedLength = Symbol("kChunkedLength"); const kBytesWritten = Symbol("kBytesWritten"); const kLenientValidation = Symbol("kLenientValidation"); +// Filled by node:_http_server at module initialization. node:https builds https.Server from these. +const httpServerParts: { + initHttpServer?: (this: $Node.HttpServer, options?: any, callback?: any) => $Node.HttpServer; + serverPrototypeDescriptors?: PropertyDescriptorMap; +} = {}; const requestRoad: { endsUnseen?: (request: $Node.IncomingMessage) => boolean; endUnseen?: (request: $Node.IncomingMessage) => void; @@ -94,7 +63,11 @@ const responseRoad: { requestState: $Node.ReadableState | null, ) => $Node.HttpExchange; storeHeader?: (exchange: $Node.HttpExchange, name: string, value: $Node.HttpHeaderValue) => void; - settleLeniency?: (exchange: $Node.HttpExchange, name: string) => void; + storeOnMessageAsFirstHeaderDoes?: ( + message: $Node.OutgoingMessage, + exchange: $Node.HttpExchange, + name: string, + ) => void; spillHeaders?: (exchange: $Node.HttpExchange) => void; spillHeadersReachedThrough?: (receiver: $Node.OutgoingMessage) => void; spillStateReachedThrough?: (receiver: $Node.OutgoingMessage) => void; @@ -114,28 +87,9 @@ const responseRoad: { value: $Node.HttpHeaderValue, ) => void; outgoingSetHeader?: $Node.OutgoingMessage["setHeader"]; - flushSocketHeldChunk?: (socket: object | null | undefined) => void; noChunks?: (string | $Node.HttpChunk | $Node.HttpWriteCallback | null | undefined)[]; } = {}; -// node:_http_server registers its pipelined-response machinery here at module -// initialization, letting internal/http1_server_fallback drive the same -// per-connection queue without widening node:_http_server's exports. The -// fallback loads node:http (and with it _http_server) before reading these. -const http1ServerPipeline: { - queuePipelinedResponse?: (socket: $Node.HttpSocket, res: $Node.ServerResponse, isAncient: boolean) => void; - advanceResponsePipeline?: ( - server: $Node.HttpSocketServer | null | undefined, - socket: $Node.HttpSocket | null | undefined, - ) => void; - abortQueuedPipelinedResponses?: (socket: $Node.HttpSocket, error?: Error) => void; - lastPipelinedResponse?: (socket: $Node.HttpSocket) => $Node.PipelinedResponse | undefined; - maybePauseFallbackReads?: (socket: $Node.HttpSocket) => void; - resumeFallbackReadsOnDrain?: (socket: $Node.HttpSocket) => void; - finishDrainedResponse?: (res: $Node.ServerResponse) => void; - mustCloseConnection?: (res: $Node.ServerResponse) => boolean; -} = {}; - export const enum NodeHTTPResponseAbortEvent { none = 0, abort = 1, @@ -145,7 +99,7 @@ export const enum NodeHTTPResponseAbortEvent { export const enum NodeHTTPBodyReadState { none, pending = 1 << 1, - /** The last chunk arrived, or a WebSocket took the connection. */ + /** The last chunk arrived, or the reader let go of the body. */ done = 1 << 2, /** The connection closed before the last chunk. */ aborted = 1 << 3, @@ -156,18 +110,10 @@ export const enum NodeHTTPResponseFlags { socket_closed = 1 << 0, request_has_completed = 1 << 1, ended = 1 << 2, - upgraded = 1 << 3, - dispatch_threw_while_queued = 1 << 9, closed_or_completed = socket_closed | request_has_completed, } -export const enum NodeHTTPHeaderState { - none, - assigned, - sent, -} - function emitErrorNextTickIfErrorListenerNT( self: $Node.EventEmitter, err: Error | null | undefined, @@ -192,11 +138,11 @@ function emitErrorNextTickIfErrorListener( } } -function takeRequestTrailers(self: $Node.IncomingMessage, handle: $Node.NodeHTTPResponse | undefined) { +function takeRequestTrailers(self: $Node.IncomingMessage) { // The lenient value bytes must match what the parser accepted on the wire, or a CTL byte in a trailer value would make the trailers vanish here. const server = self.socket?.server; const { calculateLenientFlags, HTTPParser } = require("node:_http_common"); - return (handle ?? self[kHandle]!).takeRequestTrailers( + return self[kHandle]!.takeRequestTrailers( server != null && calculateLenientFlags(server.httpValidation, server.insecureHTTPParser) !== HTTPParser.kLenientNone, ); @@ -213,7 +159,7 @@ function emitEOFIncomingMessageOuter(self: $Node.IncomingMessage) { // none, so skip the native call (and the socket/server option walk) for the // common GET/HEAD case. if (self[kHandle] !== undefined && !self[noBodySymbol]) { - const rawTrailers = takeRequestTrailers(self, undefined); + const rawTrailers = takeRequestTrailers(self); if (rawTrailers !== undefined) { ("unlikely"); self._addHeaderLines(rawTrailers, rawTrailers.length); @@ -236,6 +182,12 @@ function emitEOFIncomingMessageOuter(self: $Node.IncomingMessage) { function clearServerParserIncoming(parser: $Node.HTTPParser | $Node.HttpServerParserShim, req: $Node.IncomingMessage) { if (parser.incoming === req) parser.incoming = null; } +// One microtask a request, in their order, and no function object for each. +type RequestQueue = import("internal/fifo").default<$Node.IncomingMessage>; +let requestsAtTheirEnd: RequestQueue | undefined; +function emitEOFOfNextRequest() { + emitEOFIncomingMessageOuter(requestsAtTheirEnd!.shift()!); +} function emitEOFIncomingMessage(self: $Node.IncomingMessage) { self[eofInProgress] = true; queueTick1(emitEOFIncomingMessageOuter, self); @@ -247,15 +199,6 @@ function onDataIncomingMessage( isLast: boolean, aborted: NodeHTTPResponseAbortEvent, ) { - if (aborted === NodeHTTPResponseAbortEvent.readParsed) { - const socket = this.socket; - const onReadParsed = socket?.[kOnReadParsed]; - if (onReadParsed) { - socket[kOnReadParsed] = undefined; - onReadParsed(socket); - } - return; - } if (aborted === NodeHTTPResponseAbortEvent.abort) { // The request is aborted from the socket's #onClose (like Node.js's // socketOnClose → abortIncoming), which the native close path always @@ -282,21 +225,28 @@ function onDataIncomingMessage( else if (finSent && !this._consuming) socket![kHandle]!.stopParsing(); else if (socket && (finSent || !socket.writableEnded)) { socket.pause(); - // For a pipelined request the socket's current response is an earlier one, and an ended response does not pause. - this[kHandle]?.pause(); } } } if (isLast) { - 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) { - if (socket.$resumeUnlessFlowing !== undefined) socket.$resumeUnlessFlowing(false); - else { - ("unlikely"); - socket.resume(); + try { + if (chunk) { + // Node's parser runs the promise jobs of the last chunk's 'data' listeners before parserOnMessageComplete pushes the EOF. + this[eofInProgress] = true; + (requestsAtTheirEnd ??= new (require("internal/fifo"))<$Node.IncomingMessage>()).push(this); + queueMicrotask(emitEOFOfNextRequest); + } else { + emitEOFIncomingMessage(this); + } + } finally { + // Like Node's parserOnMessageComplete: a readStop above left the socket not flowing, which would swallow the next request's 'pause'. + if (!this.upgrade && socket && !socket._paused && socket.readable) { + if (socket.$resumeUnlessFlowing !== undefined) socket.$resumeUnlessFlowing(false); + else { + ("unlikely"); + socket.resume(); + } } } } @@ -406,67 +356,16 @@ const STATUS_CODES: Record = { 511: "Network Authentication Required", }; -function hasServerResponseFinished( - self: $Node.ServerResponse, - chunk: $Node.HttpChunk | null | undefined, - callback: $Node.HttpWriteCallback | undefined, - fromEnd: boolean, -) { - const finished = self.finished; - - // Only end() takes "" for no chunk. To Node.js's write_() it is a write: https://github.com/nodejs/node/blob/v26.3.0/lib/_http_outgoing.js#L943-L957 - if ( - typeof chunk === "string" ? chunk.length !== 0 || !fromEnd : typeof chunk === "object" ? chunk !== null : !!chunk - ) { - const destroyed = self.destroyed; - - if (finished || destroyed) { - let err; - if (finished) { - err = $ERR_STREAM_WRITE_AFTER_END(); - } else if (destroyed) { - err = $ERR_STREAM_DESTROYED("write"); - } - - if (!destroyed) { - process.nextTick(emitErrorNt, self, err, callback); - } else if (!fromEnd && $isCallable(callback)) { - // Node.js's end() never gives this error to its callback: https://github.com/nodejs/node/blob/v26.3.0/lib/_http_outgoing.js#L1086-L1098 - process.nextTick(callback, err); - } - - return true; - } - } else if (finished) { - if ($isCallable(callback)) { - if (!self.writableFinished) { - self.on("finish", callback); - } else { - callback($ERR_STREAM_ALREADY_FINISHED("end")); - } - } - - return true; - } - - return false; -} - -function emitErrorNt(msg: $Node.ServerResponse, err: Error, callback: $Node.HttpWriteCallback | undefined) { - if ($isCallable(callback)) { - callback(err); - } - if ($isCallable(msg.emit) && !msg.destroyed) { - msg.emit("error", err); - } -} const setMaxHTTPHeaderSize: (maxHeaderSize: number) => void = $bindgen( "node_http_binding.bind.ts", "setMaxHTTPHeaderSize", ); const getMaxHTTPHeaderSize: () => number = $bindgen("node_http_binding.bind.ts", "getMaxHTTPHeaderSize"); +const RE_CONN_CLOSE = /(?:^|\W)close(?:$|\W)/i; const kOutHeaders = Symbol("kOutHeaders"); const kNeedDrain = Symbol("kNeedDrain"); +const kFinishesWithoutLastWrite = Symbol("kFinishesWithoutLastWrite"); +const kFailHeldWrites = Symbol("kFailHeldWrites"); const kErrored = Symbol("errored"); const kHighWaterMark = Symbol("kHighWaterMark"); const kSocket = Symbol("kSocket"); @@ -500,9 +399,12 @@ declare global { incoming: IncomingMessage | null; outgoing: null; socket: HttpServerSocket | null; + /** False once a 'data' or 'readable' listener of the program gets every read ahead of the parser. */ + _consumed: boolean; [callback: number]: (() => void) | null; free(): void; close(): void; + resume(): void; } /** A stream that carries HTTP messages, with what `node:http` keeps on it: a `net.Socket`, or a `Duplex` that was given to `node:http`. */ @@ -517,11 +419,9 @@ declare global { setTimeout?(msecs: number, callback?: () => void): this | undefined; destroySoon?(): void; _unrefTimer?(): void; - [kHandle]?: NodeHTTPServerSocket | null; - [kOnReadParsed]?: (socket: HttpSocket) => void; - [kHandoffResponse]?: NodeHTTPResponse; - [kRequest]?: IncomingMessage; - [kCloseCallback]?: (this: HttpServerSocket) => void; + /** The HTTP side of the connection, while the native listener parses it. */ + [kHandle]?: NodeHTTPServerSocket; + [kCloseCallback]?: (this: HttpSocket) => void; $resumeUnlessFlowing?(asksWhetherReadable: boolean): void; } @@ -541,19 +441,6 @@ declare global { [kIncomingMessage]?: IncomingMessageConstructor; } - interface HttpServerSocket { - [kHandle]: NodeHTTPServerSocket | null | undefined; - [kOnReadParsed]: ((socket: HttpSocket) => void) | undefined; - [kHandoffResponse]: NodeHTTPResponse | undefined; - [kRequest]: IncomingMessage | undefined; - [kCloseCallback]: ((this: HttpServerSocket) => void) | undefined; - [kClosedWithUnsentData]: boolean | undefined; - readonly [kInternalSocketData]: NodeHTTPResponse | null | undefined; - readonly [kHasQueuedReads]: boolean; - $resumeUnlessFlowing?(asksWhetherReadable: boolean): void; - [kRememberPeer](): void; - } - interface IncomingMessage { /** The native response of a request of the native listener, until the request is destroyed. */ [kHandle]?: NodeHTTPResponse; @@ -574,22 +461,27 @@ declare global { [kRejectNonStandardBodyWrites]: boolean | undefined; [kUniqueHeaders]?: Set | null; /** Of a `ServerResponse`. */ - [kHandle]?: NodeHTTPResponse | Http1ResponseHandle; + [kHandle]?: NodeHTTPResponse | null; + /** Only `ServerResponse.prototype` has it, until the message fails what it holds. */ + [kFinishesWithoutLastWrite]?: boolean; + /** Whether the message held anything. */ + [kFailHeldWrites](error?: Error): boolean; } interface ServerResponse { - [kHandle]: NodeHTTPResponse | Http1ResponseHandle | undefined; - [kPendingCallbacks]: HttpWriteCallback[] | undefined; + /** `null`: the response writes to the socket it is assigned, like in Node. */ + [kHandle]: NodeHTTPResponse | null | undefined; + [kPendingCallbacks]: HttpWriteCallback[]; + /** `null` while the response waits for its turn on the connection. */ [fakeSocketSymbol]: HttpSocket | null | undefined; - [headerStateSymbol]: number; [kCloseCallback]: (() => void) | undefined; } interface HttpServer { [serverSymbol]: NativeHTTPServer | undefined; [isTlsSymbol]?: boolean; - [tlsSymbol]: HttpServerTLSOptions | null; - [optionsSymbol]: HttpServerOptions; + /** An `https.Server` keeps its TLS state in a `tls.Server` that never listens. */ + [tlsSymbol]: TLSServer | null; [kHighWaterMark]: number | undefined; [kUniqueHeaders]: Set | null; [kIncomingMessage]: IncomingMessageConstructor | undefined; @@ -599,7 +491,10 @@ declare global { host: string | undefined, socketPath: string | undefined, reusePort: boolean, + ipv6Only: boolean, + fd?: number, ): void; + [kLeaveHttp](socket: HttpServerSocket, request: IncomingMessage): void; } interface Agent { @@ -647,7 +542,7 @@ class ProxyConfig { bypassList: string[]; proxyConnectionOptions: { host: string; port: number }; - constructor(proxyUrl: string, keepAlive: boolean | undefined, noProxyList: string | undefined) { + constructor(proxyUrl: string, noProxyList: string | undefined) { let parsedURL: URL; try { parsedURL = new URL(proxyUrl); @@ -745,11 +640,7 @@ function parseProxyUrl(env: Record, protocol: string return proxyUrl; } -function parseProxyConfigFromEnv( - env: Record, - protocol: string, - keepAlive: boolean | undefined, -) { +function parseProxyConfigFromEnv(env: Record, protocol: string) { // We only support proxying for HTTP and HTTPS requests. if (protocol !== "http:" && protocol !== "https:") return null; const proxyUrl = parseProxyUrl(env, protocol); @@ -757,7 +648,7 @@ function parseProxyConfigFromEnv( // Only http:// and https:// proxies are supported. Ignore instead of throw, in case other protocols are supposed to be handled by the user land. if (!proxyUrl.startsWith("http://") && !proxyUrl.startsWith("https://")) return null; - return new ProxyConfig(proxyUrl, keepAlive, env.no_proxy || env.NO_PROXY); + return new ProxyConfig(proxyUrl, env.no_proxy || env.NO_PROXY); } function checkShouldUseProxy( @@ -777,6 +668,7 @@ function loadAllOfNodeHttp() { export { METHODS, + RE_CONN_CLOSE, STATUS_CODES, abortedSymbol, checkShouldUseProxy, @@ -788,46 +680,41 @@ export { eofInProgress, fakeSocketSymbol, getMaxHTTPHeaderSize, - hasServerResponseFinished, - headerStateSymbol, - http1ServerPipeline, + httpServerParts, isTlsSymbol, kAbortController, kBytesWritten, kChunkedBuffer, kChunkedLength, kCloseCallback, - kClosedWithUnsentData, kCorked, kErrored, + kFailHeldWrites, + kFinishesWithoutLastWrite, kHandle, - kHandoffResponse, - kHasQueuedReads, kHeaderSource, kHighWaterMark, kIncomingMessage, kInternalSocketData, + kLeaveHttp, kLenientValidation, kMadeOverHandle, kNeedDrain, - kOnReadParsed, kOutHeaders, kPendingCallbacks, kPerRequestCheckServerIdentity, kProxyConfig, kRealListen, kRejectNonStandardBodyWrites, - kRememberPeer, kReqShouldKeepAlive, - kRequest, kSkipPendingData, kSocket, kUniqueHeaders, + kUpgradeStream, kWaitForProxyTunnel, loadAllOfNodeHttp, noBodySymbol, onDataIncomingMessage, - optionsSymbol, parseProxyConfigFromEnv, parseProxyUrl, redactInvalidProxyUrl, @@ -835,10 +722,6 @@ export { responseRoad, serverSymbol, setMaxHTTPHeaderSize, - setServerAppFlags, - setServerCustomOptions, - setServerMaxHeadersCount, - takeRequestTrailers, tlsSymbol, utcDate, }; diff --git a/src/js/ahead-of-time/internal/http/FakeSocket.ts b/src/js/ahead-of-time/internal/http/FakeSocket.ts index a5be861a5bab..dbc43a19101e 100644 --- a/src/js/ahead-of-time/internal/http/FakeSocket.ts +++ b/src/js/ahead-of-time/internal/http/FakeSocket.ts @@ -1,12 +1,7 @@ // What the compiler reads in place of src/js/internal/http/FakeSocket.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 d93d41b56254446c0d11f40b311ed02bf9cfaa57ccb434b01ead3347bc42ae05. +// Written against that file with the SHA-256 3bbff026b2c4b0a0f04c4c75c2bfd13cde10fc227ab1f57ef15f259a7782d87c. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. -const http = require("internal/http"); -const { kAutoDestroyed } = require("internal/shared"); -// A symbol taken out of a pattern is a plain `symbol` for the type checker, and what it keys is untyped. -const kInternalSocketData: typeof http.kInternalSocketData = http.kInternalSocketData; -const serverSymbol: typeof http.serverSymbol = http.serverSymbol; const Duplex = require("internal/streams/duplex"); // The other text takes what lies under `Duplex` on node:stream when it is loaded. Here the class above the socket is written into the compiled program. if (require("internal/stream").Duplex !== Duplex) @@ -16,24 +11,20 @@ if (require("internal/stream").Duplex !== Duplex) type FakeSocket = InstanceType; var FakeSocket = class Socket extends Duplex { - [kInternalSocketData]?: [$Node.HttpServer, $Node.ServerResponse, Request]; bytesRead = 0; bytesWritten = 0; connecting = false; timeout = 0; isServer = false; - #address: Partial<$Node.SocketAddressInfo> | undefined; + #address: Partial<$Node.SocketAddressInfo> = {}; _httpMessage: $Node.OutgoingMessage | null | undefined; constructor(httpMessage: $Node.OutgoingMessage | null | undefined) { super(); this._httpMessage = httpMessage; } address() { - // Call server.requestIP() without doing any property getter twice. - var internalData: [$Node.HttpServer, $Node.ServerResponse, Request] | undefined; - return (this.#address ??= - (internalData = this[kInternalSocketData])?.[0]?.[serverSymbol]?.requestIP(internalData[2]) ?? {}); + return this.#address; } get bufferSize() { @@ -47,11 +38,7 @@ var FakeSocket = class Socket extends Duplex { this.emit("timeout"); }; - _destroy(_err: Error | null, _callback: $Node.ErrorCallback) { - const socketData = this[kInternalSocketData]; - if (!socketData) return; // sometimes 'this' is Socket not FakeSocket - if (!socketData[1]["req"][kAutoDestroyed]) socketData[1].end(); - } + _destroy(_err: Error | null, _callback: $Node.ErrorCallback) {} _final(_callback: $Node.ErrorCallback) {} @@ -125,12 +112,7 @@ var FakeSocket = class Socket extends Duplex { return this; } - setTimeout(timeout: number, callback?: () => void) { - const socketData = this[kInternalSocketData]; - if (!socketData) return; // sometimes 'this' is Socket not FakeSocket - - const http_res = socketData[1]; - http_res?.req?.setTimeout(timeout, callback); + setTimeout(_timeout: number, _callback?: () => void) { return this; } diff --git a/src/js/ahead-of-time/internal/http1_server_fallback.ts b/src/js/ahead-of-time/internal/http1_server_fallback.ts deleted file mode 100644 index 2a04f295d43b..000000000000 --- a/src/js/ahead-of-time/internal/http1_server_fallback.ts +++ /dev/null @@ -1,790 +0,0 @@ -// What the compiler reads in place of src/js/internal/http1_server_fallback.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 9411c7ddf6a9e79667dfde90e0dc5ac6161ac63bd73bb2a91021597f3d5be87e. -// When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. - -// 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 { SafeSet } = require("internal/primordials"); -const AsyncContextFrame = require("internal/async_context_frame"); - -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"); -const reportError = globalThis.reportError; - -function rethrowUncaught(err: unknown) { - throw err; -} - -// Node fails the write callbacks that a destroyed socket still holds before it emits 'close' (Writable's errorBuffer). -function failPendingWriteCallbacks(res: $Node.ServerResponse, err: Error) { - const callbacks = res[kPendingCallbacks]; - if (callbacks === undefined) return; - res[kPendingCallbacks] = []; - for (let i = 0; i < callbacks.length; i++) { - try { - callbacks[i](err); - } catch (e) { - // An uncaught exception, as in Node. It must not cut the socket's 'close' listeners short. - process.nextTick(rethrowUncaught, e); - } - } -} - -declare global { - namespace $Node { - interface HttpSocket { - [kHttp1ActiveRequests]?: number; - [kHttp1MessageOpen]?: boolean; - } - interface HttpSocketServer { - [kHttp1Connections]?: Set; - } - - /** The stand-in for a `NodeHTTPResponse` on a connection that is parsed here. */ - interface Http1ResponseHandle extends HttpResponseHandle { - ended: boolean; - finished: boolean; - aborted: boolean; - shouldKeepAlive: boolean; - onfinished: (() => void) | null; - onflushed: (() => void) | null; - socketDrained(): void; - socketEnded(fn: () => void): void; - flushed(): void; - } - } -} - -interface Http1FallbackResponseHead { - statusCode: number; - statusMessage: string | undefined; - headers: string[]; - autoHeaderBits: number; - keepAliveTimeoutSecs: number; -} - -function createHttp1FallbackResponseHandle( - socket: $Node.HttpSocket, - shouldKeepAlive: boolean, - keepAliveTimeout: number, -): $Node.Http1ResponseHandle { - const { _checkInvalidHeaderChar: checkInvalidHeaderChar } = require("node:_http_common"); - let head: Http1FallbackResponseHead | null = null; - let headWritten = false; - let chunked = false; - let noBody = false; - let closeDelimited = false; - // The drain callback of a write() that reported backpressure, and its async context (native's onwritable slot). - let onwritable: $Node.HttpDrainCallback | null | undefined = null; - let onwritableFrame: unknown; - - function writeHeadToSocket(contentLength: number | null) { - if (headWritten) return; - headWritten = true; - const statusCode = head?.statusCode ?? 200; - let statusMessage = head?.statusMessage; - if (typeof statusMessage !== "string" || statusMessage === "") { - statusMessage = STATUS_CODES[statusCode] || "unknown"; - } - let out = `HTTP/1.1 ${statusCode} ${statusMessage}\r\n`; - let hasContentLength = false; - let hasTransferEncoding = false; - let hasDate = false; - let hasConnection = false; - let hasKeepAlive = false; - const headers = head?.headers; - if (headers) { - // ServerResponse drives this handle with renderNativeHeaders(): a flat - // [name, value, name, value, ...] array with original-case names. - for (let i = 0, end = headers.length - 1; i < end; i += 2) { - const name = headers[i]; - const value = headers[i + 1]; - if (name.length === 1 && name.charCodeAt(0) === 0) { - // node:http's NUL-named framing sentinel pair (see NodeHTTP.cpp): - // value "2" = no body (HEAD), anything else = close-delimited. - if (value === "2") noBody = true; - else closeDelimited = true; - continue; - } - switch (name.toLowerCase()) { - case "content-length": - hasContentLength = true; - break; - case "transfer-encoding": - hasTransferEncoding = true; - if (String(value).toLowerCase().includes("chunked")) chunked = true; - break; - case "date": - hasDate = true; - break; - case "connection": - hasConnection = true; - break; - case "keep-alive": - hasKeepAlive = true; - break; - } - out += `${name}: ${value}\r\n`; - } - } - // renderNativeHeaders carries its framing and Connection decisions in the - // auto-header bits (AUTO_HEADER_* in _http_server.ts / kAutoHeader* in - // NodeHTTP.cpp) rather than the flat array. - const autoBits = head?.autoHeaderBits ?? 0; - // Framing decided here but Transfer-Encoding rendered after Connection (Node _storeHeader - // order); suppress the Content-Length it would otherwise invent (RFC 9112 §6.1 smuggling). - const chunkedFromAutoBits = (autoBits & 16) !== 0; - // Decide the framing here, but write it after Date/Connection/Keep-Alive: - // Node's _storeHeader emits Content-Length and the chunked Transfer-Encoding - // after its automatic connection block. - let autoContentLength: number | null = null; - let autoChunked = false; - if (!hasContentLength && !hasTransferEncoding && !noBody && !closeDelimited) { - if (chunkedFromAutoBits || contentLength === null) { - chunked = true; - autoChunked = !chunkedFromAutoBits; - } else { - autoContentLength = contentLength; - } - } - // Mirror native writeAutoHeaders: each line written iff its bit is set, so sendDate=false / - // removeHeader / suppressed Keep-Alive round-trip identically. A head-less write (no writeHead - // on this handle — only off node:http's ServerResponse) keeps the old defaults. - if (head === null) { - if (!hasDate) { - out += `Date: ${new Date().toUTCString()}\r\n`; - } - if (!hasConnection && !closeDelimited) { - if (shouldKeepAlive) { - out += "Connection: keep-alive\r\n"; - if (!hasKeepAlive) { - out += `Keep-Alive: timeout=${Math.floor((keepAliveTimeout || 5000) / 1000)}\r\n`; - } - } else { - out += "Connection: close\r\n"; - } - } - } else { - if (!hasDate && (autoBits & 1) !== 0) { - out += `Date: ${new Date().toUTCString()}\r\n`; - } - if (!hasConnection) { - if ((autoBits & 2) !== 0) { - out += "Connection: keep-alive\r\n"; - if (!hasKeepAlive && (autoBits & 8) !== 0) { - out += `Keep-Alive: timeout=${head.keepAliveTimeoutSecs}\r\n`; - } - } else if ((autoBits & 4) !== 0) { - out += "Connection: close\r\n"; - } - } - } - // Last, where Node's _storeHeader puts them — after Connection/Keep-Alive. - if (autoContentLength !== null) { - out += `Content-Length: ${autoContentLength}\r\n`; - } else if (autoChunked || (chunkedFromAutoBits && chunked)) { - out += "Transfer-Encoding: chunked\r\n"; - } - out += "\r\n"; - writeToSocket(out); - } - - function toBuffer(chunk: $Node.HttpChunk | null | undefined, encoding: string | null | undefined) { - if (chunk == null) return null; - if (typeof chunk === "string") return Buffer.from(chunk, encoding || "utf8"); - return chunk; - } - - // Like the `conn.writable` gate of Node's _writeRaw: a write to a socket that was ended (after the client's FIN) would destroy it. - function writeToSocket(data: $Node.HttpChunk, callback?: $Node.ErrorCallback) { - if (!socket.writableEnded) socket.write(data, callback); - } - - function writeBody(buf: Buffer | Uint8Array | null, callback?: $Node.ErrorCallback) { - const length = buf ? (buf.byteLength ?? buf.length) : 0; - if (length) { - if (chunked) { - writeToSocket(length.toString(16) + "\r\n"); - writeToSocket(buf!); - writeToSocket("\r\n", callback); - } else { - writeToSocket(buf!, callback); - } - } - return length; - } - - // Runs `fn` once in the place of the waiting callback, in that callback's async context. - function settleWaiting(fn: $Node.HttpDrainCallback) { - const frame = onwritableFrame; - onwritable = null; - onwritableFrame = undefined; - AsyncContextFrame.run(frame, fn); - } - - // The bytes end() left in the socket are out, or the socket closed with them. - function flushed() { - if (!handle.ended || handle.finished) return; - handle.finished = true; - try { - handle.onflushed?.(); - } catch (err) { - // A 'finish' listener that throws must not unwind into the socket's write callbacks or 'close' listeners. - reportError(err); - } - } - - // A failed write or a destroyed socket leaves 'finish' to the socket's 'close': streams fail writes inside destroy(), or just before it. - function onEndWritten(err?: Error | null) { - if (!err && !socket.destroyed) flushed(); - } - - const handle: $Node.Http1ResponseHandle = { - // NodeHTTPResponseFlags.socket_closed, like the native getter: end() and write() stop at it. - get flags() { - return this.aborted || socket.destroyed ? 1 : 0; - }, - ended: false, - // True once the bytes end() wrote have left the socket, like the native handle's. - finished: false, - aborted: false, - shouldKeepAlive, - onfinished: null, - // Like the native getter: an empty slot reads as undefined. - get onwritable() { - return onwritable ?? undefined; - }, - set onwritable(callback) { - onwritable = callback; - onwritableFrame = callback ? AsyncContextFrame.current() : undefined; - }, - // The socket's bytes while a write waits for 'drain' or an ended response is still flushing; a socket under its high water mark emits no 'drain'. - get bufferedAmount() { - return onwritable || (this.ended && !this.finished) ? socket.writableLength : 0; - }, - // Native on_drain: the socket drained, so the waiting callback runs. - socketDrained() { - if (onwritable) settleWaiting(onwritable); - }, - // The socket finished or closed, so no 'drain' comes: `fn` runs, and the waiting callback never does. - socketEnded(fn: () => void) { - if (onwritable) settleWaiting(fn); - }, - // Runs once, from flushed(). ServerResponse#end()'s `onwritable` stays unused: its 'finish' comes a tick later, after a closing socket's 'close'. - onflushed: null, - cork(callback: () => void) { - return callback(); - }, - writeContinue() { - writeToSocket("HTTP/1.1 100 Continue\r\n\r\n"); - }, - writeInformational(chunk: $Node.HttpChunk, encoding?: string | null) { - // _writeRaw hands the fully-rendered 1xx block here (writeEarlyHints / - // writeProcessing / writeInformation all route through it). - if (!socket.writableEnded) socket.write(chunk, encoding); - }, - writeHead( - statusCode: number, - statusMessage: string | undefined, - headers: string[], - autoHeaderBits: number, - keepAliveTimeoutSecs: number, - ) { - const originalStatusCode = statusCode; - statusCode |= 0; - if (statusCode < 100 || statusCode > 999) { - throw $ERR_HTTP_INVALID_STATUS_CODE(`${originalStatusCode}`); - } - if (typeof statusMessage === "string" && checkInvalidHeaderChar(statusMessage)) { - throw $ERR_INVALID_CHAR("statusMessage"); - } - head = { statusCode, statusMessage, headers, autoHeaderBits, keepAliveTimeoutSecs }; - }, - flushHeaders() { - writeHeadToSocket(null); - }, - writeHeadAndEnd( - this: $Node.Http1ResponseHandle, - statusCode: number, - statusMessage: string | undefined, - headers: string[], - chunk: $Node.HttpChunk | null | undefined, - encoding: string | null | undefined, - strictContentLength: number | undefined, - autoHeaderBits: number, - keepAliveTimeoutSecs: number, - ) { - // The native NodeHTTPResponse batches writeHead + end into one call; - // this fallback composes the same two steps. - this.writeHead(statusCode, statusMessage, headers, autoHeaderBits, keepAliveTimeoutSecs); - return this.end(chunk, encoding, undefined, strictContentLength); - }, - write( - this: $Node.Http1ResponseHandle, - chunk: $Node.HttpChunk | null | undefined, - encoding: string | null | undefined, - callback: $Node.HttpDrainCallback | null | undefined, - _strictContentLength: number | undefined, - ) { - const buf = toBuffer(chunk, encoding); - 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. - if (buf === null || !socket.writableNeedDrain) return length; - // Like native write_or_end: a negative result, and the callback waits for the drain. - if (callback) this.onwritable = callback; - // An empty write has no length to negate. - return length > 0 ? -length : -1; - }, - end( - this: $Node.Http1ResponseHandle, - chunk: $Node.HttpChunk | null | undefined, - encoding: string | null | undefined, - _callback: undefined, - _strictContentLength: number | undefined, - ) { - if (this.ended) return 0; - // 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; - writeHeadToSocket(length); - // Like Node's `_hasBody && chunkedEncoding` gate: a bodiless (HEAD) - // response never writes the terminating chunk, even when the user set - // Transfer-Encoding: chunked themselves. - const terminated = chunked && !noBody; - writeBody(buf, terminated ? undefined : onEndWritten); - if (terminated) writeToSocket("0\r\n\r\n", onEndWritten); - 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) { - // An ended socket took no write, so its own 'finish' tells. The empty write is for an end() that wrote nothing: behind a body, _writev would copy that body. - if (socket.writableEnded) socket.once("finish", onEndWritten); - else if (!length && !terminated) socket.write("", "latin1", onEndWritten); - } else { - this.finished = true; - } - const onfinished = this.onfinished; - if (onfinished) { - this.onfinished = null; - onfinished(); - } - // A close-delimited body ends at EOF, so the response ends the connection. - if (closeDelimited && !socket.destroyed) { - socket.end(); - } - // The native handle's contract: -(length + 1) while bytes still drain, so that ServerResponse#end() holds 'finish' back. - return this.finished ? length : -(length + 1); - }, - flushed, - abort(this: $Node.Http1ResponseHandle) { - this.aborted = true; - if (!socket.destroyed) socket.destroy(); - }, - }; - return handle; -} - -// HTTP/1.1 fallback for Http2SecureServer `allowHTTP1: true`: parse the TLS socket and emit -// 'request' with http.IncomingMessage/ServerResponse, like Node's httpConnectionListener routing. -function connectionListenerHTTP1( - server: $Node.HttpSocketServer, - socket: $Node.HttpSocket, - options: { - http1Options?: { - IncomingMessage?: $Node.IncomingMessageConstructor; - ServerResponse?: $Node.ServerResponseConstructor; - }; - }, -) { - const http = require("node:http"); - const { - HTTPParser, - prepareError, - calculateLenientFlags, - continueExpression, - MAX_HEADER_PAIRS, - } = require("node:_http_common"); - const { ConnResetException } = require("internal/shared"); - const { kHandle: kHttp1ResponseHandle, kHeaderSource, http1ServerPipeline } = require("internal/http"); - // Populated by node:_http_server, which the require("node:http") above loads. - const { - queuePipelinedResponse, - advanceResponsePipeline, - abortQueuedPipelinedResponses, - lastPipelinedResponse, - maybePauseFallbackReads, - resumeFallbackReadsOnDrain, - finishDrainedResponse, - mustCloseConnection, - } = http1ServerPipeline; - const { allMethods } = process.binding("http_parser"); - - const http1Options = options.http1Options || {}; - const IncomingMessageClass = http1Options.IncomingMessage || http.IncomingMessage; - const ServerResponseClass = http1Options.ServerResponse || http.ServerResponse; - const keepAliveTimeout = typeof server.keepAliveTimeout === "number" ? server.keepAliveTimeout : 5000; - - // Node's connectionListenerInternal sets this so handlers can reach the server - // through req.socket.server (nodejs/node#13435). - socket.server = server; - - const connections = (server[kHttp1Connections] ??= new SafeSet()); - connections.add(socket); - socket[kHttp1ActiveRequests] = 0; - socket[kHttp1MessageOpen] = false; - - const kOnHeaders = HTTPParser.kOnHeaders | 0; - const kOnMessageBegin = HTTPParser.kOnMessageBegin | 0; - const kOnHeadersComplete = HTTPParser.kOnHeadersComplete | 0; - const kOnBody = HTTPParser.kOnBody | 0; - const kOnMessageComplete = HTTPParser.kOnMessageComplete | 0; - - // Mirror Node's connectionListenerInternal: carry maxHeaderSize / leniency / maxHeadersCount - // into the parser. https://github.com/nodejs/node/blob/main/lib/_http_server.js - const lenientFlags = calculateLenientFlags(server.httpValidation, server.insecureHTTPParser); - const parser = new HTTPParser(); - parser.initialize(HTTPParser.REQUEST, {}, server.maxHeaderSize || 0, lenientFlags); - parser.socket = socket; - socket.parser = parser; - // Node takes its parser from the node:_http_common freelist, which seeds MAX_HEADER_PAIRS. - const { maxHeadersCount } = server; - parser.maxHeaderPairs = typeof maxHeadersCount === "number" ? maxHeadersCount << 1 : MAX_HEADER_PAIRS; - - let req: $Node.IncomingMessage | null = null; - let pendingUpgrade: $Node.IncomingMessage | null = null; - // Node's per-connection state.requestsCount, behind server.maxRequestsPerSocket. - let requestsCount = 0; - - // Like node:_http_common: after the parser's first kOnHeaders flush (32 fields, or trailers), kOnHeadersComplete gets no headers and no url. - let flushedHeaders: string[] = []; - let flushedUrl = ""; - parser[kOnMessageBegin] = function onHttp1MessageBegin() { - socket[kHttp1MessageOpen] = true; - }; - - parser[kOnHeaders] = function onHttp1Headers(headers: string[], url: string) { - for (let i = 0; i < headers.length; i++) $arrayPush(flushedHeaders, headers[i]); - flushedUrl += url; - }; - - parser[kOnHeadersComplete] = function onHttp1HeadersComplete( - versionMajor: number, - versionMinor: number, - rawHeaders: string[] | undefined, - methodNum: number | undefined, - url: string | undefined, - _statusCode: number | undefined, - _statusMessage: string | undefined, - upgrade: boolean, - shouldKeepAlive: boolean, - ) { - if (rawHeaders === undefined) { - rawHeaders = flushedHeaders; - flushedHeaders = []; - } - if (url === undefined) { - url = flushedUrl; - flushedUrl = ""; - } - - socket[kHttp1ActiveRequests]!++; - - req = new IncomingMessageClass(socket); - req![kHeaderSource] = null; - req!.socket = socket; - req!.httpVersionMajor = versionMajor; - req!.httpVersionMinor = versionMinor; - req!.httpVersion = `${versionMajor}.${versionMinor}`; - req!.url = url; - req!.method = (typeof methodNum === "number" ? allMethods[methodNum] : methodNum)!; - req!.upgrade = upgrade; - // _addHeaderLine reads this off the request (Node's parserOnHeadersComplete copies it the same way). - if (server.joinDuplicateHeaders) { - req!.joinDuplicateHeaders = true; - } - req!._addHeaderLines(rawHeaders, rawHeaders.length); - - // Node's parserOnIncoming: upgrade only sticks for CONNECT or when an 'upgrade' listener - // exists; otherwise fall through to normal dispatch. Returning 2 makes llhttp stop after - // this message so tunnel bytes are never parsed as HTTP. - if (upgrade) { - req!.upgrade = - req!.method === "CONNECT" || - (typeof server.shouldUpgradeCallback === "function" - ? !!server.shouldUpgradeCallback(req!) - : server.listenerCount("upgrade") > 0); - if (req!.upgrade) { - pendingUpgrade = req; - return 2; - } - } - // The body is fed by the parser callbacks below; reading just resumes the - // socket - unless the pipelining read gate paused it (the gate's release - // resumes it instead). - req!._read = function (this: $Node.IncomingMessage, _size: number) { - this._consuming = true; - if (!socket._paused && socket.readable) socket.resume(); - }; - - const res: $Node.ServerResponse = new ServerResponseClass(req!); - // The native dispatcher seeds these from the server; renderNativeHeaders - // reads them to decide the Keep-Alive auto-header bits, so the fallback - // path must carry them too or keep-alive responses lose their timeout line. - res._keepAliveTimeout = keepAliveTimeout; - const { maxRequestsPerSocket } = server; - res._maxRequestsPerSocket = maxRequestsPerSocket; - const handle = createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTimeout); - handle.onfinished = function () { - socket[kHttp1ActiveRequests] = Math.max(0, (socket[kHttp1ActiveRequests] || 1) - 1); - if (!shouldKeepAlive && !socket.destroyed) { - socket.end(); - } - }; - handle.onflushed = function () { - finishDrainedResponse!(res); - }; - res[kHttp1ResponseHandle] = handle; - // Node's parserOnIncoming outgoing queue: pipelined requests parse while - // the previous response is still assigned (its 'finish' detach is a tick - // away), so queue this response instead of letting assignSocket throw - // ERR_HTTP_SOCKET_ASSIGNED. - if (socket._httpMessage) { - queuePipelinedResponse!(socket, res, versionMajor < 1 || versionMinor < 1); - } else { - res.assignSocket(socket); - } - // node's resOnFinish: release the socket once the response completes, - // then either end the connection (a response that advertised Connection: - // close must not be followed by another one - the close path aborts the - // queued responses) or hand the socket to the next queued pipelined - // response, replaying whatever it buffered. - res.on("finish", function onFallbackResponseFinish(this: $Node.ServerResponse) { - const finishedReq = this.req; - if (!finishedReq._consuming && !finishedReq._readableState.resumeScheduled) finishedReq._dump(); - this.detachSocket(socket); - // `_last`: onHttp1SocketEnd saw the client's FIN while this response owned the socket. - if (mustCloseConnection!(this) || this._last) { - if (typeof socket.destroySoon === "function") { - socket.destroySoon(); - } else if (!socket.writableEnded) { - socket.end(); - } - return; - } - advanceResponsePipeline!(server, socket); - }); - - // Node's parserOnIncoming read gate: stop reading once the connection's - // outgoing side is backed up, so pipelined requests cannot flood it. - maybePauseFallbackReads!(socket); - - // 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 - ) { - requestsCount++; - res.maxRequestsOnConnectionReached = maxRequestsPerSocket <= requestsCount; - if (maxRequestsPerSocket < requestsCount) { - server.emit("dropRequest", req, socket); - res.writeHead(503); - res.end(); - return 0; - } - } - - // 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 (continueExpression.test(String(expect))) { - if (server.listenerCount("checkContinue") > 0) { - server.emit("checkContinue", req, res); - } else { - res.writeContinue(); - server.emit("request", req, res); - } - } else if (server.listenerCount("checkExpectation") > 0) { - server.emit("checkExpectation", req, res); - } else { - res.writeHead(417); - res.end(); - } - return 0; - } - server.emit("request", req, res); - return 0; - }; - parser[kOnBody] = function onHttp1Body(chunk: Buffer) { - if (req && !req._dumped) req.push(chunk); - }; - parser[kOnMessageComplete] = function onHttp1MessageComplete() { - socket[kHttp1MessageOpen] = false; - // Fields flushed after the header section are the trailers. - const rawTrailers = flushedHeaders; - if (rawTrailers.length !== 0) flushedHeaders = []; - flushedUrl = ""; - if (req) { - req.complete = true; - req._addHeaderLines(rawTrailers, rawTrailers.length); - req.push(null); - } - }; - - function onHttp1SocketError( - err: Error & { rawPacket?: Buffer | Uint8Array; reason?: string }, - rawPacket: Buffer | undefined, - ) { - // 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); - } - } - function onHttp1SocketData(data: Buffer) { - const ret = parser.execute(data); - if (ret instanceof Error) { - onHttp1SocketError(ret, data); - return; - } - if (pendingUpgrade) { - // Node's onParserExecuteCommon: connection stops being HTTP here. Free parser, hand the - // socket over with the first tunnel bytes, destroy when nobody is listening (only CONNECT - // reaches here listener-less; Upgrade already fell through above). - const upgradeReq = pendingUpgrade; - pendingUpgrade = null; - socket.removeListener("data", onHttp1SocketData); - socket.removeListener("error", onHttp1SocketErrorListener); - socket.removeListener("end", onHttp1SocketEnd); - socket.removeListener("drain", onHttp1SocketDrain); - connections.delete(socket); - try { - parser.close(); - } catch {} - socket.parser = null; - const eventName = upgradeReq.method === "CONNECT" ? "connect" : "upgrade"; - const bodyHead = typeof ret === "number" ? data.slice(ret) : Buffer.alloc(0); - if (server.listenerCount(eventName) > 0) { - socket.readableFlowing = null; - server.emit(eventName, upgradeReq, socket, bodyHead); - } else { - socket.destroy(); - } - } - } - function onHttp1SocketErrorListener(err: Error) { - 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() { - const ret = parser.finish(); - if (ret instanceof Error) { - onHttp1SocketError(ret, undefined); - return; - } - if (!server.httpAllowHalfOpen) { - if (req && !req.complete) req.destroy(); - if (socket.writable) socket.end(); - return; - } - const httpMessage = lastPipelinedResponse!(socket) ?? socket._httpMessage; - if (httpMessage) { - (httpMessage as $Node.OutgoingMessage)._last = true; - } else if (socket.writable) { - socket.end(); - } - } - // Node's socketOnDrain: a transport-backpressure pause lifts when the - // socket drains (a queued-bytes pause lifts from the pipeline advance). - function onHttp1SocketDrain() { - resumeFallbackReadsOnDrain!(socket); - // Node's socketOnDrain then emits 'drain' on the response that waits for it. - (socket._httpMessage?.[kHttp1ResponseHandle] as $Node.Http1ResponseHandle | undefined)?.socketDrained(); - } - socket.on("data", onHttp1SocketData); - socket.on("error", onHttp1SocketErrorListener); - socket.once("end", onHttp1SocketEnd); - socket.on("drain", onHttp1SocketDrain); - socket.once("finish", () => { - const inflight: $Node.ServerResponse | null | undefined = socket._httpMessage as any; - // An ended socket emits no 'drain', and all that the response wrote is out: the write callbacks that wait succeed. - (inflight?.[kHttp1ResponseHandle] as $Node.Http1ResponseHandle | undefined)?.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] as $Node.Http1ResponseHandle | undefined)?.flushed(); - }); - socket.once("close", () => { - connections.delete(socket); - const inflight: $Node.ServerResponse | null | undefined = socket._httpMessage as any; - // The write callbacks that still wait fail, before the response's 'close', as in Node. - (inflight?.[kHttp1ResponseHandle] as $Node.Http1ResponseHandle | undefined)?.socketEnded(() => - failPendingWriteCallbacks(inflight!, socket.errored ?? inflight!.errored ?? $ERR_STREAM_DESTROYED("write")), - ); - // Like the native socket's close path (Node's socketOnClose -> - // abortIncoming): abort the in-flight request, then the responses (and - // requests) still queued behind it, so they all emit 'close'. The - // in-flight response's own 'close' comes from onServerResponseClose, - // installed by assignSocket. - const inflightReq = inflight?.req; - if (inflightReq && !inflightReq.destroyed) { - if (inflightReq.listenerCount("error") > 0) { - inflightReq.destroy(new ConnResetException("aborted")); - } else { - inflightReq.destroy(); - } - } - abortQueuedPipelinedResponses!(socket); - try { - parser.close(); - } catch {} - }); -} - -function closeIdleHttp1Connections(server: $Node.HttpSocketServer) { - const connections = server[kHttp1Connections]; - if (!connections) return; - for (const socket of connections) { - if (!socket[kHttp1ActiveRequests] && !socket[kHttp1MessageOpen] && !socket.destroyed) { - socket.destroy(); - } - } -} - -function closeAllHttp1Connections(server: $Node.HttpSocketServer) { - const connections = server[kHttp1Connections]; - if (!connections) return; - for (const socket of connections) { - if (!socket.destroyed) socket.destroy(); - } -} - -export default { - connectionListenerHTTP1, - closeIdleHttp1Connections, - closeAllHttp1Connections, - kHttp1Connections, -} as const; diff --git a/src/js/ahead-of-time/internal/net/symbols.ts b/src/js/ahead-of-time/internal/net/symbols.ts index 28d624565b08..20f08828322c 100644 --- a/src/js/ahead-of-time/internal/net/symbols.ts +++ b/src/js/ahead-of-time/internal/net/symbols.ts @@ -1,16 +1,28 @@ // What the compiler reads in place of src/js/internal/net/symbols.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 eec4d2dfffa4dacbd7320c65df9ebf0be888c1720396a3c98746c10c3389d9a6. +// Written against that file with the SHA-256 912e09c35380c27c0d8bcda419db0b456dcc309ae1c2a4cf3b214f373f2d0128. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. 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"), + // net.Server.prototype method. node:http calls it on its own server, whose connections uWS accepts. + kForeignListener: Symbol("kForeignListener"), + // A function that node:http puts on a socket it handed to 'connect' or 'upgrade'. net.Socket calls it when the connection has closed. + kHandleClosed: Symbol("kHandleClosed"), 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"), + // 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"), @@ -31,10 +43,16 @@ export default { constructors: { Socket: undefined }, resumeOfSocket: undefined, } as unknown as { + readonly kAcceptedTLS: unique symbol; readonly kArmHandshakeTimeout: unique symbol; readonly kDestroyOnRead: unique symbol; + readonly kForeignListener: unique symbol; + readonly kHandleClosed: unique symbol; readonly kPreHandshakeWrite: unique symbol; + readonly kReadStop: unique symbol; readonly kSecureConnectDone: unique symbol; + readonly kSNIError: unique symbol; + readonly kSNIContexts: unique symbol; readonly kVerifyError: unique symbol; readonly kUpgradeClientTLS: unique symbol; readonly kStandaloneWrap: unique symbol; diff --git a/src/js/ahead-of-time/internal/streams/readable.ts b/src/js/ahead-of-time/internal/streams/readable.ts index 85680d1459eb..5587ba10f65f 100644 --- a/src/js/ahead-of-time/internal/streams/readable.ts +++ b/src/js/ahead-of-time/internal/streams/readable.ts @@ -1,5 +1,5 @@ // What the compiler reads in place of src/js/internal/streams/readable.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 26a4a653d8f942a822a4a5b79b8005d8725ed9562c1b0e5b742392e74be9e862. +// Written against that file with the SHA-256 187149b53a9f2817dfa45fdb83095da5cf50ca1a44dfe0a8bc21c06681f11625. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. "use strict"; @@ -45,7 +45,6 @@ const TypedArrayPrototypeSet = Uint8Array.prototype.set; const { errorOrDestroy } = destroyImpl; const nop = () => {}; const { emit } = shared.originals.eventEmitter!; -const kAutoDestroyed: typeof shared.kAutoDestroyed = shared.kAutoDestroyed; const kShapeMode: typeof shared.kShapeMode = IS_COMPILED_AHEAD_OF_TIME ? shared.kShapeMode : (undefined as never); const kCapture: typeof shared.kCapture = IS_COMPILED_AHEAD_OF_TIME ? shared.kCapture : (undefined as never); const EventEmitterPrototype: $Node.EventEmitterPrototype = IS_COMPILED_AHEAD_OF_TIME @@ -1583,9 +1582,6 @@ internals.readableMethods = { if (IS_COMPILED_AHEAD_OF_TIME) internals.readableMethods.removeListener = Readable.prototype.removeListener; internals.finishResume = finishResume; internals.finishReadOfNothing = finishReadOfNothing; -internals.readMoreLater = maybeReadMore; -internals.readMoreOnNextTick = maybeReadMore_; -internals.emitReadableNow = emitReadable_; Readable.prototype.pause = function (this: $Node.Readable) { const state: ReadableState = @@ -1806,8 +1802,7 @@ async function* createAsyncIterator(stream: $Node.Readable, options?: $Node.Read try { while (true) { const chunk = - IS_COMPILED_AHEAD_OF_TIME && - $findsDeclaredMember(stream as unknown as $Node.IncomingMessage, "destroyed", Readable) + IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(stream, "destroyed", Readable) ? readUnlessDestroyed(stream) : stream.destroyed ? null @@ -2186,7 +2181,6 @@ const finishEnd = function endReadableNT(state: ReadableState, stream: $Node.Rea $findsNoMember(stream, "_writableState") ) { if ($findsDeclaredMember(state, "autoDestroy") ? (state[kState] & Flag.AutoDestroy) !== 0 : state.autoDestroy) { - stream[kAutoDestroyed] = true; stream.destroy(); } return; @@ -2209,14 +2203,12 @@ const finishEnd = function endReadableNT(state: ReadableState, stream: $Node.Rea (wState.finished || wState.writable === false)); if (autoDestroy) { - stream[kAutoDestroyed] = true; // workaround for node:http Server not using node:net Server stream.destroy(); } } }; internals.endOnNextTick = endReadableNT; -internals.finishEnd = finishEnd; function endWritableNT(stream: $Node.Duplex) { const writable = stream.writable && !stream.writableEnded && !stream.destroyed; @@ -2232,7 +2224,6 @@ function makeObjectReadable(): $Node.Readable { if (IS_COMPILED_AHEAD_OF_TIME) internals.makeObjectReadable = makeObjectReadable; if (IS_COMPILED_AHEAD_OF_TIME) internals.emitsToListeners = emitsToListeners; -if (IS_COMPILED_AHEAD_OF_TIME) internals.emitData = emitData; if (IS_COMPILED_AHEAD_OF_TIME) internals.isPlainReadableAlone = isPlainReadableAlone; Readable.from = function ( diff --git a/src/js/ahead-of-time/internal/streams/state.ts b/src/js/ahead-of-time/internal/streams/state.ts index b6aaf02375a8..e45e2f1067ea 100644 --- a/src/js/ahead-of-time/internal/streams/state.ts +++ b/src/js/ahead-of-time/internal/streams/state.ts @@ -148,12 +148,6 @@ interface Internals { resumeRequestBody: ((stream: $Node.Readable, state: ReadableState) => boolean) | undefined; finishReadOfNothing: ((state: ReadableState) => void) | undefined; finishResume: ((stream: $Node.Readable, state: ReadableState) => void) | undefined; - readMoreLater: ((stream: $Node.Readable, state: ReadableState) => void) | undefined; - readMoreOnNextTick: ((stream: $Node.Readable, state: ReadableState) => void) | undefined; - emitReadableNow: ((stream: $Node.Readable) => void) | undefined; - finishEnd: ((state: ReadableState, stream: $Node.Readable) => void) | undefined; - onRequestBodyData: - ((this: $Node.IncomingMessage, chunk: Buffer | undefined, isLast: boolean, aborted: number) => void) | undefined; takeRequestCloseTick: ((request: $Node.IncomingMessage) => boolean) | undefined; destroyEndedRequest: ((state: ReadableState, stream: $Node.Readable) => boolean) | undefined; /** Only in what was compiled ahead of time. */ @@ -167,8 +161,6 @@ interface Internals { /** Only in what was compiled ahead of time. */ emitsToListeners?: (stream: $Node.Readable, type: string) => boolean; /** Only in what was compiled ahead of time. */ - emitData?: (stream: $Node.Readable, chunk: unknown) => void; - /** Only in what was compiled ahead of time. */ isPlainReadableAlone?: (stream: $Node.AnyStream) => boolean; /** Compiled code only. Returns one of `Sides` of internal/streams/utils. Unless it is `Unknown`, inspecting the stream runs no user code. */ plainStreamSides?: (stream: unknown) => number; @@ -206,11 +198,6 @@ const internals: Internals = { resumeRequestBody: undefined, finishReadOfNothing: undefined, finishResume: undefined, - readMoreLater: undefined, - readMoreOnNextTick: undefined, - emitReadableNow: undefined, - finishEnd: undefined, - onRequestBodyData: undefined, takeRequestCloseTick: undefined, destroyEndedRequest: undefined, }; diff --git a/src/js/ahead-of-time/node/_http_agent.ts b/src/js/ahead-of-time/node/_http_agent.ts index 2238d2db305b..6b80fa0b0266 100644 --- a/src/js/ahead-of-time/node/_http_agent.ts +++ b/src/js/ahead-of-time/node/_http_agent.ts @@ -1,5 +1,5 @@ // What the compiler reads in place of src/js/node/_http_agent.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 9161c607d9c8f981ce072e148aa617473a8be3f076599f05727c3c23a97b8aff. +// Written against that file with the SHA-256 60c5e9732a6b2278590ae78ce23c9a84db6ed5ff26b24f7bc1c7aa4923626f5d. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. // This is a port of Node.js's lib/_http_agent.js @@ -154,7 +154,7 @@ function Agent(this: $Node.Agent, options?: $Node.AgentOptions): void { const proxyEnv = this.options.proxyEnv; if (typeof proxyEnv === "object" && proxyEnv !== null) { - this[kProxyConfig] = parseProxyConfigFromEnv(proxyEnv, this.protocol, this.keepAlive); + this[kProxyConfig] = parseProxyConfigFromEnv(proxyEnv, this.protocol); $debug(`new ${this.protocol} agent with proxy config`, this[kProxyConfig]); } diff --git a/src/js/ahead-of-time/node/_http_client.ts b/src/js/ahead-of-time/node/_http_client.ts index d413a4abc1e1..ec701d397a53 100644 --- a/src/js/ahead-of-time/node/_http_client.ts +++ b/src/js/ahead-of-time/node/_http_client.ts @@ -293,7 +293,7 @@ function ClientRequest( input = urlToHttpOptions(new URL(urlStr)); } else if (isURL(input)) { // url.URL instance - input = urlToHttpOptions(input); + input = urlToHttpOptions(input as URL); } else { cb = options as ResponseListener | undefined; options = input as $Node.ClientRequestOptions | ResponseListener | null | undefined; diff --git a/src/js/ahead-of-time/node/_http_common.ts b/src/js/ahead-of-time/node/_http_common.ts index 3bc12154870e..a04374217361 100644 --- a/src/js/ahead-of-time/node/_http_common.ts +++ b/src/js/ahead-of-time/node/_http_common.ts @@ -1,11 +1,13 @@ // What the compiler reads in place of src/js/node/_http_common.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 e9bcb64b690623cf8e8b8ce199a3326e23a7430bdeed5fd855a1902f2798e2d8. +// Written against that file with the SHA-256 1dd10190f02729fc74566c3328a01d6738fd628dae45459823472c568f445add. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. // This is a port of Node.js's lib/_http_common.js // https://github.com/nodejs/node/blob/v26.3.0/lib/_http_common.js const { checkIsHttpToken } = require("internal/validators"); const FreeList = require("internal/freelist"); +const http = require("internal/http"); +const { drainMicrotasks } = http; interface HTTPParserConstructor { new (): $Node.HTTPParser; @@ -43,7 +45,6 @@ interface HTTPParserBinding { const { methods, allMethods, HTTPParser } = process.binding("http_parser") as HTTPParserBinding; const incoming = require("node:_http_incoming"); -const http = require("internal/http"); // A symbol taken out of a pattern is a plain `symbol` for the type checker, and what it keys is untyped. const kIncomingMessage: typeof http.kIncomingMessage = http.kIncomingMessage; const kSkipPendingData: typeof http.kSkipPendingData = http.kSkipPendingData; @@ -130,6 +131,13 @@ const MAX_HEADER_PAIRS = 2000; // across multiple TCP packets or too large to be // processed in a single run. This method is also // called to process trailing HTTP headers. +// Node makes kOnHeaders and kOnBody with MakeCallback(), which runs the ticks and microtasks when the read came from the handle. Its other callbacks skip them. +function runTaskQueues(parser: $Node.HTTPParser) { + // What the socket held did not come from the handle. A tick could hand the parser the rest of it, or the end, in the middle of this read. + const socket = parser.socket!; + if (parser._consumed && socket.readableLength === 0 && !socket._readableState.ended) drainMicrotasks(); +} + function parserOnHeaders(this: $Node.HTTPParser, headers: string[], url: string) { // Once we exceeded headers limit - stop collecting them const capacity = this.maxHeaderPairs - this._headers.length; @@ -139,6 +147,7 @@ function parserOnHeaders(this: $Node.HTTPParser, headers: string[], url: string) this._headers.push(...headers.slice(0, capacity)); } this._url += url; + runTaskQueues(this); } // `headers` and `url` are set only if .onHeaders() has not been called for @@ -206,13 +215,12 @@ function parserOnBody(this: $Node.HTTPParser, b: Buffer) { const stream = this.incoming; // If the stream has already been removed, then drop it. - if (stream === null || stream[kSkipPendingData]) return; - - // Pretend this was the result of a stream._read call. - if (!stream._dumped) { + if (stream !== null && !stream[kSkipPendingData] && !stream._dumped && !stream.destroyed) { + // Pretend this was the result of a stream._read call. const ret = stream.push(b); if (!ret) readStop(this.socket); } + runTaskQueues(this); } function parserOnMessageComplete(this: $Node.HTTPParser) { @@ -230,6 +238,10 @@ function parserOnMessageComplete(this: $Node.HTTPParser) { parser._url = ""; } + // JS feeds this parser, so user frames can be on the stack and no queue has run. A read that the 'request' listener scheduled counts as begun. + const state = stream._readableState; + if (!parser._consumed && (state.resumeScheduled || state.readableListening)) stream._consuming = true; + // For emit end event stream.push(null); } diff --git a/src/js/ahead-of-time/node/_http_incoming.ts b/src/js/ahead-of-time/node/_http_incoming.ts index 95a17b44e7dd..02a2bef4b3fb 100644 --- a/src/js/ahead-of-time/node/_http_incoming.ts +++ b/src/js/ahead-of-time/node/_http_incoming.ts @@ -1,5 +1,5 @@ // What the compiler reads in place of src/js/node/_http_incoming.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 28181b2c5a22f824a8caebb17a1f5a3d65525df1d060b79b1a452932b3f20a1f. +// Written against that file with the SHA-256 4f95665043e6f4f3d49e32817a7356b59d1a04ac2406fe4d7f29c9a945deca3a. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. // Hardcoded module "node:_http_incoming" @@ -15,7 +15,6 @@ const streamState = require("internal/streams/state"); const { advanceFlags, addFlags, internals, createUnreadByteState } = streamState; const kState: typeof streamState.kState = streamState.kState; const EventEmitterPrototype = require("node:events").prototype; -const kDecoderValue: typeof streamState.kDecoderValue = streamState.kDecoderValue; const { errorOrDestroy } = require("internal/streams/destroy"); const shared = require("internal/shared"); const { @@ -34,23 +33,19 @@ const http = require("internal/http"); const { emitErrorNextTickIfErrorListenerNT, NodeHTTPBodyReadState, - NodeHTTPResponseAbortEvent, emitEOFIncomingMessage, emitEOFIncomingMessageOuter, clearServerParserIncoming, onDataIncomingMessage, - takeRequestTrailers, } = http; // A symbol taken out of a pattern is a plain `symbol` for the type checker, and what it keys is untyped. const kCapture: typeof shared.kCapture = shared.kCapture; const kShapeMode: typeof shared.kShapeMode = shared.kShapeMode; -const kAutoDestroyed: typeof shared.kAutoDestroyed = shared.kAutoDestroyed; const abortedSymbol: typeof http.abortedSymbol = http.abortedSymbol; const kAbortController: typeof http.kAbortController = http.kAbortController; const eofInProgress: typeof http.eofInProgress = http.eofInProgress; const kHandle: typeof http.kHandle = http.kHandle; const kMadeOverHandle: typeof http.kMadeOverHandle = http.kMadeOverHandle; -const kHandoffResponse: typeof http.kHandoffResponse = http.kHandoffResponse; const kHeaderSource: typeof http.kHeaderSource = http.kHeaderSource; const noBodySymbol: typeof http.noBodySymbol = http.noBodySymbol; const kReqShouldKeepAlive: typeof http.kReqShouldKeepAlive = http.kReqShouldKeepAlive; @@ -72,12 +67,7 @@ const hearsNothingButErrors = internals.hearsNothingButErrors!; const isPlainReadableState = internals.isPlainReadableState!; const finishReadOfNothing = internals.finishReadOfNothing!; const finishResume = internals.finishResume!; -const readMoreLater = internals.readMoreLater!; -const readMoreOnNextTick = internals.readMoreOnNextTick!; -const emitReadableNow = internals.emitReadableNow!; -const finishEnd = internals.finishEnd!; const emitsToListeners = internals.emitsToListeners!; -const emitData = internals.emitData!; const { read, push, resume, removeAllListeners } = internals.readableMethods!; const { destroy, closeAfterThrow } = internals.destroyMethods!; const { @@ -106,12 +96,8 @@ const kEndEmitted = 1 << 10; const kReading = 1 << 11; const kSync = 1 << 12; const kNeedReadable = 1 << 13; -const kEmittedReadable = 1 << 14; -const kReadableListening = 1 << 15; const kResumeScheduled = 1 << 16; -const kMultiAwaitDrain = 1 << 17; const kReadingMore = 1 << 18; -const kDataEmitted = 1 << 19; const kDefaultUTF8Encoding = 1 << 20; const kDecoder = 1 << 21; const kEncoding = 1 << 22; @@ -136,13 +122,6 @@ const kDecoding = kDecoder | kEncoding; const kBodyResumed = kFlagsResumed | kDataListening; const kBodyAsked = (kBodyResumed | kReading | kNeedReadable) & ~kReadingMore; const kBodyReading = kFlagsReading | kDataListening; -const kBodyGiven = (kBodyReading & ~kReading) | kDataEmitted; -const kBodyReadingOn = kBodyGiven | kReading; -const kBodyEnded = (kBodyGiven & ~kNeedReadable) | kEnded; -const kBodyAwaited = - (kFlagsUntouched | kReadableListening | kNeedReadable | kHasFlowing | kReading) & ~(kSync | kReadingMore); -const kBodyBuffered = (kBodyAwaited & ~(kReading | kNeedReadable)) | kEmittedReadable | kReadingMore; -const kBodyAnnounced = (kBodyBuffered & ~kEmittedReadable) | kNeedReadable; const kDestroysItself = kAutoDestroy | kConstructed | kEnded | kEndEmitted; const kDestroysItselfMask = kDestroysItself | kDestroyed | kClosed | kCloseEmitted | kErrored; @@ -202,7 +181,6 @@ declare global { _dumpAndCloseReadable(): void; _dump(): void; [kHeaders]: IncomingHttpHeaders | null; - [kAutoDestroyed]: boolean | undefined; [kHeadersDistinct]?: Dictionary; [kHeadersCount]: number; [kRawHeaders]: string[] | null; @@ -443,19 +421,18 @@ function readsRawHeadersThroughAccessor(message: $Node.IncomingMessage): boolean return parent !== null && ObjectPrototypeLookupGetter.$call(parent, "rawHeaders") === getRawHeaders; } -// Whether a read of `message.readableEnded` reaches the accessor of Readable.prototype, which is not configurable. -function readsReadableEndedThroughAccessor(message: $Node.IncomingMessage): boolean { - if (IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(message, "readableEnded", IncomingMessage)) { +// Whether reads of `message.readableEnded` and `message.destroyed` reach the accessors of Readable.prototype, which are not configurable. +function readsItsEndThroughAccessors(message: $Node.IncomingMessage): boolean { + if ( + IS_COMPILED_AHEAD_OF_TIME && + $findsDeclaredMember(message, "readableEnded", IncomingMessage) && + $findsDeclaredMember(message, "destroyed", IncomingMessage) + ) { return true; } - if (ObjectHasOwn(message, "readableEnded")) return false; - let level: object | null = ObjectGetPrototypeOf(message); - if (level === IncomingMessagePrototype) { - if (ObjectHasOwn(IncomingMessagePrototype, "readableEnded")) return false; - level = ObjectGetPrototypeOf(IncomingMessagePrototype); - } + let level: object | null = message; while (level !== ReadablePrototype) { - if (level === null || ObjectHasOwn(level, "readableEnded")) return false; + if (level === null || ObjectHasOwn(level, "readableEnded") || ObjectHasOwn(level, "destroyed")) return false; level = ObjectGetPrototypeOf(level); } return true; @@ -592,9 +569,9 @@ function resumeIfReadable(socket: $Node.HttpSocket) { } function resumeReadableConnection(req: $Node.IncomingMessage, socket: $Node.HttpSocket) { - if (req.upgrade || socket[kHandoffResponse] !== undefined) { + if (req.upgrade || socket.parser === null) { ("unlikely"); - // Upgrade request with a body (Node 26 semantics): reading the request must not flip the raw socket into flowing mode - tunnel bytes pushed to the socket before the 'upgrade' listener attaches its own 'data' handler would be discarded by a flowing stream with no readers. + // Upgrade request with a body (Node 26 semantics): reading the request must not flip the raw socket into flowing mode - raw bytes pushed to the socket before the 'upgrade' listener attaches its own 'data' handler would be discarded by a flowing stream with no readers. onIncomingMessageResumeNodeHTTPResponse.$call(req); } else if (!socket._paused) { if (socket.$resumeUnlessFlowing !== undefined) http.requestRoad.resumeUnlessFlowing!(socket, true); @@ -651,7 +628,7 @@ const incomingMessageRead = function _read(this: $Node.IncomingMessage, _n: numb } if (!handle.ondata) { - handle.ondata = onBodyData.bind(this); + handle.ondata = onDataIncomingMessage.bind(this); } }; IncomingMessage.prototype._read = incomingMessageRead; @@ -679,9 +656,7 @@ const incomingMessageDestroy = function _destroy( // (node's _destroy null-socket check). this[kHandle] = undefined; handle.onabort = handle.ondata = undefined; - let connection: $Node.IncomingMessage["socket"]; - if (!handle.finished && shouldEmitAborted && (connection = this.socket)) { - http.responseRoad.flushSocketHeldChunk?.(connection); + if (!handle.finished && shouldEmitAborted && this.socket) { handle.abort(); } const socket = this.socket; @@ -704,7 +679,11 @@ const incomingMessageDestroy = function _destroy( socket.destroy(err); const state = { cleanup: undefined as undefined | (() => void) }; state.cleanup = finished(socket, onFinishedAfterDestroy.bind(this, state, err, cb)); + } else if (err == null && !this.aborted) { + cb(); } else { + // pipeline() and `for await` destroy a request but keep its connection (req.socket is null). The rest of the body is dropped, not held back. + if (!socket && !this.complete) readStart(this.client); process.nextTick(onError, this, err, cb); } }; @@ -1024,7 +1003,8 @@ const dump = function _dump(this: $Node.IncomingMessage) { this.removeAllListeners("data"); if (endsUnwatched(this)) return; // ondata stays armed: it drops the chunks and still delivers the last one, which completes the request. - this.resume(); + if (this._readableState.ended && !this.destroyed && this.readableLength === 0) this.read(0); + else this.resume(); } }; IncomingMessage.prototype._dump = dump; @@ -1224,7 +1204,7 @@ function handleToDestroyUnwatched(req: $Node.IncomingMessage, state: $Node.Reada // destroy() reads it once more. (side === 1 && !!req._writableState) || !readsItsSocketUnseen(req) || - !readsReadableEndedThroughAccessor(req) || + !readsItsEndThroughAccessors(req) || !advanceFlags(state, kFlagsEnded, kFlagsDestroyed) ) { return undefined; @@ -1241,7 +1221,6 @@ function destroyOnNextTick(req: $Node.IncomingMessage, state: $Node.ReadableStat ("unlikely"); return endOnNextTick(state, req); } - req[kAutoDestroyed] = true; let unseen: boolean; try { req[kHandle] = undefined; @@ -1286,7 +1265,7 @@ function mayDumpInPlace(req: $Node.IncomingMessage): boolean { findsWithoutAsking(req, "resume", resume)) && ((IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(req, "removeAllListeners", IncomingMessage)) || findsWithoutAsking(req, "removeAllListeners", removeAllListeners)) && - readsReadableEndedThroughAccessor(req) + readsItsEndThroughAccessors(req) ); } catch { return false; @@ -1442,6 +1421,10 @@ function resumeRequestBody(stream: $Node.Readable, state: $Node.ReadableState): ("unlikely"); emitEOFIncomingMessage(req); } + if (!handle.ondata) { + ("unlikely"); + handle.ondata = onDataIncomingMessage.bind(req); + } } catch (err) { errorOrDestroy(stream, err as Error); } @@ -1457,307 +1440,6 @@ function resumeRequestBody(stream: $Node.Readable, state: $Node.ReadableState): internals.resumeRequestBody = resumeRequestBody; -function onBodyData(this: $Node.IncomingMessage, chunk: Buffer | undefined, isLast: boolean, aborted: number) { - return takeBodyData(this, chunk, isLast, aborted); -} - -internals.onRequestBodyData = onBodyData; - -// `stream` and `chunk` as in addChunk(): a TypeError about a replaced emit() quotes the call. -function takeBodyData(stream: $Node.IncomingMessage, bytes: Buffer | undefined, isLast: boolean, aborted: number) { - // Behind the yes no read off the request runs code of the program: an accessor laid on one of its fields lies outside its slot. - if ( - aborted !== NodeHTTPResponseAbortEvent.none || - !(IS_COMPILED_AHEAD_OF_TIME && $isPlainHeirOf(stream, IncomingMessage)) - ) { - ("unlikely"); - return onDataIncomingMessage.$call(stream, bytes, isLast, aborted); - } - const state = stream._readableState; - if (!(IS_COMPILED_AHEAD_OF_TIME && isPlainReadableState(state))) { - ("unlikely"); - return onDataIncomingMessage.$call(stream, bytes, isLast, aborted); - } - const flags = state[kState]; - const word = flags & ~kDecoding; - // Only the native server hands over a body this way. - const socket: $Node.HttpServerSocket | null | undefined = stream.socket as any; - if ( - !isLast || - bytes === undefined || - (word !== kBodyReading && - (word !== kBodyAwaited || bytes.length * ((flags & kDecoder) !== 0 ? 2 : 1) >= state.highWaterMark)) || - state.length !== 0 || - stream._dumped || - stream.upgrade || - stream[eofInProgress] || - (!(IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(stream, "push", IncomingMessage)) && - !findsWithoutAsking(stream, "push", push)) || - (!(IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(stream, "read", IncomingMessage)) && - !findsWithoutAsking(stream, "read", read)) || - (!(IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(stream, "_read", IncomingMessage)) && - !findsWithoutAsking(stream, "_read", incomingMessageRead)) || - (socket != null && !(IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(socket, "_unrefTimer"))) || - !ticksAreUnseen() - ) { - ("unlikely"); - return onDataIncomingMessage.$call(stream, bytes, isLast, aborted); - } - - socket?._unrefTimer(); - - const decoding = flags & ~kReading; - state[kState] = decoding; - const chunk = (flags & kDecoder) !== 0 ? state[kDecoderValue]!.write(bytes) : bytes; - const given = (flags & kDecoding) | kBodyGiven; - const length = chunk.length as number; - // The decoder's write() may be the program's. - const isAsTested = (flags & kDecoder) === 0 || (state[kState] === decoding && state.length === 0); - if (!isAsTested) { - ("unlikely"); - if (length !== 0) addDecodedChunk(stream, state, chunk, length); - } else if (length !== 0 && word === kBodyReading) { - state.awaitDrainWriters = null; - state[kState] = given; - emitData(stream, chunk); - } - if (isAsTested && length !== 0 && word === kBodyAwaited) { - state.length = length; - state.buffer.push(chunk); - state[kState] = (flags & kDecoding) | kBodyBuffered; - queueTick1(emitReadableNow, stream); - markEndInProgress(stream, state); - queueTick2(finishBufferedBody, stream, state); - } else if (isAsTested && state[kState] === given && state.length === 0) { - state[kState] = given | kReadingMore; - markEndInProgress(stream, state); - queueTick2(finishBody, stream, state); - } else { - ("unlikely"); - readMoreLater(stream, state); - if ((state[kState] & kEnded) !== 0 || (state.length >= state.highWaterMark && state.length !== 0)) { - const finSent = socket?.[kHandle]?.finSent === true; - // Nothing reads this body and a pause would hide the peer's FIN: uWS drops the later reads, so that FIN closes the connection. - if (finSent && !stream._consuming) socket![kHandle]!.stopParsing(); - else if (socket && (finSent || !socket.writableEnded)) { - socket.pause(); - stream[kHandle]?.pause(); - } - } - emitEOFIncomingMessage(stream); - } - if (!stream.upgrade && socket && !socket._paused && socket.readable) { - if (socket.$resumeUnlessFlowing !== undefined) http.requestRoad.resumeUnlessFlowing!(socket, false); - else { - ("unlikely"); - socket.resume(); - } - } -} - -// As readable's addChunk() adds a chunk at the end, less its look for more, which the caller takes. -function addDecodedChunk( - stream: $Node.IncomingMessage, - state: $Node.ReadableState, - chunk: string | Buffer, - length: number, -) { - if ((state[kState] & (kFlowing | kSync | kDataListening)) === (kFlowing | kDataListening) && state.length === 0) { - if ((state[kState] & kMultiAwaitDrain) !== 0) (state.awaitDrainWriters as Set<$Node.AnyStream>).clear(); - else state.awaitDrainWriters = null; - state[kState] |= kDataEmitted; - emitData(stream, chunk); - return; - } - state.length += length; - state.buffer.push(chunk); - if ((state[kState] & kNeedReadable) === 0) return; - state[kState] &= ~kNeedReadable; - if ((state[kState] & kEmittedReadable) === 0) { - state[kState] |= kEmittedReadable; - queueTick1(emitReadableNow, stream); - } -} - -function markEndInProgress(req: $Node.IncomingMessage, state: $Node.ReadableState) { - try { - req[eofInProgress] = true; - } catch (error) { - queueTick2(readMoreOnNextTick, req, state); - throw error; - } -} - -function readMoreThenEnd(req: $Node.IncomingMessage, state: $Node.ReadableState) { - try { - readMoreOnNextTick(req, state); - } catch (error) { - queueTick1(emitEOFIncomingMessageOuter, req); - throw error; - } - emitEOFIncomingMessageOuter(req); -} - -// A tick runs in a later turn than the one that queued it: what was asked there is asked again. -function finishBody(req: $Node.IncomingMessage, state: $Node.ReadableState) { - if (!(IS_COMPILED_AHEAD_OF_TIME && $isPlainHeirOf(req, IncomingMessage) && isPlainReadableState(state))) { - ("unlikely"); - return readMoreThenEnd(req, state); - } - const flags = state[kState]; - if ( - req._readableState !== state || - (flags & ~kDecoding) !== (kBodyGiven | kReadingMore) || - state.length !== 0 || - !req[kHandle] || - (!(IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(req, "read", IncomingMessage)) && - !findsWithoutAsking(req, "read", read)) || - (!(IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(req, "_read", IncomingMessage)) && - !findsWithoutAsking(req, "_read", incomingMessageRead)) - ) { - ("unlikely"); - return readMoreThenEnd(req, state); - } - const asked = flags | kReading | kSync; - state[kState] = asked; - try { - resumeConnection(req); - } catch (err) { - const stream: $Node.Readable = req; - errorOrDestroy(stream, err as Error); - } - // oxlint-disable-next-line bun/no-duplicate-conditional-property-access - if (state[kState] !== asked || state.length !== 0) { - ("unlikely"); - finishReadOfNothing(state); - if (state.length !== 0) return readMoreThenEnd(req, state); - state[kState] &= ~kReadingMore; - return emitEOFIncomingMessageOuter(req); - } - state[kState] = (flags & kDecoding) | kBodyReadingOn; - endBody(req, state); -} - -function finishBufferedBody(req: $Node.IncomingMessage, state: $Node.ReadableState) { - if (!(IS_COMPILED_AHEAD_OF_TIME && $isPlainHeirOf(req, IncomingMessage) && isPlainReadableState(state))) { - ("unlikely"); - return readMoreThenEnd(req, state); - } - const flags = state[kState]; - const length = state.length; - if ( - req._readableState !== state || - (flags & ~kDecoding) !== kBodyAnnounced || - length === 0 || - length >= state.highWaterMark || - !req[kHandle] || - (!(IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(req, "read", IncomingMessage)) && - !findsWithoutAsking(req, "read", read)) || - (!(IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(req, "_read", IncomingMessage)) && - !findsWithoutAsking(req, "_read", incomingMessageRead)) - ) { - ("unlikely"); - return readMoreThenEnd(req, state); - } - const asked = flags | kReading | kSync; - state[kState] = asked; - try { - resumeConnection(req); - } catch (err) { - const stream: $Node.Readable = req; - errorOrDestroy(stream, err as Error); - } - // oxlint-disable-next-line bun/no-duplicate-conditional-property-access - if (state[kState] !== asked || state.length !== length) { - ("unlikely"); - finishReadOfNothing(state); - if (state.length !== length) return readMoreThenEnd(req, state); - state[kState] &= ~kReadingMore; - return emitEOFIncomingMessageOuter(req); - } - state[kState] = (flags | kReading) & ~kReadingMore; - endBody(req, state); -} - -function releaseParserOnNextTick(req: $Node.IncomingMessage) { - const socket = req.socket; - if (socket != null) { - const parser = socket.parser; - if (parser != null && parser.incoming === req) { - queueTick2(clearServerParserIncoming, parser, req); - } - } -} - -function endBody(req: $Node.IncomingMessage, state: $Node.ReadableState) { - if (!(IS_COMPILED_AHEAD_OF_TIME && $isPlainHeirOf(req, IncomingMessage) && isPlainReadableState(state))) { - ("unlikely"); - return emitEOFIncomingMessageOuter(req); - } - const handle = req[kHandle] as $Node.NodeHTTPResponse | undefined; - if ( - handle === undefined || - req._readableState !== state || - (!(IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(req, "push", IncomingMessage)) && - !findsWithoutAsking(req, "push", push)) || - (!(IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(req, "read", IncomingMessage)) && - !findsWithoutAsking(req, "read", read)) - ) { - ("unlikely"); - return emitEOFIncomingMessageOuter(req); - } - req.complete = true; - const rawTrailers = takeRequestTrailers(req, handle); - if (rawTrailers !== undefined) { - ("unlikely"); - req._addHeaderLines(rawTrailers, rawTrailers.length); - req.push(null); - return releaseParserOnNextTick(req); - } - let flags = state[kState]; - let asked = flags; - if ((flags & kDecoder) !== 0) { - // The decoder's end() may be the program's: it finds the request no longer reading, as behind push(null). - asked = flags & ~kReading; - state[kState] = asked; - const rest = state[kDecoderValue]!.end(); - if (rest?.length) { - ("unlikely"); - state.buffer.push(rest); - state.length += rest.length as number; - } - } - flags = state[kState]; - let isUnheard = false; - if (flags === asked && state.length === 0 && (flags & kFlowing) !== 0) { - isUnheard = isReadableUnheard(req); - } - if (!isUnheard) { - state[kState] = (flags & ~(kReading | kNeedReadable)) | kEnded | kEmittedReadable; - emitReadableNow(req); - return releaseParserOnNextTick(req); - } - state[kState] = (flags & kDecoding) | kBodyEnded; - if (ring.read === ring.write && ticksAreUnseen()) { - try { - releaseParserOnNextTick(req); - } catch (error) { - queueTick2(endOnNextTick, state, req); - throw error; - } - emitBodyEnd(req, state); - } else { - queueTick2(endOnNextTick, state, req); - releaseParserOnNextTick(req); - } -} - -function emitBodyEnd(req: $Node.IncomingMessage, state: $Node.ReadableState) { - state[kState] |= kEndEmitted; - if (!emitsToListeners(req, "end")) req.emit("end"); - return finishEnd(state, req); -} - function destroyEndedRequest(state: $Node.ReadableState, stream: $Node.Readable): boolean { const req: $Node.IncomingMessage = stream as any; let handle: $Node.IncomingMessage[typeof kHandle]; @@ -1780,13 +1462,12 @@ function destroyEndedRequest(state: $Node.ReadableState, stream: $Node.Readable) req._readableState !== state || // destroy() reads it once more. (side === 1 && !!req._writableState) || - !readsReadableEndedThroughAccessor(req) || + !readsItsEndThroughAccessors(req) || !readsItsSocketUnseen(req) ) { ("unlikely"); return false; } - req[kAutoDestroyed] = true; } catch { return false; } @@ -1929,7 +1610,8 @@ function keepsItsMethods(req: $Node.IncomingMessage): boolean { $findsDeclaredMember(req, "push", IncomingMessage) && $findsDeclaredMember(req, "destroy", IncomingMessage) && $findsDeclaredMember(req, "listenerCount", IncomingMessage) && - $findsDeclaredMember(req, "readableEnded", IncomingMessage) + $findsDeclaredMember(req, "readableEnded", IncomingMessage) && + $findsDeclaredMember(req, "destroyed", IncomingMessage) )) { ("unlikely"); return readsTheMethodsOfItsClass(req); @@ -1962,7 +1644,7 @@ function readsTheMethodsOfItsClass(req: $Node.IncomingMessage): boolean { findsWithoutAsking(req, "destroy", destroy) && findsWithoutAsking(req, "_destroy", incomingMessageDestroy) && findsWithoutAsking(req, "listenerCount", listenerCount) && - readsReadableEndedThroughAccessor(req) && + readsItsEndThroughAccessors(req) && hasNoListenerAndNoWritableSide(req) ); } @@ -1973,8 +1655,7 @@ function readsItsSocketUnseen(req: $Node.IncomingMessage): boolean { } function endUnseen(req: $Node.IncomingMessage): void { - // The one store that a request closed in any way refuses comes first, so that a request is ended whole or not at all. - req[kAutoDestroyed] = true; + // A frozen request refuses the first store, so that a request is ended whole or not at all. req._dumped = true; req[kShapeMode] = false; req._consuming = true; diff --git a/src/js/ahead-of-time/node/_http_outgoing.ts b/src/js/ahead-of-time/node/_http_outgoing.ts index 0d83c8b2ff98..60594994077b 100644 --- a/src/js/ahead-of-time/node/_http_outgoing.ts +++ b/src/js/ahead-of-time/node/_http_outgoing.ts @@ -1,5 +1,5 @@ // What the compiler reads in place of src/js/node/_http_outgoing.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 7eb68379dcc85e37da343c90d1da52f21b0bd91937cea041c06b56a6db7551fc. +// Written against that file with the SHA-256 522503b9d2e85c020704fb18d9ed72f6b825baf7b68fd5c30aefa57dd202bf92. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. // Hardcoded module "node:_http_outgoing" @@ -17,10 +17,12 @@ const { isUint8Array } = require("node:util/types"); const { deprecate } = require("internal/util/deprecate"); const { getDefaultHighWaterMark } = require("internal/streams/state"); const http = require("internal/http"); -const { utcDate } = http; +const { utcDate, RE_CONN_CLOSE } = http; // A symbol taken out of a pattern is a plain `symbol` for the type checker, and what it keys is untyped. const kOutHeaders: typeof http.kOutHeaders = http.kOutHeaders; const kNeedDrain: typeof http.kNeedDrain = http.kNeedDrain; +const kFailHeldWrites: typeof http.kFailHeldWrites = http.kFailHeldWrites; +const kFinishesWithoutLastWrite: typeof http.kFinishesWithoutLastWrite = http.kFinishesWithoutLastWrite; const kErrored: typeof http.kErrored = http.kErrored; const kHighWaterMark: typeof http.kHighWaterMark = http.kHighWaterMark; const kSocket: typeof http.kSocket = http.kSocket; @@ -33,7 +35,6 @@ const { chunkExpression: RE_TE_CHUNKED, isLenient, } = require("node:_http_common"); -const { FakeSocket } = require("internal/http/FakeSocket"); const EE = require("node:events"); const ObjectDefineProperty = Object.defineProperty; @@ -134,12 +135,11 @@ const kCorked: typeof http.kCorked = http.kCorked; const kChunkedBuffer: typeof http.kChunkedBuffer = http.kChunkedBuffer; const kChunkedLength: typeof http.kChunkedLength = http.kChunkedLength; const kBytesWritten: typeof http.kBytesWritten = http.kBytesWritten; +const kEndCallbacks = Symbol("kEndCallbacks"); const kLenientValidation: typeof http.kLenientValidation = http.kLenientValidation; const nop = () => {}; -const RE_CONN_CLOSE = /(?:^|\W)close(?:$|\W)/i; - // isCookieField performs a case-insensitive comparison of a provided string // against the word "cookie." As of V8 6.6 this is faster than handrolling or // using a case-insensitive RegExp. @@ -227,7 +227,11 @@ declare global { callback?: HttpWriteCallback | null, size?: number, ): boolean; - _storeHeader(firstLine: string, headers: OutgoingHttpHeaders | HttpHeadersInit | null | undefined): void; + _storeHeader( + firstLine: string, + headers: OutgoingHttpHeaders | HttpHeadersInit | null | undefined, + trailerHeaderChecked?: boolean, + ): void; setHeader(name: string, value: HttpHeaderValue): this; setHeaders(headers: Iterable<[name: string, value: HttpHeaderValue]>): this; appendHeader(name: string, value: HttpHeaderValue): this; @@ -480,33 +484,69 @@ OutgoingMessage.prototype.cork = function cork(this: $Node.OutgoingMessage) { OutgoingMessage.prototype.uncork = function uncork(this: $Node.OutgoingMessage) { this[kCorked]--; - if (this[kSocket]) { - this[kSocket].uncork(); - } + const socket = this[kSocket]; + try { + if (this[kCorked] || this[kChunkedBuffer].length === 0) { + return; + } - if (this[kCorked] || this[kChunkedBuffer].length === 0) { - return; - } + const len = this[kChunkedLength]; + const buf = this[kChunkedBuffer]; + if (this.destroyed || socket?.destroyed) { + failCorkedChunks(this, this[kErrored] || socket?._writableState?.errored || $ERR_STREAM_DESTROYED("write")); + return; + } - const len = this[kChunkedLength]; - const buf = this[kChunkedBuffer]; + $assert(this.chunkedEncoding); - $assert(this.chunkedEncoding); + // Node emits 'drain' at the end of uncork(), where a listener that is added after uncork() returns misses it. + if (this[kNeedDrain]) process.nextTick(emitDrainIfFlushedNT, this); - let callbacks: $Node.HttpWriteCallback[] | undefined; - 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] as $Node.HttpChunk, buf[n + 1] as string | null | undefined, null); - if (buf[n + 2]) { - callbacks ??= []; - callbacks.push(buf[n + 2] as $Node.HttpWriteCallback); + let callbacks: $Node.HttpWriteCallback[] | undefined; + 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] as $Node.HttpChunk, buf[n + 1] as string | null | undefined, null); + if (buf[n + 2]) { + callbacks ??= []; + callbacks.push(buf[n + 2] as $Node.HttpWriteCallback); + } } + 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; +function failCorkedChunks(msg: $Node.OutgoingMessage, err: Error) { + const buf = msg[kChunkedBuffer]; + for (let n = 2; n < buf.length; n += 3) { + if (buf[n] !== nop) process.nextTick(buf[n] as $Node.HttpWriteCallback, err); + } + // The list that every response of the native server begins with takes no store. + if (buf.length !== 0) buf.length = 0; + msg[kChunkedLength] = 0; +} + +// Fails the callbacks of what the message holds and can't send any more. Node drops them. +OutgoingMessage.prototype[kFailHeldWrites] = function (this: $Node.OutgoingMessage, err?: Error) { + const held = this.outputData; + if (held.length === 0 && this[kChunkedBuffer].length === 0) return false; + err ??= $ERR_STREAM_DESTROYED("write"); + // None of this reached the socket, so a last write among it doesn't complete the message. + this[kFinishesWithoutLastWrite] = false; + failCorkedChunks(this, err); + this.outputData = []; + this._onPendingData(-this.outputSize); + this.outputSize = 0; + for (let i = 0; i < held.length; i++) { + const callback = held[i].callback; + if (typeof callback === "function") process.nextTick(callback, err); + } + return true; }; function runChunkCallbacks(callbacks: $Node.HttpWriteCallback[], err?: Error | null) { @@ -601,18 +641,20 @@ function _writeRaw( _size?: number, ) { const conn = this[kSocket]; + if (typeof encoding === "function") { + callback = encoding; + encoding = null; + } + if (conn?.destroyed) { // The socket was destroyed. If we're still trying to write to it, // then we haven't gotten the 'close' event yet. + if (typeof callback === "function") process.nextTick(callback, conn.errored ?? $ERR_STREAM_DESTROYED("write")); return false; } - if (typeof encoding === "function") { - callback = encoding; - encoding = null; - } - - if (conn && conn._httpMessage === this && conn.writable) { + // A socket whose last write failed takes this one too, and fails its callback before it closes. + if (conn && conn._httpMessage === this && (conn.writable || conn.errored)) { // There might be pending data in the this.output buffer. if (this.outputData.length) { this._flushOutput(conn); @@ -632,6 +674,7 @@ function _storeHeader( this: $Node.OutgoingMessage, firstLine: string, headers: $Node.OutgoingHttpHeaders | $Node.HttpHeadersInit | null | undefined, + trailerHeaderChecked = false, ) { // firstLine in the case of request is: 'GET /index.html HTTP/1.1\r\n' // in the case of response it is: 'HTTP/1.1 200 OK\r\n' @@ -756,7 +799,7 @@ function _storeHeader( // message will be terminated by the first empty line after the // header fields, regardless of the header fields present in the // message, and thus cannot contain a message body or 'trailers'. - if (this.chunkedEncoding !== true && state.trailer) { + if (this.chunkedEncoding !== true && state.trailer && !trailerHeaderChecked) { throw $ERR_HTTP_TRAILER_INVALID(); } @@ -981,11 +1024,9 @@ interface Exchange { value3: number | string; value4: number | string; value5: number | string; - isReplaying: boolean; response: $Node.ServerResponse; connection: $Node.HttpServerSocket | null; endCallback: $Node.HttpWriteCallback | null; - queuedTick: import("internal/process/tick_ring").Tick | null; events: ResponseEvents; holdsNumber: boolean; onWritable: (() => void) | null; @@ -1032,11 +1073,9 @@ function makeExchange( value3: 0, value4: 0, value5: 0, - isReplaying: false, response, connection, endCallback: null, - queuedTick: null, events, holdsNumber: false, onWritable: null, @@ -1684,7 +1723,7 @@ function flatHeadersOf(exchange: Exchange): unknown[] | null { http.responseRoad.makeExchange = makeExchange; http.responseRoad.storeHeader = storeShapedHeader; -http.responseRoad.settleLeniency = settleLeniency; +http.responseRoad.storeOnMessageAsFirstHeaderDoes = storeOnMessageAsFirstHeaderDoes; http.responseRoad.spillHeaders = spillHeaders; http.responseRoad.spillHeadersReachedThrough = spillHeadersReachedThrough; http.responseRoad.hasHeaderUnderKey = hasHeaderUnderKey; @@ -1692,13 +1731,24 @@ http.responseRoad.flatHeadersOf = flatHeadersOf; http.responseRoad.headerEntriesOf = headerEntriesOf; http.responseRoad.pushRenderedHeader = pushRenderedHeader; +// The first header of a message stores on the message. One that is frozen refuses that, where any other message stores it. +function storeOnMessageAsFirstHeaderDoes(message: $Node.OutgoingMessage, exchange: Exchange, name: string): void { + const lenient = message[kLenientValidation]; + if (lenient === undefined) return settleLeniency(exchange, name); + try { + message[kLenientValidation] = lenient; + } catch { + message._isLenientHeaderValidation(); + } +} + const outgoingSetHeader = function setHeader(this: $Node.OutgoingMessage, name: string, value: $Node.HttpHeaderValue) { if (this._header) { throw $ERR_HTTP_HEADERS_SENT("set"); } const exchange = this.$exchange; if (IS_COMPILED_AHEAD_OF_TIME && exchange !== undefined && exchange.response === this) { - if (exchange.shape.count === 0 && this[kLenientValidation] === undefined) settleLeniency(exchange, name); + if (exchange.shape.count === 0) storeOnMessageAsFirstHeaderDoes(this, exchange, name); storeShapedHeader(exchange, name, value); return this; } @@ -1766,13 +1816,13 @@ OutgoingMessage.prototype.appendHeader = function appendHeader( } if (IS_COMPILED_AHEAD_OF_TIME) spillHeadersReachedThrough(this); const field = validatedLowercaseHeaderName(name); - validateHeaderValueLenient(name, value, this._isLenientHeaderValidation()); const exchange = this.$exchange; // oxlint-disable-next-line bun/no-duplicate-conditional-property-access if (exchange !== undefined && exchange.response === this && exchange.shape !== spilledHeadersShape) { const index = indexInShape(exchange.shape, field); if (index < 0) return this.setHeader(name, value); + validateHeaderValueLenient(name, value, this._isLenientHeaderValidation()); let existing = shapedValue(exchange, index); if (!ArrayIsArray(existing)) { existing = [existing as number | string]; @@ -1792,6 +1842,7 @@ OutgoingMessage.prototype.appendHeader = function appendHeader( if (headers === null || !headers[field]) { return this.setHeader(name, value); } + validateHeaderValueLenient(name, value, this._isLenientHeaderValidation()); // Prepare the field for appending, if required if (!ArrayIsArray(headers[field][1])) { @@ -2061,6 +2112,12 @@ function write_( } } + // _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: $Node.HttpSocket | null; if (!fromEnd && (msgSocket = msg.socket) && !msgSocket.writableCorked) { msgSocket.cork(); @@ -2070,7 +2127,8 @@ function write_( let ret; if (msg.chunkedEncoding && chunk.length !== 0) { len ??= typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.byteLength; - if (msg[kCorked] && msg._headerSent) { + // After an uncork() too many the count is below zero, which in Node holds every later chunk back until end(). + if (msg[kCorked] > 0 && msg._headerSent) { let held = msg[kChunkedBuffer]; // Every response of the native server begins with one shared empty list. Whoever holds chunks back lays a list of the response's own first. if (held === http.responseRoad.noChunks) { @@ -2142,9 +2200,21 @@ OutgoingMessage.prototype.addTrailers = function addTrailers( } }; -function onFinish(outmsg: $Node.OutgoingMessage) { - if (outmsg?.socket?._hadError) return; - outmsg.emit("finish"); +function queueEndCallback(msg: $Node.OutgoingMessage, callback: () => void) { + 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?) { + const callbacks = outmsg[kEndCallbacks]; + if (callbacks) outmsg[kEndCallbacks] = undefined; + if ((outmsg[kFinishesWithoutLastWrite] && outmsg[kSocket]) || (!err && !outmsg.socket?._hadError)) { + return void outmsg.emit("finish"); + } + if (!callbacks) return; + 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( @@ -2176,7 +2246,7 @@ OutgoingMessage.prototype.end = function end( } 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")); } @@ -2191,19 +2261,28 @@ OutgoingMessage.prototype.end = function end( 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)) { 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); + // As in Node, a last write to a connection that was gone before end() doesn't complete the message or call the callback. + const lastWrite = this[kSocket]?.destroyed ? nop : finish; if (this._hasBody && this.chunkedEncoding) { - this._send("0\r\n" + this._trailer + "\r\n", "latin1", finish); + this._send("0\r\n" + this._trailer + "\r\n", "latin1", lastWrite); } else if (!this._headerSent || this.writableLength || chunk) { - this._send("", "latin1", finish); + this._send("", "latin1", lastWrite); } else { process.nextTick(finish); } @@ -2257,19 +2336,24 @@ OutgoingMessage.prototype._flush = function _flush(this: $Node.OutgoingMessage) const socket = this[kSocket]; if (socket?.writable) { + if (this[kNeedDrain]) process.nextTick(emitDrainIfFlushedNT, this); // There might be remaining data in this.output; write it out this._flushOutput(socket); if (this.finished) { // This is a queue to the server or client to bring in the next this. this._finish(); - } else if (this[kNeedDrain] && this.writableLength === 0) { - this[kNeedDrain] = false; - this.emit("drain"); } } }; +// Queued ahead of a flush: as in Node, 'drain' comes a tick later than the flush and before the callbacks of the writes it sent. +function emitDrainIfFlushedNT(msg: $Node.OutgoingMessage) { + if (!msg[kNeedDrain] || msg.writableLength !== 0) return; + msg[kNeedDrain] = false; + if (!msg.destroyed && !msg.finished) msg.emit("drain"); +} + OutgoingMessage.prototype._flushOutput = function _flushOutput(this: $Node.OutgoingMessage, socket: $Node.HttpSocket) { const outputLength = this.outputData.length; if (outputLength <= 0) return undefined; @@ -2430,5 +2514,4 @@ export default { validateHeaderName, validateHeaderValue, OutgoingMessage, - FakeSocket, } as const; diff --git a/src/js/ahead-of-time/node/_http_server.ts b/src/js/ahead-of-time/node/_http_server.ts index 21dce50466c9..f7222e38e15c 100644 --- a/src/js/ahead-of-time/node/_http_server.ts +++ b/src/js/ahead-of-time/node/_http_server.ts @@ -1,10 +1,10 @@ // What the compiler reads in place of src/js/node/_http_server.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 ff5fd0cf3ffd5e9a3fb246c83dbbefe854e635a64472500199657149d7819c82. +// Written against that file with the SHA-256 dae95a0d0caa9ce7b4dbd63a75f431a318b15f0c07786b5bb6a92dae31fccc0e. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. // Hardcoded module "node:_http_server" const EventEmitter = require("node:events"); -const { Stream } = require("node:stream"); +const { Stream, Duplex } = require("node:stream"); const { Stream: StreamOfLibrary } = require("internal/streams/legacy"); const StreamPrototype = Stream.prototype; // A name read from `plainStream` is looked up from Stream.prototype on. No constructor runs: the other text makes no stream here. @@ -37,23 +37,38 @@ const { isIP } = require("internal/net/isIP"); const { _checkInvalidHeaderChar: checkInvalidHeaderChar, chunkExpression, + continueExpression, validateHeaderName, validateHeaderValue, HTTPParser, calculateLenientFlags, - isLenient, + parsers, + freeParser, + prepareError, } = require("node:_http_common"); +/** The native class `ConnectionsList` of `process.binding("http_parser")`: the parsers of a server's connections. */ +interface ConnectionsList { + all(): $Node.HTTPParser[]; + idle(): $Node.HTTPParser[]; + active(): $Node.HTTPParser[]; + expired(headersTimeout: number, requestTimeout: number): $Node.HTTPParser[]; +} +const { ConnectionsList } = process.binding("http_parser") as { ConnectionsList: new () => ConnectionsList }; +const AsyncContextFrame = require("internal/async_context_frame"); const { validateObject, validateLinkHeaderValue, validateBoolean, validateInteger, + validateNumber, validateFunction, validateOneOf, + validatePort, } = require("internal/validators"); const { ConnResetException, ErrnoException, + ExceptionWithHostPort, hasObserver, startPerf, stopPerf, @@ -66,42 +81,24 @@ const kServerResponseStatistics = Symbol("ServerResponseStatistics"); const { isPrimary, loadChild } = require("internal/cluster/isPrimary"); const { addServerAbortSignalOption } = require("internal/net/server_abort_signal"); const http = require("internal/http"); -const { - STATUS_CODES, - hasServerResponseFinished, - drainMicrotasks, - setServerCustomOptions, - setServerAppFlags, - setServerMaxHeadersCount, - getMaxHTTPHeaderSize, - http1ServerPipeline, -} = http; +const netSymbols = require("internal/net/symbols"); +const kForeignListener: typeof netSymbols.kForeignListener = netSymbols.kForeignListener; +const kHandleClosed: typeof netSymbols.kHandleClosed = netSymbols.kHandleClosed; +const kSNIContexts: typeof netSymbols.kSNIContexts = netSymbols.kSNIContexts; +const { STATUS_CODES, drainMicrotasks, getMaxHTTPHeaderSize, onDataIncomingMessage, RE_CONN_CLOSE } = http; const enum NodeHTTPResponseAbortEvent { none = 0, abort = 1, timeout = 2, readParsed = 3, } -const enum NodeHTTPBodyReadState { - none, - pending = 1 << 1, - done = 1 << 2, - aborted = 1 << 3, -} const enum NodeHTTPResponseFlags { socket_closed = 1 << 0, request_has_completed = 1 << 1, ended = 1 << 2, - upgraded = 1 << 3, - dispatch_threw_while_queued = 1 << 9, closed_or_completed = socket_closed | request_has_completed, } -const enum NodeHTTPHeaderState { - none, - assigned, - sent, -} // A symbol taken out of a pattern is a plain `symbol` for the type checker, and what it keys is untyped. const kInternalSocketData: typeof http.kInternalSocketData = http.kInternalSocketData; const serverSymbol: typeof http.serverSymbol = http.serverSymbol; @@ -110,62 +107,39 @@ const kMadeOverHandle: typeof http.kMadeOverHandle = http.kMadeOverHandle; // Opens the birth of a response over a handle. ServerResponse alone is handed it: the mark above is shown to a request class of the program's, as the other text shows it one. // A literal: a call of Symbol while this file loads is one that a program can count. const kResponseMadeOverHandle = { __proto__: null } as $Node.ServerResponseOptions; -const kOnReadParsed: typeof http.kOnReadParsed = http.kOnReadParsed; -const kHandoffResponse: typeof http.kHandoffResponse = http.kHandoffResponse; -const kHasQueuedReads: typeof http.kHasQueuedReads = http.kHasQueuedReads; +const kLeaveHttp: typeof http.kLeaveHttp = http.kLeaveHttp; const kRealListen: typeof http.kRealListen = http.kRealListen; +const kUpgradeStream: typeof http.kUpgradeStream = http.kUpgradeStream; const tlsSymbol: typeof http.tlsSymbol = http.tlsSymbol; -const optionsSymbol: typeof http.optionsSymbol = http.optionsSymbol; const kPendingCallbacks: typeof http.kPendingCallbacks = http.kPendingCallbacks; -const headerStateSymbol: typeof http.headerStateSymbol = http.headerStateSymbol; const fakeSocketSymbol: typeof http.fakeSocketSymbol = http.fakeSocketSymbol; -const kChunkedEncoding = Symbol("kChunkedEncoding"); -const kShouldKeepAlive = Symbol("kShouldKeepAlive"); -const kMustCloseConnection = Symbol("kMustCloseConnection"); const kDetachedFrom = Symbol("kDetachedFrom"); -const kSocketTimeoutTimer = Symbol("socketTimeoutTimer"); const kKeepAliveTimeoutSet = Symbol("keepAliveTimeoutSet"); -// It stands above the functions that name it, as each symbol must that a function is registered for below. -const kStopParsingOnCloseListener = Symbol("kStopParsingOnCloseListener"); +// The key that internal/timers exports as `kTimeout`. +const kTimeout = Symbol.for("::buntimeout::"); const kBytesBuffered = Symbol("kBytesBuffered"); const kAccountingFlushScheduled = Symbol("kAccountingFlushScheduled"); const kSnapshotStatusCode = Symbol("kSnapshotStatusCode"); const kSnapshotStatusMessage = Symbol("kSnapshotStatusMessage"); const kFramingFrozenChunked = Symbol("kFramingFrozenChunked"); const kImplicitHeaderFromEnd = Symbol("kImplicitHeaderFromEnd"); -const kReplayingPipelinedOps = Symbol("kReplayingPipelinedOps"); const kPendingFinish = Symbol("kPendingFinish"); // Each symbol is made in front of the first call of the body: a variable that something may read before its store holds undefined beside the symbol, and what it keys goes by key. -// Snapshot of bytesWritten, taken when the socket releases its native handle. -const kSocketBytesWritten = Symbol("kBytesWritten"); -const kEnableStreaming = Symbol("kEnableStreaming"); -// Upgrade request whose body is still being parsed: reading the raw socket also -// resumes this request, like Node.js's UpgradeStream._read, so an unread body -// can never stall the upgrade data behind it. -const kUpgradeIncoming = Symbol("kUpgradeIncoming"); -const kStreamingEnabled = Symbol("kStreamingEnabled"); -// destroySoon() was called: native lets the read that is being parsed reach the request, then closes right behind the FIN. -const kDestroySoon = Symbol("kDestroySoon"); // Per-socket cached bound abort handler (the socket outlives its requests). const kBoundOnAbort = Symbol("kBoundOnAbort"); -// 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"); -// HTTP/1.1 pipelining (responses queued behind an in-flight response): -// - under a symbol on the socket: array of queued ServerResponses, in arrival order -// - on a queued response: { ops, bytes, needDrain, ended, isAncient } while it -// is queued (undefined once it owns the socket) -const kPipelinedQueuedState = Symbol("kPipelinedQueuedState"); -// Node's `state.outgoingData`: bytes buffered across this connection's queued -// responses. Reads are paused while it is at or above the high water mark. -const kOutgoingData = Symbol("kOutgoingData"); -const kPipelineKickScheduled = Symbol("kPipelineKickScheduled"); -const kRememberPeer: typeof http.kRememberPeer = http.kRememberPeer; -const kRequest: typeof http.kRequest = http.kRequest; +// The timers' clock 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"); +const kStopParsingOnCloseListener = Symbol("kStopParsingOnCloseListener"); +// What Node's connection listener keeps for each connection, made for one that uWS parses when its first request arrives early. +const kConnectionState = Symbol("kConnectionState"); +const kFinishedAtClose = Symbol("kFinishedAtClose"); +const kDetachedWhileClosing = Symbol("kDetachedWhileClosing"); +// On a socket: the Upgrade request whose body ended in the read that brought its head, until 'upgrade' is emitted. +const kUpgradeRequest = Symbol("kUpgradeRequest"); +// The request of a response that waited for its turn on a connection that uWS parses. +const kNativeRequest = Symbol("kNativeRequest"); +const kDispatchThrew = Symbol("kDispatchThrew"); const kCloseCallback: typeof http.kCloseCallback = http.kCloseCallback; -const kClosedWithUnsentData: typeof http.kClosedWithUnsentData = http.kClosedWithUnsentData; const isTlsSymbol: typeof http.isTlsSymbol = http.isTlsSymbol; const kOutHeaders: typeof http.kOutHeaders = http.kOutHeaders; const kErrored: typeof http.kErrored = http.kErrored; @@ -175,8 +149,11 @@ const kRejectNonStandardBodyWrites: typeof http.kRejectNonStandardBodyWrites = h const kUniqueHeaders: typeof http.kUniqueHeaders = http.kUniqueHeaders; const kReqShouldKeepAlive: typeof http.kReqShouldKeepAlive = http.kReqShouldKeepAlive; const kIncomingMessage: typeof http.kIncomingMessage = http.kIncomingMessage; +const kFailHeldWrites: typeof http.kFailHeldWrites = http.kFailHeldWrites; +const kFinishesWithoutLastWrite: typeof http.kFinishesWithoutLastWrite = http.kFinishesWithoutLastWrite; const kCapture: typeof shared.kCapture = shared.kCapture; const kShapeMode: typeof shared.kShapeMode = shared.kShapeMode; +const kClusterOwner: typeof shared.kClusterOwner = shared.kClusterOwner; const EventEmitterPrototype = EventEmitter.prototype; const kCorked: typeof http.kCorked = http.kCorked; const kChunkedBuffer: typeof http.kChunkedBuffer = http.kChunkedBuffer; @@ -199,9 +176,8 @@ const enum HeaderBits { const { FakeSocket } = require("internal/http/FakeSocket"); const NumberIsNaN = Number.isNaN; -const { IncomingMessage } = require("node:_http_incoming"); +const { IncomingMessage, readStart } = require("node:_http_incoming"); const IncomingMessageOfLibrary = http.responseRoad.IncomingMessage!; -const onBodyData = streamInternals.onRequestBodyData!; const takeRequestCloseTick = streamInternals.takeRequestCloseTick!; const { OutgoingMessage, parseUniqueHeadersOption } = require("node:_http_outgoing"); const endsUnseen = http.requestRoad.endsUnseen!; @@ -216,7 +192,7 @@ const requestReadsUnseen = http.requestRoad.readsUnseen!; const requestChainIsPlain = http.requestRoad.chainIsPlain!; const makeExchange = http.responseRoad.makeExchange!; const storeHeader = http.responseRoad.storeHeader!; -const settleLeniency = http.responseRoad.settleLeniency!; +const storeOnMessageAsFirstHeaderDoes = http.responseRoad.storeOnMessageAsFirstHeaderDoes!; const spillHeaders = http.responseRoad.spillHeaders!; const spillHeadersReachedThrough = http.responseRoad.spillHeadersReachedThrough!; const hasHeaderUnderKey = http.responseRoad.hasHeaderUnderKey!; @@ -226,16 +202,22 @@ const pushRenderedHeader = http.responseRoad.pushRenderedHeader!; const outgoingSetHeader = http.responseRoad.outgoingSetHeader!; const OutgoingMessagePrototype = OutgoingMessage.prototype; const outgoingGetHeader = OutgoingMessagePrototype.getHeader; +const failHeldWrites = OutgoingMessagePrototype[kFailHeldWrites]; const outgoingHasHeader = OutgoingMessagePrototype.hasHeader; -function PlainOutgoingMessage() {} -$toClass(PlainOutgoingMessage, "PlainOutgoingMessage", OutgoingMessage); -const plainOutgoingMessage: any = new PlainOutgoingMessage(); -let http1Fallback: typeof import("internal/http1_server_fallback").default | undefined; +const outgoingSocketSetter: (this: $Node.OutgoingMessage, value: $Node.HttpSocket | null) => void = + Object.getOwnPropertyDescriptor(OutgoingMessagePrototype, "socket")!.set!; +const kConnections = Symbol("http.server.connections"); +const buntls = Symbol.for("::buntls::"); const kConnectionsCheckingInterval = Symbol("http.server.connectionsCheckingInterval"); const kTrackedConnections = Symbol("http.server.trackedConnections"); const kPendingDrainClose = Symbol("http.server.pendingDrainClose"); const kPendingCloseGenerations = Symbol("http.server.pendingCloseGenerations"); const kListenerGeneration = Symbol("http.server.listenerGeneration"); +const 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; // Set on the 'connect'/'upgrade' handoff; closeAll/closeIdleConnections() skip these, as in Node. const kHandedOff = Symbol("http.server.socketHandedOff"); function closeHandle(handle: $Node.NodeHTTPServerSocket) { @@ -249,21 +231,50 @@ const kMaxHeadersCount = Symbol("http.server.maxHeadersCount"); // category, so this is near-zero cost when tracing is off. const kHttpTraceCat = "node,node.http"; let traceEvents: typeof import("internal/trace_event_recorder").default; -function traceServerRequestStart(http_res: $Node.ServerResponse) { +// What Node's ServerResponse constructor starts. Node makes no response for an 'upgrade' or a 'connect'. +function observeRequest(req: $Node.IncomingMessage, res: $Node.ServerResponse) { + if (hasObserver("http")) { + startPerf(res, kServerResponseStatistics, { + type: "http", + name: "HttpRequest", + detail: { req: { method: req.method, url: req.url, headers: req.headers } }, + }); + res.once("finish", stopServerResponsePerf); + } + traceEvents ??= require("internal/trace_event_recorder"); if (!traceEvents.isCategoryGroupEnabled(kHttpTraceCat)) return; traceEvents.emitEvent("b", kHttpTraceCat, "http.server.request"); - http_res.once("finish", traceServerRequestEnd); + res.once("finish", traceServerRequestEnd); } function traceServerRequestEnd() { traceEvents.emitEvent("e", kHttpTraceCat, "http.server.request"); } -const getBunServerAllClosedPromise = $bindgen("node_http_binding.bind.ts", "getBunServerAllClosedPromise") as ( +const addServerName = $bindgen("Listener.bind.ts", "addServerName") as ( + listener: $Node.NativeHTTPServer, + hostname: string, + context: $Node.NativeSecureContext, +) => void; +const setServerAppFlags = $bindgen("node_http_binding.bind.ts", "setServerAppFlags") as ( server: $Node.NativeHTTPServer, -) => Promise; -const getBunServerOpenCount = $bindgen("node_http_binding.bind.ts", "getBunServerOpenCount") as ( + lenientHttpFlags: number, + httpAllowHalfOpen: boolean, +) => void; +const setServerCustomOptions = $bindgen("node_http_binding.bind.ts", "setServerCustomOptions") as ( + server: $Node.NativeHTTPServer, + lenientHttpFlags: number, + httpAllowHalfOpen: boolean, + maxHeaderSize: number, + onClientError: (socket: $Node.NodeHTTPServerSocket, errorCode: number, rawPacket: ArrayBuffer) => void, +) => void; +const setServerMaxHeadersCount = $bindgen("node_http_binding.bind.ts", "setServerMaxHeadersCount") as ( server: $Node.NativeHTTPServer, -) => number; + maxHeadersCount: number, +) => void; +const setBunServerSocketHandlers = $bindgen("node_http_binding.bind.ts", "setBunServerSocketHandlers"); +const httpConnectionOf = $bindgen("node_http_binding.bind.ts", "httpConnectionOf") as ( + socket: unknown, +) => $Node.NodeHTTPServerSocket | undefined; const kServerResponse = Symbol("ServerResponse"); const kOptimizeEmptyRequests = Symbol("kOptimizeEmptyRequests"); @@ -271,10 +282,10 @@ const kEmptyBuffer = Buffer.alloc(0); const ObjectKeys = Object.keys; const ObjectHasOwn = Object.hasOwn; const ObjectGetPrototypeOf = Object.getPrototypeOf; -const ObjectSetPrototypeOf = Object.setPrototypeOf; +const ArrayBufferIsView = ArrayBuffer.isView; const MathMin = Math.min; const MathFloor = Math.floor; -const DateNow = Date.now; +const timerClockMs = $bindgen("Timer.bind.ts", "timerClockMs") as () => number; let cluster: typeof import("internal/cluster/child").default | undefined; @@ -289,19 +300,38 @@ function emitCloseServer(self: $Node.HttpServer) { } const relistened = !!self[serverSymbol]; // Node queues 'close' inside a close() that leaves no connection open. A listen() after it, or a connection to that listener, cannot hold the event back. - if ((relistened || self[kTrackedConnections].size > 0) && !drainedAtClose) return; + // Without one, the event waits for the 'close' of the sockets that close() swept. + if (relistened ? !drainedAtClose : self._connections > 0) return; self[kPendingDrainClose] = false; pendingGenerations.clear(); if (!relistened) self[kListenerGeneration] = undefined; self.emit("close"); } +function emitCloseNotRunning(self: $Node.HttpServer) { + self.emit("close"); +} function markListenerGenerationClosed(self: $Node.HttpServer, generation: ListenerGeneration) { generation.nativeClosed = true; - emitCloseServer(self); + emitCloseServerInImmediate(self); } function emitCloseNTServer(this: $Node.HttpServer, generation: ListenerGeneration) { 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: $Node.HttpServer, stopped: Promise, generation: ListenerGeneration) { + 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); + } +} const onClose = function onClose() {}; @@ -328,7 +358,7 @@ function emitCloseNT(self: $Node.ServerResponse) { } } -function setCloseCallback(self: $Node.HttpServerSocket, callback: (this: $Node.HttpServerSocket) => void) { +function setCloseCallback(self: $Node.HttpSocket, callback: (this: $Node.HttpSocket) => void) { const current = self[kCloseCallback]; if (callback === current) { return; @@ -375,13 +405,13 @@ const onServerResponseClose = function onServerResponseClose(this: $Node.HttpSoc }; // Node's write_() checks before _implicitHeader(): the write that renders the headers is counted but not checked. end() is always checked. -function strictContentLength(response: $Node.ServerResponse, headerState: NodeHTTPHeaderState, fromEnd: boolean) { +function strictContentLength(response: $Node.ServerResponse, hadHeader: boolean, fromEnd: boolean) { if (!response.strictContentLength) return; - return contentLengthToKeepTo(response, headerState, fromEnd); + return contentLengthToKeepTo(response, hadHeader, fromEnd); } -function contentLengthToKeepTo(response: $Node.ServerResponse, headerState: NodeHTTPHeaderState, fromEnd: boolean) { - if (!fromEnd && headerState === NodeHTTPHeaderState.none) return; +function contentLengthToKeepTo(response: $Node.ServerResponse, hadHeader: boolean, fromEnd: boolean) { + if (!fromEnd && !hadHeader) return; let contentLength = response._contentLength ?? response.getHeader("content-length"); if ( contentLength != null && @@ -415,15 +445,18 @@ function createServerParserShim(socket: $Node.HttpServerSocket): $Node.HttpServe incoming: null, outgoing: null, socket, + _consumed: true, free: serverParserShimFree, close: serverParserShimClose, + resume: serverParserShimClose, [kParserOnTimeout]: serverParserShimOnTimeout, }; } function releaseServerParserShim(socket: $Node.HttpServerSocket, req?: $Node.IncomingMessage) { const parser = socket.parser; - if (parser == null) return; + // A socket that was handed off and came back through emit('connection') has Node's parser, which socketOnClose frees. + if (parser == null || parser instanceof HTTPParser) return; socket.parser = null; socket.$isPrepared = false; parser.incoming = null; @@ -433,14 +466,14 @@ function releaseServerParserShim(socket: $Node.HttpServerSocket, req?: $Node.Inc if (req != null && req.parser != null) req.parser = null; } -function onNodeHTTPServerSocketTimeout(this: $Node.HttpServerSocket) { - const req = this[kRequest]; - // Like Node.js's socketOnTimeout: the request only sees 'timeout' while its - // message is still being received. +function socketOnTimeout(this: $Node.HttpSocket) { + // A keep-alive timer that fired is dead: the next response finish arms a new one. + if (this[kKeepAliveTimeoutSet]) this[kTimeout] = null; + const req = this.parser?.incoming; const reqTimeout = req && !req.complete && req.emit("timeout", this); const res = this._httpMessage; const resTimeout = res && res.emit("timeout", this); - const serverTimeout = this.server.emit("timeout", this); + const serverTimeout = this.server!.emit("timeout", this); if (!reqTimeout && !resTimeout && !serverTimeout) this.destroy(); } @@ -457,6 +490,20 @@ function emitListenErrorNextTick(self: $Node.HttpServer, err: unknown) { self.emit("error", err); } +function emitListenError( + server: $Node.HttpServer, + err: unknown, + port: number | string | undefined, + host: string | undefined, + socketPath: string | undefined, + fd: number | undefined, +) { + const { listenException } = require("internal/net/errors"); + if (fd !== undefined) err = listenException(err, null); + else err = socketPath ? listenException(err, socketPath, -1) : listenException(err, host ?? "::", port as number); + process.nextTick(emitListenErrorNextTick, server, err); +} + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1367-L1368 function normalizeServerTls(tls: $Node.HttpServerTLSOptions) { tls.requestCert = tls.requestCert === true; @@ -464,48 +511,84 @@ function normalizeServerTls(tls: $Node.HttpServerTLSOptions) { return tls; } -// Node registers connectionListener on every http.Server so `server.emit("connection", socket)` -// works for foreign Duplex sockets. The native listener handles its own sockets end to end; -// this picks up the rest. https://github.com/nodejs/node/blob/main/lib/_http_server.js -function connectionListener(this: $Node.HttpServer, socket: $Node.HttpSocket) { - // 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, { - http1Options: { - IncomingMessage: this[kIncomingMessage], - ServerResponse: this[kServerResponse], - }, - }); +// uWS parses the connections it accepts. Every other Duplex gets Node's connection listener. +function connectionListener(this: $Node.HttpServer, socket: $Node.HttpServerSocket) { + // A socket that 'connect' or 'upgrade' got is a connection like any other when it's emitted again. + if (!socket[kHandedOff]) { + if (socket[kHandle] !== undefined) return; + const http = httpConnectionOf(socket._handle); + if (http !== undefined) { + attachConnection(this, socket, http); + return; + } + } + 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 connectionListenerInternal(this, socket); } +function secureConnectionListener(this: $Node.HttpServer, socket: $Node.HttpServerSocket) { + if (socket[kHandle] !== undefined && !socket[kHandedOff]) nativeConnectionListener(this, socket); + else connectionListenerInternal(this, socket); +} + +// https://github.com/nodejs/node/blob/v26.3.0/lib/https.js#L103-L106 +function onTLSClientError(this: $Node.HttpServer, err, socket) { + if (!this.emit("clientError", err, socket)) socket.destroy(err); + else if (err?.code === "ERR_TLS_HANDSHAKE_TIMEOUT") process.nextTick(destroyUnlessSecure, socket); +} + +// handshakeTimeout closes the socket whatever the 'clientError' listener did with it. Node leaves that to the listener. +function destroyUnlessSecure(socket: $Node.HttpServerSocket) { + if (!socket._secureEstablished) socket.destroy(); +} + +type Frame = ReturnType; + /** One `listen()`: a native listener, and what `close()` has to know about it. */ interface ListenerGeneration { + server: $Node.HttpServer; isUnix: boolean; + tls: $Node.HttpServerTLSOptions | null; nativeClosed: boolean; drainedAtClose: boolean; + /** The async context of `listen()`, until `close()`. */ + frame: Frame; } -type PipelinedOperation = [ - kind: "raw" | "write" | "end", - chunk: $Node.HttpChunk | null | undefined, - encoding: string | null | undefined, - callback: $Node.HttpWriteCallback | null | undefined, -]; +/** Holds a turn among the responses that wait: of a request without a response, or of a 'connect' behind responses that are owed. */ +interface ResponseTurn { + destroyed: boolean; + destroy(): void; + handedOff?: $Node.NodeHTTPResponse; +} -/** What a response holds while it waits behind the response in flight on its connection. */ -interface PipelinedQueuedState { - ops: PipelinedOperation[]; - bytes: number; - headerBytes: number; - needDrain: boolean; - ended: boolean; - isAncient: boolean; - socket: $Node.HttpSocket; +/** What Node's connection listener keeps for each connection. */ +interface ConnectionState { + onData?: ((chunk: Buffer) => void) | null; + onEnd?: (() => void) | null; + onClose?: (() => void) | null; + onDrain?: (() => void) | null; + /** In the order of their requests. */ + outgoing: ($Node.ServerResponse | ResponseTurn)[]; + incoming: $Node.IncomingMessage[]; + outgoingData: number; + onPendingData: ((delta: number) => void) | undefined; + frame?: Frame; } type RequestListener = (request: $Node.IncomingMessage, response: $Node.ServerResponse) => void; declare global { + namespace NodeJS { + interface Timeout { + readonly _destroyed: boolean; + /** On the timers' clock. */ + _idleStart: number; + } + } + namespace $Node { /** The options of `tls.createSecureContext()` are validated by `internal/tls`. */ interface HttpServerOptions { @@ -526,6 +609,9 @@ declare global { shouldUpgradeCallback?: (request: IncomingMessage) => boolean; rejectNonStandardBodyWrites?: boolean; optimizeEmptyRequests?: boolean; + allowHalfOpen?: boolean; + keepAlive?: boolean; + keepAliveInitialDelay?: number; pfx?: unknown; cert?: unknown; key?: unknown; @@ -562,11 +648,18 @@ declare global { /** The listener that `createServer()` was given. A request goes to it by value while it is all that `emit("request")` would call. */ $requestListener: RequestListener | undefined; readonly listening: boolean; + /** The native server while it listens, for those who ask whether there is one. */ + _handle: NativeHTTPServer | null; + /** `net.Socket` counts the sockets it accepts and closes for this server. */ + _connections: number; timeout: number; maxRequestsPerSocket: number | null; maxHeadersCount: number | null; maxConnections?: number; noDelay: boolean; + keepAlive: boolean; + keepAliveInitialDelay: number; + highWaterMark: number; serverName?: string; maxHeaderSize: number | undefined; insecureHTTPParser: boolean | undefined; @@ -580,6 +673,7 @@ declare global { joinDuplicateHeaders: boolean | undefined; rejectNonStandardBodyWrites: boolean; httpAllowHalfOpen: boolean; + allowHalfOpen: boolean; shouldUpgradeCallback: (request: IncomingMessage) => boolean; blockList?: BlockList; _unref: boolean; @@ -592,12 +686,18 @@ declare global { address(): SocketAddressInfo | string | null; listen(...args: unknown[]): this; setTimeout(msecs: number, callback?: (socket: HttpSocket) => void): this; + _emitCloseIfDrained(): void; [kServerResponse]: ServerResponseConstructor | undefined; + [kConnections]?: ConnectionsList; [kConnectionsCheckingInterval]: Timeout | undefined; [kTrackedConnections]: Set; + [kHandedOffSockets]: Set; [kPendingDrainClose]: boolean; [kPendingCloseGenerations]: Set; [kListenerGeneration]: ListenerGeneration | undefined; + [kClusterHandle]: any; + [kClusterListeningId]: number; + [kConstructed]?: boolean; [kHttpAllowHalfOpen]: boolean; [kMaxHeadersCount]: number | null; [kOptimizeEmptyRequests]: boolean; @@ -605,89 +705,62 @@ declare global { type HttpServerConstructor = typeof Server; + interface NativeHTTPServer { + /** Only `bun --hot` sets one. */ + readonly id: string; + } + /** What the server keeps on every connection, its own sockets and the streams that were given to it. */ interface HttpSocket { - _requestCount?: number; + requestsCount?: number; [kHandedOff]?: boolean; - [kUpgradeIncoming]?: IncomingMessage; - /** Not undefined once a list was stored under the symbol of the pipelined responses. The list is read under the symbol. */ - $pipelinedResponses?: PipelinedResponse[]; - [kOutgoingData]?: number; - [kPipelineKickScheduled]?: boolean; - [kSocketTimeoutTimer]?: Timeout; + [kUpgradeStream]?: UpgradeStream; + [kTimeout]?: Timeout | null; [kKeepAliveTimeoutSet]?: boolean; - [kKeepAliveIdleStart]?: number; - } - - /** A connection of the native listener: the class `Socket` of this module, which extends `net.Socket`. */ - interface HttpServerSocket extends HttpSocket { - server: HttpServer; - parser: HttpServerParserShim | null; - _httpMessage: ServerResponse | null | undefined; - _paused: boolean; - _secureEstablished: boolean; - bytesRead: number; - bytesWritten: number; - connecting: boolean; - timeout: number; - encrypted: boolean; - resetAndClosing: boolean; - remoteAddress: string | undefined; - remoteFamily: string | undefined; - remotePort: number | undefined; - readonly localAddress: string | undefined; - readonly localFamily: string | undefined; - readonly localPort: number | undefined; - setTimeout(msecs: number, callback?: () => void): this; - destroySoon(): void; - destroyNow(): void; - _onTimeout(): void; - _unrefTimer(): void; - [kHandedOff]: boolean; - [kUpgradeIncoming]: IncomingMessage | undefined; - [kStreamingEnabled]: boolean; - $armedTimeout: number; - $isOpen: boolean; - $isPrepared: boolean; - $heldChunk: HttpHeldChunk | null; - $hasWaitingResponses: boolean; - $ownReadableState: ReadableState; - $ownWritableState: WritableState; - [kKeepAliveTimeoutSet]: boolean | undefined; - [kSocketTimeoutTimer]?: Timeout; - $pipelinedResponses: PipelinedResponse[] | undefined; - [kDestroySoon]: boolean; - [kBoundOnAbort]: ((event: number) => void) | null; - [kKeepAliveIdleStart]: number | undefined; - [kSocketBytesWritten]: number; - [kStopParsingOnCloseListener]: boolean | undefined; - [kEnableStreaming](enable: boolean): void; - } - - /** A native response among the pipelined responses: it holds the turn of a request whose `ServerResponse` is not made yet. */ - interface PipelinedTurn extends NodeHTTPResponse { - /** Never there, which tells it from a `ServerResponse` that waits. */ - [kPipelinedQueuedState]?: undefined; + [kDetachedWhileClosing]?: ServerResponse; + } + + /** What the server keeps on a connection. `node:net` makes the object with the layout of `Socket`, which compiled code tests for: a type of its own would fail every test, and a member that `Socket` lacks has no slot. */ + interface Socket { + // The client declares narrower types for these two on its own sockets, which has to stay a conversion from `Socket`. + parser?: any; + _httpMessage?: any; + _paused?: boolean; + _SNICallback?: NetServer["_SNICallback"]; + requestsCount?: number; + $isPrepared?: boolean; + $ownReadableState?: ReadableState; + $resumeUnlessFlowing?(asksWhetherReadable: boolean): void; + [kHandle]?: NodeHTTPServerSocket; + [kCloseCallback]?: (this: HttpSocket) => void; + [kHandedOff]?: boolean; + [kUpgradeStream]?: UpgradeStream; + [kKeepAliveTimeoutSet]?: boolean; + [kDetachedWhileClosing]?: ServerResponse; + [kBoundOnAbort]?: (event: number) => void; + [kLastResponseWrite]?: number; + [kStopParsingOnCloseListener]?: boolean; + [kConnectionState]?: ConnectionState; + [kUpgradeRequest]?: IncomingMessage; + [kFinishedAtClose]?: ServerResponse; + [kHandleClosed]?: (this: HttpServerSocket) => void; } - type PipelinedResponse = ServerResponse | PipelinedTurn; + /** A connection of the native listener: a `net.Socket` or a `tls.TLSSocket`. */ + type HttpServerSocket = Socket; interface ServerResponseOptions extends OutgoingMessageOptions { - [kHandle]?: NodeHTTPResponse | Http1ResponseHandle; - [kRejectNonStandardBodyWrites]?: boolean; + [kHandle]?: NodeHTTPResponse | null; } interface ServerResponse extends OutgoingMessage { req: IncomingMessage; statusCode: number; statusMessage: string | undefined; - headersSent: boolean; writableCorked: number; _sent100: boolean; _ended: boolean; - [kChunkedEncoding]: boolean; - [kShouldKeepAlive]: boolean; - [kMustCloseConnection]: boolean | undefined; + _expect_continue: boolean; [kDetachedFrom]: HttpServerSocket | undefined; [kBytesBuffered]: number | undefined; [kAccountingFlushScheduled]: boolean | undefined; @@ -695,8 +768,9 @@ declare global { [kSnapshotStatusMessage]: string | undefined; [kFramingFrozenChunked]: boolean | undefined; [kImplicitHeaderFromEnd]?: boolean; - [kReplayingPipelinedOps]?: boolean; [kPendingFinish]?: HttpWriteCallback | null; + [kNativeRequest]?: NodeHTTPResponse; + [kDispatchThrew]?: boolean; writeHead( statusCode: number, statusMessage?: string | HttpHeadersInit | null, @@ -714,7 +788,6 @@ declare global { assignSocket(socket: HttpSocket): void; detachSocket(socket: HttpSocket | null | undefined): void; _callPendingCallbacks(): void; - [kPipelinedQueuedState]: PipelinedQueuedState | undefined; $exchange: HttpExchange; [kServerResponseStatistics]?: object; } @@ -723,6 +796,19 @@ declare global { } } +const kConstructed = Symbol("kConstructed"); + +// Bun only: http.createServer() with one of these serves TLS. Own enumerable ones count, which are what tls.Server gets. +function hasOwnTLSIdentity(options: $Node.HttpServerOptions) { + const isOwn = Object.prototype.propertyIsEnumerable; + return !!( + (isOwn.$call(options, "pfx") && options.pfx) || + (isOwn.$call(options, "cert") && options.cert) || + (isOwn.$call(options, "key") && options.key) || + (isOwn.$call(options, "ca") && options.ca) + ); +} + function Server( this: $Node.HttpServer, options?: $Node.HttpServerOptions | RequestListener | null, @@ -734,14 +820,20 @@ function Server( callback?: RequestListener, ) { if (!(this instanceof Server)) return new Server(options, callback); + return initHttpServer.$call(this, options, callback); +} + +// The constructor body, with no `instanceof` guard. https.Server runs it on an instance that is not an http.Server. +function initHttpServer( + this: $Node.HttpServer, + options?: $Node.HttpServerOptions | RequestListener | null, + callback?: RequestListener, +) { EventEmitter.$call(this); this.on("listening", setupConnectionsTracking); this.on("connection", connectionListener); - this.prependListener("connection", (socket: $Node.HttpSocket | null | undefined) => { - if (socket != null && typeof socket === "object") socket.server = this; - }); - + this._handle = null; this._unref = false; this.timeout = 0; this.maxRequestsPerSocket = 0; @@ -749,11 +841,15 @@ function Server( defineHttpAllowHalfOpen(this); this[kInternalSocketData] = undefined; this[kTrackedConnections] = new Set(); + this[kHandedOffSockets] = new Set(); + this._connections = 0; this[kPendingDrainClose] = false; this[kPendingCloseGenerations] = new Set(); this[kListenerGeneration] = undefined; - this[tlsSymbol] = null; + // A constructor that is called on an existing server keeps its TLS configuration. + this[tlsSymbol] ??= null; this.noDelay = true; + let isTLS = false; if (typeof options === "function") { callback = options; options = {}; @@ -761,114 +857,43 @@ function Server( options = {}; } 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); - } + isTLS = (this[isTlsSymbol] && !this[tlsSymbol]) || hasOwnTLSIdentity(options); + } - let serverName = options.servername; - if (serverName && typeof serverName !== "string") { - throw $ERR_INVALID_ARG_TYPE("options.servername", "string", serverName); - } + storeHTTPOptions.$call(this, options); + // Node's https.Server gives tls.Server, and so net.Server, a spread of the caller's object. Its http.Server reads net.Server's options by name. + const transportOptions = this[isTlsSymbol] ? { ...options } : options; + // Node's http.Server always allows half-open sockets. https.Server takes the option, like tls.Server. + this.allowHalfOpen = this[isTlsSymbol] ? !!transportOptions.allowHalfOpen : true; + // This tls.Server never listens. It validates the TLS options and owns the SecureContext, like the one Node's https.Server extends. + if (isTLS) { + const { ignoredByHttpsServer } = require("internal/tls"); + const tlsServer = (this[tlsSymbol] = new (require("node:tls").Server)({ + ...transportOptions, + ...ignoredByHttpsServer, + })); + 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() asks for it, so a server whose TLS options threw can't listen in cleartext. + this[kConstructed] = true; + + // Node passes it to net.Server, which applies it to every accepted socket, and from there to req and res. + this[kHighWaterMark] = transportOptions.highWaterMark; + this.highWaterMark = transportOptions.highWaterMark ?? getDefaultHighWaterMark(); - let secureOptions = options.secureOptions || 0; - if (secureOptions && typeof secureOptions !== "number") { - throw $ERR_INVALID_ARG_TYPE("options.secureOptions", "number", secureOptions); - } + this[kUniqueHeaders] = parseUniqueHeadersOption(options.uniqueHeaders); - 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, - }); - } + // 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 = transportOptions.keepAliveInitialDelay; + if (keepAliveInitialDelay !== undefined) { + validateNumber(keepAliveInitialDelay, "options.keepAliveInitialDelay"); + if (keepAliveInitialDelay < 0) keepAliveInitialDelay = 0; } - - this[optionsSymbol] = options; - storeHTTPOptions.$call(this, options); + this.keepAlive = Boolean(transportOptions.keepAlive); + this.keepAliveInitialDelay = ~~(keepAliveInitialDelay! / 1000); if (callback) this.on("request", callback); this.$requestListener = callback; @@ -876,6 +901,42 @@ function Server( } $toClass(Server, "Server", EventEmitter); +// What net.ts's accept path and handshake handler read of a tls.Server. listen({ tls }) has the two certificate options only. +Object.defineProperties(Server.prototype, { + _requestCert: { + configurable: true, + get(this: $Node.HttpServer) { + const tlsServer = this[tlsSymbol]; + return tlsServer ? tlsServer._requestCert : this[kListenerGeneration]?.tls?.requestCert; + }, + }, + _rejectUnauthorized: { + configurable: true, + get(this: $Node.HttpServer) { + const tlsServer = this[tlsSymbol]; + return tlsServer ? tlsServer._rejectUnauthorized : this[kListenerGeneration]?.tls?.rejectUnauthorized; + }, + }, + _handshakeTimeout: { + configurable: true, + get(this: $Node.HttpServer) { + return this[tlsSymbol]?._handshakeTimeout ?? 120_000; + }, + }, + _ALPNCallback: { + configurable: true, + get(this: $Node.HttpServer) { + return this[tlsSymbol]?._ALPNCallback; + }, + }, + _SNICallback: { + configurable: true, + get(this: $Node.HttpServer) { + return this[tlsSymbol]?._SNICallback; + }, + }, +}); + Object.defineProperty(Server.prototype, "listening", { __proto__: null, get(this: $Node.HttpServer) { @@ -891,6 +952,10 @@ Server.prototype[kServerResponse] = undefined; Server.prototype[kConnectionsCheckingInterval] = undefined; +Server.prototype[kClusterHandle] = undefined; + +Server.prototype[kClusterListeningId] = 0; + function rethrowUncaught(err: unknown) { throw err; } @@ -905,6 +970,8 @@ function setupConnectionsTracking(this: $Node.HttpServer) { 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 @@ -914,12 +981,26 @@ function setupConnectionsTracking(this: $Node.HttpServer) { function checkConnections(this: $Node.HttpServer) { const headersTimeout = this.headersTimeout; const requestTimeout = this.requestTimeout; - if (headersTimeout === 0 && requestTimeout === 0) { - return; + const sweep = headersTimeout !== 0 || requestTimeout !== 0; + + // llhttp keeps the times of the connections it parses, uWS those of its own. + const parsed = this[kConnections]; + if (sweep && parsed !== undefined) { + const expired = parsed.expired(headersTimeout, requestTimeout); + for (let i = 0; i < expired.length; i++) { + const socket = expired[i].socket; + if (socket) onRequestTimeout(socket); + } } 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 (!sweep) { return; } @@ -933,11 +1014,13 @@ function checkConnections(this: $Node.HttpServer) { } // https://github.com/nodejs/node/blob/v26.3.0/lib/_http_server.js (onRequestTimeout) -function onRequestTimeout(socket: $Node.HttpServerSocket) { +function onRequestTimeout(socket: $Node.HttpSocket) { socketOnError.$call(socket, $ERR_HTTP_REQUEST_TIMEOUT("Request timeout")); } Server.prototype.ref = function (this: $Node.HttpServer) { + // Only what unref() let go of: a socket can have an unref() of the program's own. + if (this._unref) for (const socket of this[kHandedOffSockets]) socket.ref(); this._unref = false; this[serverSymbol]?.ref?.(); return this; @@ -946,35 +1029,72 @@ Server.prototype.ref = function (this: $Node.HttpServer) { Server.prototype.unref = function (this: $Node.HttpServer) { this._unref = true; this[serverSymbol]?.unref?.(); + for (const socket of this[kHandedOffSockets]) socket.unref(); return this; }; Server.prototype.closeAllConnections = function (this: $Node.HttpServer) { - http1Fallback?.closeAllHttp1Connections(this); - // Like Node, the listener and the sockets handed to 'connect' and 'upgrade' listeners stay. + const parsed = this[kConnections]; + if (parsed !== undefined) { + const connections = parsed.all(); + for (let i = 0, l = connections.length; i < l; i++) { + connections[i].socket!.destroy(); + } + } + // Like Node, this skips the listener, sockets handed to 'connect' / 'upgrade' listeners, and TLS connections still in their handshake. const tracked = this[kTrackedConnections]; if (tracked && tracked.size > 0) { for (const socket of $Array.from(tracked)) { - if (!socket[kHandedOff]) socket.destroyNow(); + destroyNow(socket); } } }; +// net.Socket's _destroy() calls this after it queued the socket's own 'close', so the immediate set here runs behind that. The one that stop()'s promise sets can run ahead: an idle connection that its peer or closeIdleConnections() closes emits 'close' after the server, as in Node. +Server.prototype._emitCloseIfDrained = function (this: $Node.HttpServer) { + // This server has no 'close' listener on them, as in Node, so it forgets the closed ones now and then. + const handedOff = this[kHandedOffSockets]; + if (handedOff.size > 2 * this._connections + 16) { + for (const socket of handedOff) if (socket.destroyed) handedOff.delete(socket); + } + if (this._connections === 0 && this[kPendingDrainClose]) + queueMicrotask(emitCloseServerInImmediate.bind(undefined, this)); +}; +function emitCloseServerInImmediate(self: $Node.HttpServer) { + setImmediate(emitCloseServer, self); +} + Server.prototype.getConnections = function ( this: $Node.HttpServer, callback: (error: Error | null, count: number) => void, ) { // Connections are tracked from the moment they are accepted (the native // server surfaces accepts through the onConnection callback). - const count = this[kTrackedConnections]?.size ?? 0; + const count = this._connections; if (typeof callback === "function") { process.nextTick(callback, null, count); } return this; }; +function closeIdleParsedConnections(server: $Node.HttpServer) { + const parsed = server[kConnections]; + if (parsed === undefined) return; + const connections = parsed.idle(); + for (let i = 0, l = connections.length; i < l; i++) { + const socket = connections[i].socket!; + if (socket._httpMessage && !socket._httpMessage.finished) continue; + socket.destroy(); + } +} + +function httpServerPreClose(server: $Node.HttpServer) { + server.closeIdleConnections(); + clearInterval(server[kConnectionsCheckingInterval]); +} + Server.prototype.closeIdleConnections = function (this: $Node.HttpServer) { - http1Fallback?.closeIdleHttp1Connections(this); + closeIdleParsedConnections(this); const server = this[serverSymbol]; if (server) { server.closeIdleConnections(); @@ -984,7 +1104,7 @@ Server.prototype.closeIdleConnections = function (this: $Node.HttpServer) { if (tracked && tracked.size > 0) { for (const socket of $Array.from(tracked)) { // uWS knows whether a request is arriving on the connection. A missing _httpMessage does not tell. - if (!socket[kHandedOff]) socket[kHandle]?.closeIfIdle(); + socket[kHandle]?.closeIfIdle(); } } }; @@ -994,23 +1114,38 @@ Server.prototype.close = function (this: $Node.HttpServer, optionalCallback?: (e // Node.js's httpServerPreClose clears the connections-checking interval // even when the server was never listening. clearInterval(this[kConnectionsCheckingInterval]); - http1Fallback?.closeIdleHttp1Connections(this); + closeIdleParsedConnections(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. + // https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L2596-L2647 + if (typeof optionalCallback === "function") this.once("close", () => optionalCallback($ERR_SERVER_NOT_RUNNING())); + if (this._connections === 0) process.nextTick(emitCloseNotRunning, this); return this; } if (typeof optionalCallback === "function") this.once("close", 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 (generation) generation.drainedAtClose = this._connections === swept; + if (nativeServerOwners !== undefined) { + // Under `bun --hot`, stopping a shared native server also takes the listener away from its current http.Server. + const owner = nativeServerOwners.get(server); + if (owner != null && owner !== this) nativeServerOwners.set(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; }; @@ -1060,25 +1195,50 @@ Server.prototype.listen = function (this: $Node.HttpServer) { // Before anything is registered, like net.Server: https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L2270-L2272 if (this[serverSymbol]) throw $ERR_SERVER_ALREADY_LISTEN(); const server = this; - let port: number | string | undefined, host: string | undefined; + let port: number | string | null | undefined, host: string | undefined; let socketPath: string | undefined; - let tls = this[tlsSymbol]; + let fd: number | undefined; + let reusePort = false; + let ipv6Only = false; + let exclusive = false; + const tlsServer = this[tlsSymbol]; + // tls.Server's context for its own listener always has the server's verify mode. socket.authorized reads requestCert. + let tls: $Node.HttpServerTLSOptions | null = tlsServer && { + __proto__: null, + secureContext: tlsServer[buntls](null)[0].secureContext, + requestCert: tlsServer._requestCert === true, + serverName: tlsServer.servername, + }; // This logic must align with: // - https://github.com/nodejs/node/blob/2eff28fb7a93d3f672f80b582f664a7c701569fb/lib/net.js#L274-L307 const argc = arguments.length; if (argc > 0) { const arg0 = arguments[0]; - if (($isObject(arg0) || $isCallable(arg0)) && arg0 !== null) { + if ($isObject(arg0)) { // (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; + ipv6Only = arg0.ipv6Only === 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)) { - tls = normalizeServerTls({ __proto__: null, ...otherTLS }); + if ($isObject(otherTLS)) { + tls = normalizeServerTls({ __proto__: null, ...otherTLS, secureContext: undefined }); } } else if (typeof arg0 === "string" && !(Number(arg0) >= 0)) { // (path[...][, cb]) @@ -1094,15 +1254,18 @@ Server.prototype.listen = function (this: $Node.HttpServer) { // Bun defaults to port 3000. // Node defaults to port 0. - if (port === undefined && !socketPath) { + if (port == null && !socketPath) { 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; + } else if (socketPath) { + // Like net.Server.listen(), listening on a `path` ignores `host`. + host = undefined; } const lastArg = arguments[argc - 1]; @@ -1112,16 +1275,32 @@ Server.prototype.listen = function (this: $Node.HttpServer) { } 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 as number | undefined, host, socketPath, reusePort, ipv6Only, fd); return this; } if (cluster === undefined) cluster = loadChild(); if (CAN_BE_CLUSTER_WORKER) { + // 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 as number | undefined, host, socketPath, ipv6Only, 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; @@ -1140,15 +1319,79 @@ Server.prototype.listen = function (this: $Node.HttpServer) { process.send!(message, undefined, kInternalSendOptions); }); - server[kRealListen](tls, port, host, socketPath, true); + server[kRealListen](tls, port as number | undefined, host, socketPath, true, ipv6Only, fd); + if (!exclusive) cluster!._trackServer(server); } } catch (err) { - process.nextTick(emitListenErrorNextTick, server, err); + emitListenError(server, err, port as number | undefined, host, socketPath, fd); } return this; }; +// net.Server's listenInCluster, always `sharedOnly` like its TLS servers: Bun.serve can't take connections the primary accepted. +function listenInCluster( + server: $Node.HttpServer, + tls: $Node.HttpServerTLSOptions | null, + port: number | undefined, + host: string | undefined, + socketPath: string | undefined, + ipv6Only: boolean, + fd: number | undefined, +) { + const listeningId = ++server[kClusterListeningId]; + function query(address: string | null, queryPort: number | null | undefined, addressType: number | null) { + const flags = ipv6Only ? 1 : 0; + const serverQuery = { address, port: queryPort, addressType, fd, flags, sharedOnly: true, isHttp: 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 as string, queryPort as number); + 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, 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 as string, port, family === 6 ? 6 : 4); + }); +} + +// By the prototypes alone: a read of a symbol off the server may be seen. +function isHeirOfServer(server: object): boolean { + for (let level = ObjectGetPrototypeOf(server); level !== null; level = ObjectGetPrototypeOf(level)) { + if (level === Server.prototype || $isProxyObject(level)) return true; + } + return false; +} + Server.prototype[kRealListen] = function ( this: $Node.HttpServer, tls: $Node.HttpServerTLSOptions | null, @@ -1156,17 +1399,24 @@ Server.prototype[kRealListen] = function ( host: string | undefined, socketPath: string | undefined, reusePort: boolean, + ipv6Only: boolean, + fd?: number, ) { { // What the first connection and the first request need, so that neither waits for it. - getNodeHTTPServerSocket(); + loadNet(tls); + // The constructor adds them for TLS options of its own. listen({ tls }) comes without. + if (tls && this.listenerCount("secureConnection", secureConnectionListener) === 0) { + this.prependListener("secureConnection", secureConnectionListener); + this.prependListener("tlsClientError", onTLSClientError); + } traceEvents ??= require("internal/trace_event_recorder"); - const ResponseClass = this[optionsSymbol].ServerResponse || ServerResponse; - const RequestClass = this[optionsSymbol].IncomingMessage || IncomingMessage; + if (!this[kConstructed]) throw $ERR_INVALID_STATE("The server's constructor threw"); + const ResponseClass = this[kServerResponse]!; + const RequestClass = this[kIncomingMessage]!; const canUseInternalAssignSocket = ResponseClass?.prototype.assignSocket === ServerResponse.prototype.assignSocket; let server = this; - let listenerGeneration: ListenerGeneration | undefined; const usesOwnClasses = ResponseClass === ServerResponse && RequestClass === IncomingMessageOfLibrary && @@ -1174,11 +1424,13 @@ Server.prototype[kRealListen] = function ( this[kUniqueHeaders] == null && !this[kOptimizeEmptyRequests]; + // An https.Server is no heir of Server. + const isServerOfOwnClass = !usesOwnClasses || isHeirOfServer(this); + if (tls) { this.serverName = tls.serverName || host || "localhost"; } const dispatchInFull = function onNodeHTTPRequest( - bunServer: $Node.NativeHTTPServer, url: string, method: string, // Native dispatch bitfield (kDispatch* in NodeHTTP.cpp): presence and token bits for the headers the dispatcher consults, so no header object or array is materialized unless user code reads them. @@ -1186,22 +1438,16 @@ Server.prototype[kRealListen] = function ( handle: $Node.NodeHTTPResponse, hasBody: boolean, socketHandle: $Node.NodeHTTPServerSocket, - isSocketNew: boolean, - socket: $Node.HttpServerSocket | null | undefined, + socket: $Node.HttpServerSocket, isAncientHTTP: boolean, connectHead?: Buffer, isPipelinedDispatch?: boolean, ) { - if (!socket) { - socket = new (getNodeHTTPServerSocket())(server, socketHandle, !!tls, listenerGeneration); - } - if (isPipelinedDispatch) { - // The native handle holds this request's turn until its response exists, also when a throw prevents that. - socket.$hasWaitingResponses = true; - pipelinedResponsesOf(socket).push(handle); - // For a throw before the kick below. drainMicrotasks() further down runs this one when nothing throws. - kickPipelineIfIdle(server, socket); + // Holds this request's turn until its response exists, also when a throw prevents that. + connectionState(socket).outgoing.push(kNoResponse); + // For such a throw when no response is in flight whose 'finish' would come back to the queue. + if (socket._httpMessage == null) process.nextTick(sendNextResponseIfIdle, socket); } // Like Node.js's resetSocketTimeout (parserOnIncoming): a new request @@ -1246,8 +1492,7 @@ Server.prototype[kRealListen] = function ( // can re-enter the dispatcher. // HTTP/1.0 (ancient) responses never advertise keep-alive on this // server; otherwise honor a Connection: close token from the request. - const requestAllowsKeepAlive = isAncientHTTP ? false : (dispatchBits & DispatchBits.conn_close) === 0; - http_req[kReqShouldKeepAlive] = requestAllowsKeepAlive; + http_req[kReqShouldKeepAlive] = isAncientHTTP ? false : (dispatchBits & DispatchBits.conn_close) === 0; if (server.joinDuplicateHeaders) { http_req.joinDuplicateHeaders = true; } @@ -1261,53 +1506,68 @@ Server.prototype[kRealListen] = function ( } const isPipelined = !!isPipelinedDispatch; - // Pipelined or not, like Node.js: the native parser is in tunnel mode from this request on. + // Pipelined or not, like Node.js: nothing behind this request is parsed. if (method === "CONNECT") { - // Handle CONNECT method for HTTP tunneling/proxy if (server.listenerCount("connect") > 0) { // For CONNECT, emit the event and let the handler respond // Don't assign the socket to a response for CONNECT // The handler should write the raw response - socket[kEnableStreaming](true); - // The connection already parses as a CONNECT tunnel natively; this - // additionally marks it half-open-capable so a client FIN ends the - // readable side without tearing the tunnel down (allowHalfOpen). - socketHandle.upgradeToTunnel(false, handle); - socket[kHandoffResponse] = handle; - // The parser is detached: the socket is handed over with only - // net.Socket's 'end' listener left, like Node.js. - 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. + // Node.js's parserOnIncoming: req.upgrade is true for CONNECT regardless of shouldUpgradeCallback. http_req.upgrade = true; - // Node frees the parser before handing the raw socket to 'connect'. - releaseServerParserShim(socket, http_req); // llhttp completes a CONNECT request at the end of its head, before Node emits 'connect'. http_req.complete = true; + handOffSocket(socket, http_req, isPipelined); + if (isPipelined) { + const { outgoing } = connectionState(socket); + outgoing[outgoing.length - 1] = { handedOff: handle, destroyed: false, destroy() {} }; + } try { server.emit("connect", http_req, socket, head); } catch (err) { // Like 'upgrade' below. Native would end the pending response, here the one ahead. process.nextTick(rethrowUncaught, err); } - // Native would tie a returned promise to the socket's current response, the one in flight. - if (isPipelined) return; - // Attach the internal close listener after the user's "connect" - // 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)); - return promise; + return; } else { // Node.js will close the socket and will NOT respond with 400 Bad Request socketHandle.close(); } return; } - if (socket[kStreamingEnabled] !== false) socket[kEnableStreaming](false); + + if (isPipelined) { + // A response is still owed on this connection. This one waits for its turn on Node's path, which can hold what it writes. + handle.onabort = socket[kBoundOnAbort] ??= onServerRequestEvent.bind(socket); + if (hasBody) handle.ondata = onDataIncomingMessage.bind(http_req); + const state = connectionState(socket); + state.outgoing.pop(); // the turn held above + drainMicrotasks(); + const waiting = state.outgoing.length; + let completed = false; + try { + parserOnIncoming(server, socket, state, http_req, http_req[kReqShouldKeepAlive]!, dispatchBits); + completed = true; + } finally { + const res = (socket._httpMessage?.req === http_req ? socket._httpMessage : state.outgoing[waiting]) as + $Node.ServerResponse | undefined; + if (completed) { + // As for a request that didn't have to wait, the connection closes behind the 503. + const limit = server.maxRequestsPerSocket!; + if (limit > 0 && socket.requestsCount! > limit) res!._last = true; + } else if (res === undefined) { + state.outgoing.push(kNoResponse); + if (socket._httpMessage == null) process.nextTick(sendNextResponseIfIdle, socket); + } else if (res !== socket._httpMessage) { + // It threw. Bun answers that with a close, at the response's turn unless it's complete by then. + res[kDispatchThrew] = true; + } else if (!res.finished) { + closeBehindLastWrite(socket); + } + } + if (!hasBody) completeWithoutBody(http_req, socket); + return; + } let http_res: $Node.ServerResponse; const makesResponseWithoutOptions = @@ -1316,7 +1576,7 @@ Server.prototype[kRealListen] = function ( IS_COMPILED_AHEAD_OF_TIME && $isObjectPrototypeWithoutAddedNames() && // As the short dispatcher asks. On a no the response is made as the other text makes it, line for line. - $isPlainHeirOf(server, Server) && + (!isServerOfOwnClass || $isPlainHeirOf(server, Server)) && $isPrototypeChainPlain(ServerResponse) && requestChainIsPlain(); if (makesResponseWithoutOptions) { @@ -1326,16 +1586,17 @@ Server.prototype[kRealListen] = function ( handle, socket, socket.writableHighWaterMark ?? getDefaultHighWaterMark(), - server.rejectNonStandardBodyWrites, + server.rejectNonStandardBodyWrites ?? false, server.keepAliveTimeout, undefined, + !http_req[kReqShouldKeepAlive], ); http_res[kSocket] = null; } else { http_res = new ResponseClass(http_req, { [kHandle]: handle, highWaterMark: socket.writableHighWaterMark, - [kRejectNonStandardBodyWrites]: server.rejectNonStandardBodyWrites, + rejectNonStandardBodyWrites: server.rejectNonStandardBodyWrites, }); http_res._keepAliveTimeout = server.keepAliveTimeout; if (http_res.$exchange === undefined) adoptResponse(http_res); @@ -1357,28 +1618,12 @@ Server.prototype[kRealListen] = function ( // ended inside user 'finish' listeners. (Not done for the // maxRequestsPerSocket limit - pipelined requests past the limit // still need to be answered with 503.) - if (!requestAllowsKeepAlive) { - http_res[kMustCloseConnection] = true; - http_res.$exchange.flags |= ExchangeFlags.must_close; - } // One plain on() listener (once() allocates a wrapper, a second listener // deoptimizes every 'finish' emit), registered before the 'request' event // like Node's resOnFinish so res.on-replacing middleware cannot swallow it. - if (!makesResponseWithoutOptions) http_res.on("finish", emitResponseFinish); - - if (hasObserver("http")) { - startPerf(http_res, kServerResponseStatistics, { - type: "http", - name: "HttpRequest", - detail: { - req: { - method: http_req.method, - url: http_req.url, - headers: http_req.headers, - }, - }, - }); - http_res.once("finish", stopServerResponsePerf); + if (!makesResponseWithoutOptions) { + if (!http_req[kReqShouldKeepAlive]) http_res._last = true; + http_res.on("finish", emitResponseFinish); } handle.onabort = socket[kBoundOnAbort] ??= onServerRequestEvent.bind(socket); @@ -1386,28 +1631,17 @@ Server.prototype[kRealListen] = function ( // the IncomingMessage as they arrive, and the push callback readStop()s // the socket (which emits 'pause' on it) once the buffer fills. if (hasBody) { - handle.ondata = onBodyData.bind(http_req); + handle.ondata = onDataIncomingMessage.bind(http_req); } drainMicrotasks(); - if (isSocketNew) { - server.emit("connection", socket); - } - - // Target of the socket 'timeout' handler until this request's response detaches. - socket[kRequest] = http_req; // Node.js (llhttp) only flags a request as an upgrade when it carries // both an Upgrade header and a Connection header with the "upgrade" // token; the server then consults shouldUpgradeCallback (default: an // 'upgrade' listener is installed) and otherwise dispatches the // request normally. - // Pipelined requests never upgrade, because a queued native response can't. let is_upgrade = false; - if ( - !isPipelined && - (dispatchBits & DispatchBits.has_upgrade) !== 0 && - (dispatchBits & DispatchBits.conn_upgrade) !== 0 - ) { + if ((dispatchBits & DispatchBits.has_upgrade) !== 0 && (dispatchBits & DispatchBits.conn_upgrade) !== 0) { // Like Node.js, shouldUpgradeCallback sees req.upgrade === true. http_req.upgrade = true; is_upgrade = !!server.shouldUpgradeCallback(http_req); @@ -1416,24 +1650,7 @@ Server.prototype[kRealListen] = function ( // 'upgrade' listener and false for a declined upgrade that falls // through to 'request'. http_req.upgrade = is_upgrade; - if (isPipelined) { - // A previous response on this connection has not finished yet: like - // Node.js, this response is queued (res.socket === null) and its - // writes are buffered until the in-flight response finishes and the - // pipeline assigns it the socket (advanceResponsePipeline). - pipelinedResponsesIfAny(socket)?.pop(); // the turn that the native handle held - queuePipelinedResponse(socket, http_res, !!isAncientHTTP); - // A pipelined dispatch can arrive after the previous response finished and detached - // (bytes still flushing keep it pending), leaving nothing in flight to advance the - // queue. Kick the pipeline once this dispatch settles. - kickPipelineIfIdle(server, socket); - // Node's parserOnIncoming stops reading the connection once the bytes - // queued on responses that do not own the socket yet reach the - // socket's high water mark, so pipelined requests cannot flood it. - if (!socket._paused && (socket[kOutgoingData] ?? 0) >= socket.writableHighWaterMark) { - pausePipelineReads(socket); - } - } else if (!is_upgrade) { + if (!is_upgrade) { // Node.js's connectionListener registers socketOnClose, which frees // the parser - even a manually emitted 'close' stops parsing of any // pipelined requests still in the buffer. @@ -1448,13 +1665,9 @@ Server.prototype[kRealListen] = function ( http_res.assignSocket(socket); } } - if (!isPipelined) { - http_res.$exchange.awaitsClose = true; - http_res[kCloseCallback] = onClose; - } - // Node traces every parsed request before Expect/limit routing - // (parserOnIncoming); upgrades never reach that path. - if (!is_upgrade) traceServerRequestStart(http_res); + http_res.$exchange.awaitsClose = true; + http_res[kCloseCallback] = onClose; + if (!is_upgrade) observeRequest(http_req, http_res); // Like Node.js: with the optimizeEmptyRequests server option, // requests without body headers skip the Readable life cycle (no @@ -1467,71 +1680,24 @@ Server.prototype[kRealListen] = function ( http_req._dumpAndCloseReadable(); } - // Node's parserOnIncoming never counts an accepted upgrade or a missing-Host 400 against maxRequestsPerSocket. - let rejectMissingHost = false; - let reachedRequestsLimit = false; - // Node's parserOnIncoming counts requests and routes Expect for HTTP/1.1 only. - const isHttp11 = isOfHttp11(http_req); - if (!is_upgrade) { - const maxRequestsPerSocket = server.maxRequestsPerSocket; - if ( - server.requireHostHeader && - (dispatchBits & DispatchBits.has_host) === 0 && - http_req.httpVersionMajor === 1 && - http_req.httpVersionMinor! >= 1 - ) { - // The native parser skips its Host check for Upgrade requests; one dispatched normally still needs it. - rejectMissingHost = true; - } else if (isHttp11 && typeof maxRequestsPerSocket === "number" && maxRequestsPerSocket > 0) { - const requestCount = (socket._requestCount || 0) + 1; - socket._requestCount = requestCount; - http_res._maxRequestsPerSocket = maxRequestsPerSocket; - // <= like Node: the response that uses up the limit, and every 503 after it, advertises Connection: close. - http_res.maxRequestsOnConnectionReached = maxRequestsPerSocket <= requestCount; - reachedRequestsLimit = maxRequestsPerSocket < requestCount; - } - } + // Node's parserOnIncoming never counts an accepted upgrade or a missing-Host 400 against maxRequestsPerSocket, and routes HTTP/1.1 only. + const expectation = + is_upgrade || isAncientHTTP ? kNoExpectation : expectationFromBits(server, http_res, socket, dispatchBits); - if (reachedRequestsLimit) { - try { - server.emit("dropRequest", http_req, socket); - http_res.writeHead(503); - http_res.end(); - } catch (error) { - sendWhatIsHeld(http_res); - throw error; - } - if (isPipelined) { - // The 503 is queued behind the in-flight responses; the connection - // closes once it has been written, like Node.js (Connection: close). - http_res[kMustCloseConnection] = true; - http_res.$exchange.flags |= ExchangeFlags.must_close; - } else { - socket.destroy(); - } + if (expectation === kOverRequestsLimit) { + emitRequest(server, socket, http_req, http_res, expectation); + socket.destroy(); } else if (is_upgrade) { - // Hand the raw socket over to the 'upgrade' listener, like Node.js. - // Without a body the message is already complete: bytes that arrived - // after the request head become the upgradeHead and the connection - // switches into CONNECT-style tunnel mode immediately. With a body - // (Node 26 semantics) the body keeps being parsed and delivered - // through req; the connection only switches to tunnel mode once the - // message completes, so the upgradeHead is empty and everything after - // the end of the message reaches the socket as raw data. - socketHandle.upgradeToTunnel(hasBody, handle); - socket[kHandoffResponse] = handle; - socket[kEnableStreaming](true); - detachSocketListenersForHandoff(socket); - // Node frees the parser before emitting 'upgrade' (socket.parser === null there). - releaseServerParserShim(socket, http_req); + // Like Node 26, a body is parsed and delivered through req. The connection leaves HTTP behind it, so the head is empty. if (hasBody) { - socket[kUpgradeIncoming] = http_req; - http_req.once("end", clearUpgradeIncoming.bind(undefined, socket)); - } else { - http_req.complete = true; + socketHandle.leaveHttpAfterBody(); + handle.ondata = onUpgradeBodyData.bind(http_req); + handle.notifyWhenReadParsed(); + return; } + http_req.complete = true; + handOffSocket(socket, http_req, false); const upgradeHead = !hasBody && connectHead ? connectHead : kEmptyBuffer; - socket.bytesRead += upgradeHead.length; let upgradeHandled: boolean; try { upgradeHandled = server.emit("upgrade", http_req, socket, upgradeHead); @@ -1548,52 +1714,12 @@ Server.prototype[kRealListen] = function ( socket.destroy(); return; } - // Like CONNECT: the connection is detached from the HTTP request - // machinery; hold the native callback open until the raw socket - // closes. - const upgradePromise = $newPromise(); - socket.once("close", resolveHandoffPromise.bind(undefined, upgradePromise)); - return upgradePromise; - } else if (rejectMissingHost) { - http_res.writeHead(400, { Connection: "close" }); - http_res.end(); + return; } else { - // Native code ends the response of a dispatch that throws. What the listener has written goes out first. - try { - if (isHttp11 && (dispatchBits & DispatchBits.has_expect) !== 0) { - // Case-insensitive, token-boundary match like Node's - // parserOnIncoming (RFC 7231 5.1.1: expectation values compare - // case-insensitively); computed natively into the bitfield. - if ((dispatchBits & DispatchBits.expect_continue) !== 0) { - if (server.listenerCount("checkContinue") > 0) { - server.emit("checkContinue", http_req, http_res); - } else { - http_res.writeContinue(); - server.emit("request", http_req, http_res); - } - } else if (server.listenerCount("checkExpectation") > 0) { - server.emit("checkExpectation", http_req, http_res); - } else { - http_res.writeHead(417); - http_res.end(); - } - } else { - server.emit("request", http_req, http_res); - } - } catch (error) { - sendWhatIsHeld(http_res); - throw error; - } + emitRequest(server, socket, http_req, http_res, expectation); } - // Node's parserOnMessageComplete: a message without a body is complete once the listener returns. EOF stays lazy (_read). - if (!hasBody) http_req.complete = true; - - if (isPipelined) { - // Completion of a queued response is tracked through the pipeline - // (advanceResponsePipeline) and its native handle, not this dispatch. - return; - } + if (!hasBody) completeWithoutBody(http_req, socket); if (handle.finished || http_res.$exchange.awaitsClose !== true) { handle = undefined!; @@ -1605,9 +1731,7 @@ Server.prototype[kRealListen] = function ( http_res[kDetachedFrom] = socket; http_res.$exchange.flags |= ExchangeFlags.is_detached; http_res.detachSocket(socket); - if (pipelinedResponsesIfAny(socket) !== undefined) { - advanceResponsePipeline(server, socket); - } + sendNextResponseIfAny(socket); return; } @@ -1621,11 +1745,12 @@ Server.prototype[kRealListen] = function ( port, hostname: host, unix: socketPath, + fd, reusePort, + ipv6Only, maxRequestBodySize: Number.MAX_SAFE_INTEGER, onNodeHTTPRequest( - bunServer: $Node.NativeHTTPServer, url: string, method: string, // Native dispatch bitfield (kDispatch* in NodeHTTP.cpp): presence and token bits for the headers the dispatcher consults, so no header object or array is materialized unless user code reads them. @@ -1634,32 +1759,30 @@ Server.prototype[kRealListen] = function ( hasBody: boolean, socketHandle: $Node.NodeHTTPServerSocket, isSocketNew: boolean, - socket: $Node.HttpServerSocket | null | undefined, + socket: $Node.HttpServerSocket, isAncientHTTP: boolean, connectHead?: Buffer, isPipelinedDispatch?: boolean, ) { - const ownReadableState = socket != null ? socket.$ownReadableState : undefined; + const ownReadableState = socket.$ownReadableState; if ( - socket != null && usesOwnClasses === true && // The full road makes a request and a response of small objects of options, and looks on them for names that they do not hold. IS_COMPILED_AHEAD_OF_TIME && $isObjectPrototypeWithoutAddedNames() && // In front of every read off the server. Nobody has laid an accessor above a server, a response or a request: // nobody sees what the short road reads, stores or leaves out until the listener runs. - $isPlainHeirOf(server, Server) && + (!isServerOfOwnClass || $isPlainHeirOf(server, Server)) && $isPrototypeChainPlain(ServerResponse) && requestChainIsPlain() && isAncientHTTP === false && - isSocketNew === false && !socket._httpMessage && socket._readableState === ownReadableState && readsHighWaterMarksUnseen(socket) && isPipelinedDispatch !== true && (dispatchBits & DispatchBits.usual_mask) === DispatchBits.has_host && !server.joinDuplicateHeaders && - !server.maxRequestsPerSocket && + countsNoRequests(server) && method !== "CONNECT" && !hasObserver("http") && !traceEvents.isCategoryGroupEnabled(kHttpTraceCat) && @@ -1667,20 +1790,18 @@ Server.prototype[kRealListen] = function ( ) { // The marks as they are now. The full road refuses what is no mark, and takes the default where there is none. const readableMark = ownReadableState!.highWaterMark; - const writableMark = socket.$ownWritableState.highWaterMark; + const writableMark = socket._writableState.highWaterMark; if (isMarkOfReadable(readableMark) && typeof writableMark === "number") { return onNodeHTTPRequest(server, url, method, handle, socket, hasBody === true, readableMark, writableMark); } } return dispatchInFull( - bunServer, url, method, dispatchBits, handle, hasBody, socketHandle, - isSocketNew, socket, isAncientHTTP, connectHead, @@ -1690,30 +1811,50 @@ Server.prototype[kRealListen] = function ( }); const handle = this[serverSymbol]!; - listenerGeneration = { - isUnix: !!socketPath, + // supertest only closes its server when `_handle` is set. + this._handle = handle; + this[kListenerGeneration] = { + server: this, + isUnix: !!socketPath || (fd !== undefined && handle.address === null), + tls, 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); - - if (this?._unref) { - this[serverSymbol]?.unref?.(); + // Only `bun --hot` sets an id. The next listen() on this address reuses this native server. + if (handle.id !== "") { + const owners = (nativeServerOwners ??= new (require("internal/primordials").SafeWeakMap)()); + const previous = owners.get(handle); + owners.set(handle, this); + if (previous != null) releaseListener(previous); + } + const tlsServer = this[tlsSymbol]; + if (tlsServer) { + for (const { 0: hostname, 1: context } of tlsServer[kSNIContexts]) { + addServerName(handle, hostname, context.context); + } } + if (this._unref) handle.unref(); + // A tick, not a timer, as in node: a timer never fires on its own under jest.useFakeTimers(). - process.nextTick(emitListeningNextTick, this, this[serverSymbol]?.hostname, this[serverSymbol]?.port); + process.nextTick(emitListeningNextTick, this, handle.hostname, handle.port); } }; +// Whether isOverRequestsLimit() would leave the request uncounted. +function countsNoRequests(server: $Node.HttpServer): boolean { + const maxRequestsPerSocket = server.maxRequestsPerSocket; + return typeof maxRequestsPerSocket !== "number" || maxRequestsPerSocket <= 0; +} + function prepareSocket(socket: $Node.HttpServerSocket): boolean { - if (socket[kStreamingEnabled] || socket[kHandedOff] || !socket.$isOpen) return false; - if (socket.parser === null) socket.parser = createServerParserShim(socket); + if (socket[kHandedOff] || socket[kHandle] === undefined) return false; + if (socket.parser == null) socket.parser = createServerParserShim(socket); if (socket[kBoundOnAbort] == null) socket[kBoundOnAbort] = onServerRequestEvent.bind(socket); if (!socket[kStopParsingOnCloseListener]) { - socket[kRequest] = undefined; socket[kStopParsingOnCloseListener] = true; socket.on("close", onSocketCloseStopParsing); } @@ -1739,11 +1880,8 @@ function onNodeHTTPRequest( ("unlikely"); socket.setTimeout(serverTimeout); } else socket.timeout = 0; - } else if (!(IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(socket, "_unrefTimer"))) { - ("unlikely"); + } else { socket._unrefTimer(); - } else if (socket.$armedTimeout !== 0) { - socket[kSocketTimeoutTimer]?.refresh(); } const req: $Node.IncomingMessage = new IncomingMessageOfLibrary( @@ -1760,6 +1898,7 @@ function onNodeHTTPRequest( let parser = socket.parser; if (parser == null) parser = socket.parser = createServerParserShim(socket); parser.incoming = req; + if (parser[kParserOnTimeout] !== serverParserShimOnTimeout) parser[kParserOnTimeout] = serverParserShimOnTimeout; // The object is asked, not its chain alone: a plain value laid over a name above it is to be read too. const asksRequest = !requestReadsUnseen(req); @@ -1769,16 +1908,15 @@ function onNodeHTTPRequest( handle, socket, writableMark, - server.rejectNonStandardBodyWrites, + server.rejectNonStandardBodyWrites ?? false, server.keepAliveTimeout, asksRequest ? undefined : method !== "HEAD", ); if (hasBody) { - const onData = onBodyData.bind(req); + const onData = onDataIncomingMessage.bind(req); handle.onabort = socket[kBoundOnAbort] ??= onServerRequestEvent.bind(socket); handle.ondata = onData; } else handle.onabort = socket[kBoundOnAbort] ??= onServerRequestEvent.bind(socket); - socket[kRequest] = req; req.upgrade = false; socket._httpMessage = res; socket[kCloseCallback] = onServerResponseClose; @@ -1794,25 +1932,18 @@ function onNodeHTTPRequest( if (emitOfResponse !== responseEmit || !streamsEmitAsEmittersDo) emitOfResponse.$call(res, "socket", socket); } - // The other text asks these here. Nothing hangs on the answers: the server has no limit of requests, and the request has a Host and no Expect. - if (asksRequest) isOfHttp11(req); + // The other text asks this here. Nothing hangs on the answer: the request has a Host. void server.requireHostHeader; - try { - const listener = server.$requestListener; - // Read once: an accessor may give another function at the next read. - const emitOfServer = server.emit; - if (listener !== undefined && emitOfServer === eventEmitterEmit && server._events!.request === listener) - listener.$call(server, req, res); - else if (IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(server, "emit", Server)) - server.emit("request", req, res); - else emitOfServer.$call(server, "request", req, res); - } catch (error) { - if ((exchange.flags & ExchangeFlags.holds_chunk) !== 0) sendHeldChunk(res, exchange); - throw error; - } + const listener = server.$requestListener; + // Read once: an accessor may give another function at the next read. + const emitOfServer = server.emit; + if (listener !== undefined && emitOfServer === eventEmitterEmit && server._events!.request === listener) + listener.$call(server, req, res); + else if (IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(server, "emit", Server)) server.emit("request", req, res); + else emitOfServer.$call(server, "request", req, res); - if (!hasBody) req.complete = true; + if (!hasBody) completeWithoutBody(req, socket); // Read once, where it is read at all: an accessor may give another function at the next read. let detachSocket: $Node.ServerResponse["detachSocket"] | null = null; @@ -1820,9 +1951,12 @@ function onNodeHTTPRequest( (exchange.flags & ExchangeFlags.ended_clean) !== 0 && IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(res, ServerResponse) && + !res.destroyed && ($findsDeclaredMember(res, "detachSocket") || (detachSocket = res.detachSocket) === responseDetachSocket) && reachesSocket(res) && - connectionRemovesListenersUnseen(socket) + !(res[kCorked] > 0) && + connectionRemovesListenersUnseen(socket) && + readsItsEndUnseen(socket) ) { exchange.awaitsClose = false; let takesStores = true; @@ -1842,10 +1976,10 @@ function onNodeHTTPRequest( if (socket._httpMessage === res) { socket[kCloseCallback] = undefined; socket._httpMessage = null; - socket[kRequest] = undefined; } - res[fakeSocketSymbol] = null; + res[fakeSocketSymbol] = undefined; res[kSocket] = null; + if (socket[kConnectionState] !== undefined) sendNextResponseIfAny(socket); return undefined; } } else if ( @@ -1854,7 +1988,7 @@ function onNodeHTTPRequest( (!(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(res, ServerResponse)) || res.finished || res.destroyed || - !socket.$isOpen || + socket[kHandle] === undefined || (exchange.flags & ExchangeFlags.has_stand_in) !== 0) && handle.finished ) @@ -1867,25 +2001,58 @@ function onNodeHTTPRequest( exchange.flags |= ExchangeFlags.is_detached; if (detachSocket !== null) detachSocket.$call(res, socket); else res.detachSocket(socket); + sendNextResponseIfAny(socket); return undefined; } // Pushes the per-server parser/handler options down to the native listener. -// Always sets strict method validation, for node.js compatibility. function applyServerCustomOptions(server: $Node.HttpServer) { const handle = server[serverSymbol]; if (!handle) return; setServerCustomOptions( handle, - server.requireHostHeader, - true, serverLenientFlags(server), - typeof server.maxHeaderSize !== "undefined" ? server.maxHeaderSize : getMaxHTTPHeaderSize(), - onServerClientError.bind(server, server[kListenerGeneration]), - onServerConnection.bind(server, server[kListenerGeneration]), !!server.httpAllowHalfOpen, + typeof server.maxHeaderSize !== "undefined" ? server.maxHeaderSize : getMaxHTTPHeaderSize(), + onServerClientError.bind(server, server[kListenerGeneration]!), ); setServerMaxHeadersCount(handle, nativeMaxHeadersCount(server.maxHeadersCount)); + const SocketClass = server[kListenerGeneration]!.tls ? TLSSocket : NetSocket; + const { 0: handlers, 1: data } = require("node:net").Server.prototype[kForeignListener].$call(server, SocketClass); + // The native side keeps the async context it's called in for every handler. What a socket emits runs in nobody's AsyncLocalStorage store, as in Node. + const frame = AsyncContextFrame.current(); + const socketFrame = AsyncContextFrame.currentGraphFrame(); + AsyncContextFrame.run( + socketFrame, + setBunServerSocketHandlers, + undefined, + handle, + frame === socketFrame ? handlers : withServerEventsIn(frame, handlers), + server[tlsSymbol]?.ALPNProtocols ?? kEmptyBuffer, + data, + ); +} + +// 'connection', 'secureConnection', 'keylog' and the TLS callbacks are the server's: they run in the context of listen(). +function withServerEventsIn(frame: Frame, handlers: Record unknown) | undefined>) { + const table = { ...handlers }; + for (const name of ["open", "handshake", "keylog", "serverName", "alpnCallback"]) { + const handler = handlers[name]; + if (handler) table[name] = (...args: unknown[]) => AsyncContextFrame.run(frame, handler, undefined, ...args); + } + return table; +} + +// Returns true if, under `bun --hot`, the native server now dispatches to another http.Server or was stopped by one. +function lostListener(server: $Node.HttpServer) { + if (nativeServerOwners === undefined) return false; + const owner = nativeServerOwners.get(server[serverSymbol]!); + return owner !== undefined && owner !== server; +} + +// A replaced server keeps its timeout sweep only while it still has connections. +function releaseListener(server: $Node.HttpServer) { + if (server[kTrackedConnections].size === 0) clearInterval(server[kConnectionsCheckingInterval]); } // Resolution: httpValidation > explicit insecureHTTPParser > --insecure-http-parser. Native @@ -1908,7 +2075,7 @@ function httpAllowHalfOpenSet(this: $Node.HttpServer, value: boolean) { const next = !!value; if (previous === next) return; const handle = this[serverSymbol]; - if (handle) setServerAppFlags(handle, this.requireHostHeader, true, serverLenientFlags(this), next); + if (handle) setServerAppFlags(handle, serverLenientFlags(this), next); } // Node.js keeps httpAllowHalfOpen as an own enumerable property of the server. @@ -1951,91 +2118,25 @@ function defineMaxHeadersCount(server: $Node.HttpServer) { }); } -// 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( +// Like Node.js: server.setTimeout only records the per-socket inactivity +// timeout (and an optional 'timeout' listener); it is applied to each +// connection when it is established, via socket.setTimeout. +Server.prototype.setTimeout = function ( this: $Node.HttpServer, - listenerGeneration: ListenerGeneration | undefined, - socketHandle: $Node.NodeHTTPServerSocket, + msecs: number, + callback?: (socket: $Node.HttpSocket) => void, ) { - if (socketHandle.duplex) { - // Already wrapped (shouldn't happen for a brand-new connection). - return; - } - const isTLS = !!this[tlsSymbol]; - const socket = new (getNodeHTTPServerSocket())(this, socketHandle, isTLS, 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 - // above already tracked this socket in the count. - const maxConnections = this.maxConnections; - const tracked = this[kTrackedConnections]; - if (maxConnections != null && (tracked?.size ?? 0) > maxConnections) { - tracked?.delete(socket); - const data = { - localAddress: socket.localAddress, - localPort: socket.localPort || socketHandle.localPort, - localFamily: socket.localFamily, - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - remoteFamily: socket.remoteFamily || "IPv4", - }; - socket.destroy(); - this.emit("drop", data); - return; - } - - // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2338-L2346 - const blockList = this.blockList; - if (blockList) { - const remoteAddress = socket.remoteAddress; - const addressType = isIP(remoteAddress); - if (addressType) { - let blocked = true; - try { - blocked = !!blockList.check(remoteAddress!, `ipv${addressType}`); - } finally { - if (blocked) { - tracked?.delete(socket); - socket.destroy(); - } - } - if (blocked) return; - } - } - - // 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); - } -} - -// Like Node.js: server.setTimeout only records the per-socket inactivity -// timeout (and an optional 'timeout' listener); it is applied to each -// connection when it is established, via socket.setTimeout. -Server.prototype.setTimeout = function ( - this: $Node.HttpServer, - msecs: number, - callback?: (socket: $Node.HttpSocket) => void, -) { - this.timeout = msecs; - if (typeof callback === "function") this.on("timeout", callback); - return this; -}; + this.timeout = msecs; + if (typeof callback === "function") this.on("timeout", callback); + return this; +}; function onServerRequestEvent(this: $Node.HttpServerSocket, event: NodeHTTPResponseAbortEvent) { const socket = this; switch (event) { - case NodeHTTPResponseAbortEvent.abort: { - if (!socket.destroyed) { - socket.destroy(); - } + case NodeHTTPResponseAbortEvent.abort: + // The socket's own handle hears the close right behind this, with how it closed. break; - } case NodeHTTPResponseAbortEvent.timeout: { socket.emit("timeout"); break; @@ -2048,7 +2149,6 @@ enum HttpParserError { HTTP_PARSER_ERROR_INVALID_CHUNKED_ENCODING = 1, HTTP_PARSER_ERROR_INVALID_CONTENT_LENGTH = 2, HTTP_PARSER_ERROR_INVALID_TRANSFER_ENCODING = 3, - HTTP_PARSER_ERROR_MISSING_HOST_HEADER = 4, HTTP_PARSER_ERROR_INVALID_REQUEST = 5, HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE = 6, HTTP_PARSER_ERROR_INVALID_HTTP_VERSION = 7, @@ -2063,6 +2163,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 @@ -2071,29 +2174,12 @@ enum HttpParserError { // and when to destroy the connection. function onServerClientError( this: $Node.HttpServer, - listenerGeneration: ListenerGeneration | undefined, - ssl: boolean, + listenerGeneration: ListenerGeneration, socket: $Node.NodeHTTPServerSocket, errorCode: number, rawPacket: ArrayBuffer, ) { - const self = this; - // A prior request on this keep-alive connection may already have wrapped - // the native handle (the native side returns the existing handle); a second - // wrapper would overwrite its onclose/duplex and strand the first one in - // kTrackedConnections. Reuse it, and only announce genuinely new - // connections - the existing duplex already had its 'connection' event. - const existingDuplex = socket.duplex; - const nodeSocket = existingDuplex ?? new (getNodeHTTPServerSocket())(self, socket, ssl, listenerGeneration); - if (!existingDuplex) { - nodeSocket.parser = createServerParserShim(nodeSocket); - self.emit("connection", nodeSocket); - } - - if (errorCode === HttpParserError.HTTP_PARSER_ERROR_MISSING_HOST_HEADER) { - replyMissingHostHeader(nodeSocket); - return; - } + const nodeSocket = socket.duplex!; let err; switch (errorCode) { @@ -2101,7 +2187,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`"); @@ -2153,61 +2248,117 @@ 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); } -// Node answers an HTTP/1.1 request with no Host header from parserOnIncoming -// (res.writeHead(400, ['Connection', 'close']); res.end()), so no parse error -// reaches socketOnError and 'clientError' never fires for it. -function replyMissingHostHeader(socket: $Node.HttpServerSocket) { - if (!socket.writable) return; - socket.write( - `HTTP/1.1 400 Bad Request\r\nConnection: close\r\nDate: ${new Date().toUTCString()}\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n`, - ); - socket.destroySoon(); -} - -// Raw socket.write() bytes plus the bytes the current response wrote (its body, or a WebSocket's 101). -function handleBytesWritten(handle: $Node.NodeHTTPServerSocket) { - return handle.bytesWritten + (handle.response?.getBytesWritten() ?? 0); -} - -// Like Node.js's net.Socket onReadableStreamEnd: every socket carries one 'end' -// listener. http server connections have allowHalfOpen: true, so it is a no-op, -// but its presence is part of the listener set handed to 'connect'/'upgrade'. -function onReadableStreamEnd() {} - -function clearUpgradeIncoming(socket: $Node.HttpServerSocket) { - socket[kUpgradeIncoming] = undefined; -} - -// Node's UpgradeStream._read: resume the request only while its body is incomplete. -function resumePausedUpgradeIncoming(socket: $Node.HttpSocket) { - const req = socket[kUpgradeIncoming]; - if (req == null) return; - const response = socket[kHandle]?.response; - if (response && (response.hasBody & NodeHTTPBodyReadState.done) !== 0) { - socket[kUpgradeIncoming] = undefined; - } else { - req.resume(); - } -} - // Node.js hands the connection over to 'connect'/'upgrade' listeners with the // connection-listener set removed (onParserExecuteCommon removes its data/end/ // close/drain/error/timeout listeners) and only net.Socket's own 'end' listener // left in place. -function detachSocketListenersForHandoff(socket: $Node.HttpServerSocket) { +const kHandedOffSockets = Symbol("kHandedOffSockets"); +function detachFromHttp(socket: $Node.HttpServerSocket, req: $Node.IncomingMessage) { socket[kHandedOff] = true; + socket.$isPrepared = false; socket.removeListener("error", socketOnError); - socket.removeListener("timeout", onNodeHTTPServerSocketTimeout); - socket.on("end", onReadableStreamEnd); - // Like Node's onParserExecuteCommon: a flowing socket would drop tunnel bytes that arrive before the listener attaches a reader. + socket.removeListener("timeout", socketOnTimeout); + socket.removeListener("close", nativeSocketOnClose); + socket.removeListener("close", onSocketCloseStopParsing); + socket.removeListener("end", nativeSocketOnEnd); + socket.removeListener("pause", onSocketPause); + socket.removeListener("resume", nativeSocketOnResume); + socket.removeListener("data", noopOnData); + socket.removeListener("newListener", unconsumeForListener); + delete (socket as any)._onTimeout; + // Only that function ignores the keep-alive timer, which stays armed while a request is in flight. + if (socket.timeout === 0) socket.setTimeout(0); + delete (socket as any)._final; + delete (socket as any)._destroy; + const server = socket.server as unknown as $Node.HttpServer; + server[kTrackedConnections].delete(socket); + // In Node each socket holds the process itself. Here unref() on the server has always let go of these sockets too. + server[kHandedOffSockets].add(socket); + if (server._unref) socket.unref(); + socket[kHandleClosed] = destroyUnreadSocket; + // Like Node's onParserExecuteCommon: a flowing socket would drop bytes that arrive before the listener attaches a reader. socket.readableFlowing = null; + // A pause of the HTTP phase ends with it. net.Socket has had a read pending since the accept, so no _read() would lift it. + socket._handle?.resume(); + // Node frees the parser before it emits 'upgrade' or 'connect'. + releaseServerParserShim(socket, req); + // A 'close' listener of a WebSocket logs the peer's address, and net.Socket only keeps one that was read while the socket was open. + socket._getpeername(); +} +// A socket whose connection has closed closes too, also with bytes that nobody reads. Node keeps those for a reader. +function destroyUnreadSocket(this: $Node.HttpServerSocket) { + if (this.readableLength > 0 && !this.readableFlowing) this.destroy(); +} +function handOffSocket(socket: $Node.HttpServerSocket, req: $Node.IncomingMessage, isPipelined: boolean) { + detachFromHttp(socket, req); + const http = socket[kHandle]!; + if (isPipelined) { + // Responses ahead of this request are still owed. Until its turn the socket reads and writes through uWS. + http.detachParser(); + } else if (http.leaveHttp(true)) { + socket[kHandle] = undefined; + } +} +// ws.handleUpgrade() while uWS still owns the connection: from a 'request' listener, or before the body of the Upgrade request has ended. +Server.prototype[kLeaveHttp] = function ( + this: $Node.HttpServer, + socket: $Node.HttpServerSocket, + req: $Node.IncomingMessage | null | undefined, +) { + const http = socket[kHandle]; + if (http === undefined) return; + // A framework's own request object stands for the request that is being answered. + if (req![kHandle] === undefined) req = socket.parser?.incoming ?? socket[kUpgradeStream]?.[kIncomingMessage]; + // A request that waits behind other responses doesn't own the connection. + if (req == null || http.response !== req[kHandle]) return; + const res = socket._httpMessage; + // What a paused request left in native code is its body, not the WebSocket's frames. + req[kHandle]!.resume(); + if (socket[kHandle] === undefined) return; + if (!socket[kHandedOff]) detachFromHttp(socket, req); + if (!http.leaveHttp(false)) return; + socket[kHandle] = undefined; + socket[kUpgradeStream] = undefined; + res?.detachSocket(socket); + // A reader that waits for the rest of the body gets 'end', like in Node 25 and older. + if (!req.complete) onDataIncomingMessage.$call(req, undefined, true, NodeHTTPResponseAbortEvent.none as number); +}; +// The body of an accepted Upgrade request. Node parses the whole read before it emits 'upgrade': onConnectionClosed() emits it for a body that came with its head. +function onUpgradeBodyData(this: $Node.IncomingMessage, chunk: Buffer | undefined, isLast: boolean, event: number) { + const socket = this.socket as $Node.HttpServerSocket; + if (event === NodeHTTPResponseAbortEvent.readParsed) { + if (!socket[kHandedOff]) emitUpgrade(socket, this, false); + return; + } + if (isLast && !socket[kHandedOff]) socket[kUpgradeRequest] = this; + onDataIncomingMessage.$call(this, chunk, isLast, event); +} +function emitUpgrade(socket: $Node.HttpServerSocket, req: $Node.IncomingMessage, complete: boolean) { + if (socket.destroyed) return; + const server = socket.server as unknown as $Node.HttpServer; + detachFromHttp(socket, req); + let stream: $Node.Duplex = socket; + if (!complete) { + // Node's socketOnEnd stays on the socket until the body has ended. + socket.on("end", socket.end); + stream = socket[kUpgradeStream] = new UpgradeStream(socket, req); + // The stream destroyer (a `for await` that is left early) clears req.socket. + req.once("close", onUpgradeRequestClose.bind(socket)); + } + try { + if (!server.emit("upgrade", req, stream, kEmptyBuffer)) socket.destroy(); + } catch (err) { + process.nextTick(rethrowUncaught, err); + } } -function resolveHandoffPromise(promise: Promise) { - $resolvePromise(promise, undefined); +function onUpgradeRequestClose(this: $Node.HttpServerSocket) { + this[kUpgradeStream]?.requestBodyCompleted(); } // https://github.com/nodejs/node/blob/v26.3.0/lib/_http_server.js (socketOnError) @@ -2227,10 +2378,11 @@ const requestChunkExtensionsTooLargeResponse = Buffer.from( // event and, when nothing handles it, answer with a raw error response (only // if nothing has been written for the in-flight response yet) and destroy the // connection. -function socketOnError(this: $Node.HttpServerSocket, err: Error) { +function socketOnError(this: $Node.HttpSocket, err: Error) { // 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)) { @@ -2241,7 +2393,7 @@ function socketOnError(this: $Node.HttpServerSocket, err: Error) { // Node checks _headerSent (bytes reached the socket), not headersSent // (writeHead called): after res.writeHead() but before write/end/flush, // no bytes are on the wire yet so the raw error response is still safe. - if (this.writable && (!message || message[headerStateSymbol] !== NodeHTTPHeaderState.sent)) { + if (this.writable && (!message || !message._headerSent)) { let response: Buffer; switch (err?.code) { case "HPE_HEADER_OVERFLOW": @@ -2257,46 +2409,24 @@ function socketOnError(this: $Node.HttpServerSocket, err: Error) { response = badRequestResponse; break; } - // Write through the native handle so the raw error response reaches the - // wire before the destroy below, regardless of the duplex's cork state. - const handle = this[kHandle]; - if (handle && !handle.closed) { - handle.write(response); - } else { - this.write(response); - } + this.write(response); } this.destroy(err); } } function noopOnError() {} -// $armedTimeout says what the timer of a socket is armed with. Whoever looks under the symbol of the timer with a key that is a value may change the timer or put something else there: it says -1, and the timer is asked, until the socket stores another. -function forgetWhatTimerIsArmedWith(this: $Node.HttpServerSocket): void { - if (this.$armedTimeout === undefined) return; - try { - this.$armedTimeout = -1; - } catch { - // It takes no stores: nothing else comes to lie under the symbol either. - } -} - // $isPrepared says that the socket has its listener of 'close'. After a look by value under the symbol that says so too, the next request asks again. function forgetPreparation(this: $Node.HttpServerSocket): void { if (this.$isPrepared !== true) return; try { this.$isPrepared = false; } catch { - // As above. + // It takes no stores: nothing else comes to lie under the symbol either. } } if (IS_COMPILED_AHEAD_OF_TIME) { - $newCppFunction( - "BunCompiledModules.cpp", - "jsFunctionPrepareHoldersBeforeLookupsUnder", - 2, - )(kSocketTimeoutTimer, forgetWhatTimerIsArmedWith); $newCppFunction( "BunCompiledModules.cpp", "jsFunctionPrepareHoldersBeforeLookupsUnder", @@ -2304,789 +2434,860 @@ if (IS_COMPILED_AHEAD_OF_TIME) { )(kStopParsingOnCloseListener, forgetPreparation); } -function isArmedWith(socket: $Node.HttpServerSocket, msecs: number): boolean { - const armedTimeout = socket.$armedTimeout; - if (armedTimeout >= 0) return armedTimeout === msecs; - const timer = socket[kSocketTimeoutTimer]; - return timer !== undefined && timer._idleTimeout === msecs; +// From here to onSocketResume(), Node's connection listener over any Duplex: https://github.com/nodejs/node/blob/v26.5.0/lib/_http_server.js#L763-L1413 +function connectionListenerInternal(server: $Node.HttpServer, socket: $Node.HttpSocket) { + socket.server = server; + + const { timeout } = server; + if (timeout && typeof socket.setTimeout === "function") socket.setTimeout(timeout); + socket.on("timeout", socketOnTimeout); + + const parser: $Node.HTTPParser = parsers.alloc(); + + const lenientFlags = calculateLenientFlags(server.httpValidation, server.insecureHTTPParser); + + // Node makes the list at 'listening'. A server that never listens tracks its connections too. + const connections = (server[kConnections] ??= new ConnectionsList()); + parser.initialize(HTTPParser.REQUEST, {}, server.maxHeaderSize || 0, lenientFlags, connections); + parser.socket = socket; + socket.parser = parser; + + // Propagate headers limit from server instance to parser + const { maxHeadersCount } = server; + if (typeof maxHeadersCount === "number") { + parser.maxHeaderPairs = maxHeadersCount << 1; + } + + const state: ConnectionState = { + onData: null, + onEnd: null, + onClose: null, + onDrain: null, + outgoing: [], + incoming: [], + // About how many bytes the responses in `outgoing` hold. Above the high water mark, the socket and the parser pause. + outgoingData: 0, + onPendingData: undefined, + // Node's parser is an AsyncResource made here, so its callbacks run in this context whatever delivers the bytes. + frame: AsyncContextFrame.current(), + }; + state.onData = socketOnData.bind(undefined, server, socket, parser, state); + state.onEnd = socketOnEnd.bind(undefined, server, socket, parser, state); + state.onClose = socketOnClose.bind(undefined, socket, state); + state.onDrain = socketOnDrain.bind(undefined, socket, state); + socket.on("data", state.onData); + socket.on("error", socketOnError); + socket.on("end", state.onEnd); + socket.on("close", state.onClose); + socket.on("drain", state.onDrain); + parser.onIncoming = parserOnIncoming.bind(undefined, server, socket, state); + + socket.on("resume", onSocketResume); + socket.setEncoding = socketSetEncoding; + + socket._paused = false; + // Only the event loop feeds a socket that reads a descriptor, so no tick can hand the parser more bytes in the middle of a read. + parser._consumed = (socket as any)._handle?.fd >= 0; + // Node's parser reads from the handle, so a pause() by the socket's previous owner doesn't stop it. + socket.resume(); + process.nextTick(readStartAfterAttach, socket); +} + +// Nor does a pause() by a 'connection' listener behind ours: in Node the 'resume' event of that call starts the handle's reads. Bytes that were put back may have reached a listener by now, whose pause() stays: 'request' (incoming is set), 'upgrade' or 'connect' (no parser). +function readStartAfterAttach(socket: $Node.HttpSocket) { + if (socket.parser?.incoming === null) readStart(socket); } -function onSocketTimeoutTimerExpired(socket: $Node.HttpServerSocket) { - // The keep-alive idle timer is left armed across the request to avoid a - // clear + setTimeout cycle per request. A fire while a request is in - // flight (kKeepAliveTimeoutSet was cleared on request arrival) is stale; - // re-arm the timer so the response-finish fast path, which relies on it - // staying live, keeps working, and bail. This runs at most once per idle - // interval per busy connection, not once per request. - if (socket.timeout === 0 && !socket[kKeepAliveTimeoutSet]) { - socket[kSocketTimeoutTimer]?.refresh(); - return; +function socketSetEncoding(): never { + throw $ERR_HTTP_SOCKET_ENCODING(); +} + +function updateOutgoingData(socket: $Node.HttpSocket, state: ConnectionState, delta: number) { + state.outgoingData += delta; + socketOnDrain(socket, state); +} + +function socketOnDrain(socket: $Node.HttpSocket, state: ConnectionState) { + const needPause = state.outgoingData > socket.writableHighWaterMark; + + // If we previously paused, then start reading again. + if (socket._paused && !needPause) { + socket._paused = false; + socket.parser?.resume(); + socket.resume(); } - // 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: $Node.Timeout = setTimeout(onSocketTimeoutTimerExpired, remaining, socket); - timer.unref(); - socket[kSocketTimeoutTimer] = timer; - socket.$armedTimeout = remaining; - return; - } + + const msg = socket._httpMessage; + // _flushOutput() calls this through updateOutgoingData() with its bytes still in the socket. Then 'drain' waits for the socket's own. + if (msg && !msg.finished && msg[kNeedDrain] && msg.writableLength === 0) { + msg[kNeedDrain] = false; + msg.emit("drain"); } - // A fired keep-alive idle timer is dead; drop the reference so the next - // response-finish re-arms via setTimeout instead of trusting a fired - // timer whose _idleTimeout still matches (a 'timeout' listener may keep - // the socket alive). Only for the keep-alive case: the server.timeout - // path relies on _unrefTimer() refreshing the fired timer in the slot, - // like net.Socket. - if (socket[kKeepAliveTimeoutSet]) { - socket[kSocketTimeoutTimer] = undefined; - socket.$armedTimeout = 0; - } - socket._onTimeout(); -} - -// Extends net.Socket so server connection sockets satisfy Node's class -// contract (`req.socket instanceof net.Socket`); all I/O still goes through -// the native NodeHTTP handle, not a net handle. -let NodeHTTPServerSocket: NodeHTTPServerSocketConstructor | undefined; -interface NodeHTTPServerSocketConstructor { - new ( - server: $Node.HttpServer, - handle: $Node.NodeHTTPServerSocket, - encrypted: boolean, - listenerGeneration: ListenerGeneration | undefined, - ): $Node.HttpServerSocket; - prototype: any; -} -/** What the class below takes from `net.Socket`. Its `server` is an `http.Server`, so it is no `$Node.Socket`. */ -interface NetSocketBase extends $Node.Duplex { - destroySoon(): void; -} -interface NetSocketConstructor { - new (options?: $Node.SocketOptions): NetSocketBase; - prototype: any; -} -/** What the handle can no longer tell once the connection has closed. */ -interface RememberedPeer { - remoteAddress: $Node.SocketAddressInfo | null; - servername: string | null; - peerCertVerified: boolean; - authorizationError: string | null; -} -let serverSocketResume: (() => $Node.HttpServerSocket) | undefined; -let serverSocketSetTimeout: $Node.HttpServerSocket["setTimeout"] | undefined; -// No method equals it. A saved side that is a function or an object is compared by its bits. -const noMethod = {}; -let netSocketResume: Function | object = noMethod; -let netSocketPrototype: any; -let serverSocketPrototype: object | undefined; -// The class extends what `net.Socket` holds when it is made. Where a program has stored another class there, no short road is taken. -function extendStoredNetSocket(ServerSocket: NodeHTTPServerSocketConstructor, StoredNetSocket: NetSocketConstructor) { - // Refuses what no class can extend. - void class extends (StoredNetSocket as any) {}; - ObjectSetPrototypeOf(ServerSocket, StoredNetSocket); - ObjectSetPrototypeOf(ServerSocket.prototype, StoredNetSocket.prototype); - netSocketResume = noMethod; } -function resumeSocketUnlessFlowing(socket: $Node.HttpServerSocket, asksWhetherReadable: boolean) { - if (!readsFlowUnseen(socket)) { - ("unlikely"); - if (!asksWhetherReadable || socket.readable) socket.resume(); - return; +function socketOnClose(socket: $Node.HttpSocket, state: ConnectionState) { + freeParser(socket.parser as $Node.HTTPParser | null | undefined, null, socket); + abortIncoming(state.incoming); + abortOutgoing(state.outgoing); +} + +function abortIncoming(incoming: $Node.IncomingMessage[]) { + while (incoming.length) { + const req = incoming.shift()!; + req.destroy(new ConnResetException("aborted")); } - if ( - !((IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(socket, "resume")) || socket.resume === serverSocketResume) || - netSocketPrototype!.resume !== netSocketResume || - DuplexPrototype.resume !== duplexResume || - socket[kStreamingEnabled] || - (flagsOf(socket._readableState) & (kFlowing | kHasPaused | kPaused)) !== (kFlowing | kHasPaused) || - socket[kUpgradeIncoming] !== undefined - ) { - socket.resume(); +} + +function abortOutgoing(outgoing: ConnectionState["outgoing"]) { + // The responses that waited on one connection share one error. + let error: Error | undefined; + while (outgoing.length) { + const res = outgoing.shift() as $Node.ServerResponse; + res[kFailHeldWrites]?.((error ??= $ERR_STREAM_DESTROYED("write"))); + res.destroy(new ConnResetException("aborted")); } } -function getNodeHTTPServerSocket(): NodeHTTPServerSocketConstructor { - if (NodeHTTPServerSocket) return NodeHTTPServerSocket; - const { Socket: StoredNetSocket }: { Socket: unknown } = require("node:net"); - const NetSocket = require("internal/net/symbols").constructors.Socket as unknown as NetSocketConstructor; - const { getTimerDuration } = require("internal/timers"); - const resumeUnlessFlowing = function (this: $Node.HttpServerSocket, asksWhetherReadable: boolean) { - resumeSocketUnlessFlowing(this, asksWhetherReadable); - }; - NodeHTTPServerSocket = class Socket - extends (require("node:net").Socket as unknown as NetSocketConstructor) - implements $Node.HttpServerSocket - { - bytesRead = 0; - connecting = false; - timeout = 0; - parser: $Node.HttpServerParserShim | null = null; - [kStreamingEnabled] = false; - [kBoundOnAbort]: ((event: number) => void) | null = null; - [kKeepAliveIdleStart]: number | undefined = undefined; - $pipelinedResponses: $Node.PipelinedResponse[] | undefined = undefined; - declare [kKeepAliveTimeoutSet]: boolean | undefined; - declare [kSocketTimeoutTimer]?: $Node.Timeout; - $armedTimeout = 0; - $isOpen = true; - $isPrepared = false; - $heldChunk: $Node.HttpHeldChunk | null = null; - $hasWaitingResponses = false; - $ownReadableState: $Node.ReadableState; - $ownWritableState: $Node.WritableState; - declare $resumeUnlessFlowing?: (this: $Node.HttpServerSocket, asksWhetherReadable: boolean) => void; - declare [kRequest]: $Node.IncomingMessage | undefined; - declare [kCloseCallback]: ((this: $Node.HttpServerSocket) => void) | undefined; - declare [kStopParsingOnCloseListener]: boolean | undefined; - [kSocketBytesWritten] = 0; - [kClosedWithUnsentData]: boolean | undefined = undefined; - [kHandle]: $Node.NodeHTTPServerSocket | null | undefined; - [kUpgradeIncoming]: $Node.IncomingMessage | undefined = undefined; - [kOnReadParsed]: ((socket: $Node.HttpSocket) => void) | undefined = undefined; - [kHandoffResponse]: $Node.NodeHTTPResponse | undefined = undefined; - [kDestroySoon] = false; - [kHandedOff] = false; - server: $Node.HttpServer; - _httpMessage: $Node.ServerResponse | null | undefined = undefined; - _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; - #pendingCallback: $Node.ErrorCallback | null = null; - #pendingAbortMessage: $Node.ServerResponse | null | undefined; - #closeHandled = false; - #peer: RememberedPeer | undefined; - #closingHandle: $Node.NodeHTTPServerSocket | undefined; - #resetSupported: boolean; - #closeError: Error | undefined = undefined; - declare encrypted: boolean; - declare resetAndClosing: boolean; - declare writableBuffer: { chunk: Buffer }[]; - constructor( - server: $Node.HttpServer, - handle: $Node.NodeHTTPServerSocket, - encrypted: boolean, - listenerGeneration: ListenerGeneration | undefined, - ) { - // 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. - super( - server[kHighWaterMark] !== undefined - ? { highWaterMark: server[kHighWaterMark], allowHalfOpen: true } - : { allowHalfOpen: true }, - ); - // net.Socket's constructor wires net-handle machinery this class replaces: - // its 'end' listener installs writeAfterFIN (breaks half-open tunnels), and - // it forces emitClose/decodeStrings off while this class relies on the - // Duplex defaults (destroy() emits 'close'; _write receives decoded chunks). - this.removeAllListeners("end"); - this._writableState.emitClose = true; - this._readableState.emitClose = true; - this._writableState.decodeStrings = true; - this.$ownReadableState = this._readableState; - this.$ownWritableState = this._writableState; - if (IS_COMPILED_AHEAD_OF_TIME) this.$resumeUnlessFlowing = resumeUnlessFlowing; - this.server = server; - this.#resetSupported = !encrypted && !listenerGeneration?.isUnix; - this[kHandle] = handle; - this._secureEstablished = !!handle?.secureEstablished; - handle.onclose = this.#onClose.bind(this); - handle.duplex = this; - - this.encrypted = encrypted; - 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. - this.on("error", socketOnError); - server[kTrackedConnections]?.add(this); - // 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) { - this.setTimeout(serverTimeout); - } - } +function socketOnEnd( + server: $Node.HttpServer, + socket: $Node.HttpSocket, + parser: $Node.HTTPParser, + state: ConnectionState, +) { + const ret = parser.finish(); - get bytesWritten() { - flushSocketHeldChunk(this); - const handle = this[kHandle]; - let bytes = handle ? handleBytesWritten(handle) : this[kSocketBytesWritten]; - // Like net.Socket, include writes still queued in the Writable. - const queued = this.writableBuffer; - for (let i = 0; i < queued.length; i++) bytes += queued[i].chunk.length; - return bytes; - } - set bytesWritten(value) { - this[kSocketBytesWritten] = value; + if (ret instanceof Error) { + // socketOnError has additional logic and will call socket.destroy(err). + socketOnError.$call(socket, ret); + } else if (!server.httpAllowHalfOpen) { + socket.end(); + } else { + const { outgoing } = state; + const { length } = outgoing; + if (length) { + (outgoing[length - 1] as $Node.ServerResponse)._last = true; + } else { + const { _httpMessage } = socket; + if (_httpMessage) _httpMessage._last = true; + else socket.end(); } + } +} - // 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. - [kRememberPeer]() { - const handle = this[kHandle]; - if (!handle) return; - this.#peer = { - remoteAddress: handle.remoteAddress, - servername: handle.servername, - peerCertVerified: handle.peerCertVerified, - authorizationError: handle.authorizationError, - }; - } +function socketOnData( + server: $Node.HttpServer, + socket: $Node.HttpSocket, + parser: $Node.HTTPParser, + state: ConnectionState, + d: Buffer, +) { + $assert(!socket._paused); + AsyncContextFrame.run(state.frame, executeParser, undefined, server, socket, parser, state, d); +} - [kEnableStreaming](enable: boolean) { - // The common keep-alive request never streams: the dispatcher disables - // streaming on every request, so track the state and skip the two native - // setter crossings (writing undefined over undefined) when unchanged. - if (this[kStreamingEnabled] === enable) { - return; - } - const handle = this[kHandle]; - if (handle) { - this[kStreamingEnabled] = enable; - if (enable) { - this.$isPrepared = false; - handle.ondata = this.#onData.bind(this); - handle.ondrain = this.#onDrain.bind(this); - this[kRememberPeer](); - } else { - handle.ondata = undefined; - handle.ondrain = undefined; - } - } - } - #onDrain() { - const callback = this.#pendingCallback; - if (callback) { - this.#pendingCallback = null; - callback(); - } - this.emit("drain"); - } - #onData(chunk: Buffer | undefined, last: boolean) { - 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(); - } - } - if (last) { - const handle = this[kHandle]; - if (handle) { - handle.ondata = undefined; - this[kStreamingEnabled] = false; - // The peer finished its writable side of a CONNECT/Upgrade tunnel. The - // connection stays writable (allowHalfOpen), but - like Node, where the - // detached socket stops reading and no longer keeps the process alive - - // the never-used response for this request must not keep the event loop - // alive either. - // Not handle.response: behind a pipelined CONNECT that is the response in flight. - this[kHandoffResponse]?.unref(); - } +function executeParser( + server: $Node.HttpServer, + socket: $Node.HttpSocket, + parser: $Node.HTTPParser, + state: ConnectionState, + d: Buffer, +) { + const ret = parser.execute(d); + onParserExecuteCommon(server, socket, parser, state, ret, d); +} - this.push(null); - // Like Node's net.Socket (and its UpgradeStream): EOF on a socket with no - // pending data emits 'end' even when nothing is reading the socket. - if (this.readableLength === 0) { - this.resume(); - } - } - } - #closeHandle(handle: $Node.NodeHTTPServerSocket, callback: $Node.ErrorCallback, err?: Error | null) { - this[kSocketBytesWritten] = handleBytesWritten(handle); - this[kHandle] = undefined; - this.$isOpen = false; - // Capture the in-flight response before detachSocket() can clear it: a - // synchronous res.destroy() inside the request handler runs detachSocket() - // between here and the native close delivering #onClose. The abort itself - // is deferred to #onClose so the dispatch promise resolves only after the - // native on_abort has released the pending-request ref. - this.#pendingAbortMessage = this._httpMessage; - this.#closingHandle = handle; - handle.onclose = this.#onCloseForDestroy.bind(this, callback, err, handle); - if (this.resetAndClosing) { - this.resetAndClosing = false; - handle.reset(); - } else { - handle.close(); - } - } - #onClose(closedHandle = this[kHandle]) { - // Once: a queued response's abort can destroy the socket, and run this, before the native close calls it. - if (this.#closeHandled) return; - this.#closeHandled = true; - const errored = this.errored; - // freeParser equivalent: runs before 'close' listeners so they observe the - // released parser (free() invoked, kOnTimeout nulled). - releaseServerParserShim(this); - this[kHandle] = null; - this.$isOpen = false; - if (closedHandle) { - this[kSocketBytesWritten] = handleBytesWritten(closedHandle); - 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) { - this.push(null); - this.read(0); - } - // With no 'error' listener (a socket handed to 'upgrade') the read error would be an uncaught exception. - const closeError = this.listenerCount("error") > 0 ? closedHandle.closeError : undefined; - if (closeError) { - // Node's errnoException(nread, 'read'): "read ECONNRESET". - const er = new ErrnoException(closeError.errno, "read"); - this.#closeError = er.code === closeError.code ? er : closeError; - } - } - const server = this.server; - const tracked = server?.[kTrackedConnections]; - 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)); - } - } - const timer = this[kSocketTimeoutTimer]; - if (timer) { - clearTimeout(timer); - this[kSocketTimeoutTimer] = undefined; - this.$armedTimeout = 0; - } +class UpgradeStream extends Duplex { + declare [kSocket]: $Node.HttpSocket; + declare [kIncomingMessage]: $Node.IncomingMessage | null; - // Node.js's `socketOnClose` → `abortIncoming()` only destroys requests - // that are still in `state.incoming` — i.e. requests whose response has - // not yet finished (`resOnFinish` does `incoming.shift()`). Our - // equivalent of "still in the queue" is `_httpMessage` being non-null: - // `detachSocket()` (called from `res.end()` / on `"finish"`) clears it. - // Do NOT fall back to `this[kRequest]` here — `_httpMessage` is the - // canonical "response still attached" indicator. (`detachSocket()` now - // clears `kRequest` alongside `_httpMessage`, so the two agree after a - // finished response; historically `kRequest` was never cleared and the - // fallback aborted the request on every keep-alive close even after a - // fully successful response, racing `req._dump()`'s nextTick into a - // spurious `"aborted"` — seen as flakes in the express `res.sendFile` - // suite where supertest closes the socket right after reading the body.) - // - // Gate on `!req.destroyed` rather than `!req.complete`: a body-less GET - // flips `complete` before the response is written, so an aborted - // connection would otherwise never reach `req.destroy()` → - // `emit("close")` (test-http-should-emit-close-when-connection-is-aborted). - // - // `#pendingAbortMessage` is the `_httpMessage` captured when the destroy - // was initiated from JS (`#closeHandle`). It is only honoured when the - // captured response was itself destroy()ed: a `res.end()` followed by a - // JS-initiated socket.destroy() also reaches detachSocket() via the - // dispatcher's finished branch, and Node.js does not abort the request - // once the response has finished (resOnFinish shifts state.incoming). - const pending = this.#pendingAbortMessage; - this.#pendingAbortMessage = undefined; - const message = this._httpMessage ?? (pending?.destroyed ? pending : undefined); - const writeFailure = errored ?? $ERR_STREAM_DESTROYED("write"); - if (message) { - failPendingWriteCallbacks(message, writeFailure); - } - // An ended response that was still draining finishes first, like Node.js's failed last write: its request is not aborted. - const wasDraining = message != null && message[kPendingFinish] !== undefined; - if (wasDraining) flushPendingFinish.$call(message); - const req = wasDraining ? undefined : message?.req; - - // Node emits the response 'close' from its first socket 'close' listener: a response listener cannot remove a socket listener from the current emission. - if (message && !message._closed) { - this.prependOnceListener("close", emitCloseNT.bind(undefined, message)); - } + constructor(socket: $Node.HttpSocket, req: $Node.IncomingMessage) { + super({ + allowHalfOpen: socket.allowHalfOpen, + }); - if (req && !req.destroyed && !req[kHandle]?.upgraded) { - // At this point the socket is already destroyed; let's avoid UAF - req[kHandle] = undefined; - if (req.listenerCount("error") > 0) { - req.destroy(new ConnResetException("aborted")); - } else { - req.destroy(); - } - } + this[kSocket] = socket; + this[kIncomingMessage] = req; + + // Proxy error, end & closure events immediately. + socket.on("error", err => this.destroy(err)); + + socket.on("close", () => this.destroy()); + this.on("close", () => socket.destroy()); - // Pipelined responses (and their requests) that were still queued behind - // the in-flight response are aborted, like Node.js's socketOnClose - // (abortIncoming + abortOutgoing). - abortQueuedPipelinedResponses(this, writeFailure); - - // Node's server connection socket emits 'close' whenever the TCP - // connection closes, even with no request in flight (this also covers - // tunneled/upgraded sockets, main's kIsTunnel case); reaching here from a - // native close without a JS-initiated destroy must still surface it. - if (!this.destroyed) { - this.destroy(this.#closeError); + socket.on("end", () => { + this.push(null); + + // Like a socket, which emits 'end' with no 'data' listener if it ends with nothing buffered. + if (this.readableLength === 0) { + this.resume(); } - // A tunnel write that waited for a drain fails now. After destroy(), it settles the callbacks of Writable and emits no 'error'. - const pendingCallback = this.#pendingCallback; - if (pendingCallback) { - this.#pendingCallback = null; - pendingCallback(writeFailure); + }); + } + + requestBodyCompleted(upgradeHead?: Buffer) { + this[kIncomingMessage] = null; + + if ((upgradeHead?.length ?? 0) > 0) { + if (!this.push(upgradeHead)) { + this[kSocket].pause(); } } - // The close of a TLS handle waits for ciphertext that write() reported and a stalled peer does not take, but only once. - destroyNow() { - this.destroy(); - const handle = this.#closingHandle; - if (!handle || handle.closed) return; - handle.close(); - // In the middle of its own request a handle only takes note, and closes behind it, for the first time. - if (!handle.closed) setImmediate(closeHandle, handle); - } - #onCloseForDestroy( - closeCallback: $Node.ErrorCallback, - err: Error | null | undefined, - handle: $Node.NodeHTTPServerSocket, - ) { - this.#closingHandle = undefined; - this.#onClose(handle); - // Thread the destroy error through to the streams machinery (like - // Node.js's net.Socket._destroy passing the exception to its callback), - // so socket.destroy(err) emits 'error' before 'close'. - if ($isCallable(closeCallback)) closeCallback(err); - } - _onTimeout() { - const handle = this[kHandle]; - const response = handle?.response; - // If there is a response, and it has pending data, - // we suppress the timeout because a write is in progress. - if (response && response.writableLength! > 0) { - // Re-arm so the timeout still fires once the write actually stalls. - this._unrefTimer(); - return; + this[kSocket].on("data", data => { + if (!this.push(data)) { + this[kSocket].pause(); } - this.emit("timeout"); - } - _unrefTimer() { - // Socket activity: push the inactivity timeout (socket.setTimeout / - // server.timeout / keepAliveTimeout) further out, like Node.js's - // net.Socket._unrefTimer. - this[kSocketTimeoutTimer]?.refresh(); - } + }); + } - address() { - return (this[kHandle] ?? this.#peer)?.remoteAddress || null; - } + _read(_size: number) { + // Otherwise a listener would have to read the whole request body before it could read this stream. + this[kIncomingMessage]?.resume(); - get bufferSize() { - return this.writableLength; - } + this[kSocket].resume(); + } - connect(_port?: unknown, _host?: unknown, _connectListener?: unknown) { - return this; - } + _final(callback: $Node.ErrorCallback) { + const socket = this[kSocket]; + // The listener can have ended `req.socket` itself, and end() on a finished socket calls back with an error. + if (socket.writableEnded) callback(); + else socket.end(callback); + } - _destroy(err: Error | null, callback: $Node.ErrorCallback) { - flushSocketHeldChunk(this); - // Match net.Socket._destroy: #onClose clears this too, but the native close is async and an already-due timer would fire first. - const timer = this[kSocketTimeoutTimer]; - if (timer) { - clearTimeout(timer); - this[kSocketTimeoutTimer] = undefined; - this.$armedTimeout = 0; - } - const handle = this[kHandle]; - if (!handle) { - if ($isCallable(callback)) callback(err); - return; - } - handle.ondata = undefined; - if (handle.closed) { - const onclose = handle.onclose; - handle.onclose = undefined; - if ($isCallable(onclose)) { - onclose.$call(handle); - } - if ($isCallable(callback)) callback(err ?? this.#closeError); - return; - } + _write(chunk: unknown, encoding: string, callback: $Node.ErrorCallback) { + this[kSocket].write(chunk, encoding, callback); + } - this.#closeHandle(handle, callback, err); - } + _destroy(err: Error | null, callback: $Node.ErrorCallback) { + this[kSocket].destroy(err); + callback(err); + } +} - _final(callback: $Node.ErrorCallback) { - flushSocketHeldChunk(this); - const handle = this[kHandle]; - if (!handle) { - callback(); - return; - } - handle.end(this[kDestroySoon]); - callback(); +function onParserExecuteCommon( + server: $Node.HttpServer, + socket: $Node.HttpSocket, + parser: $Node.HTTPParser | null, + state: ConnectionState, + ret: number | $Node.HTTPParserError | undefined, + d: Buffer, +) { + const req = parser!.incoming; + if (ret instanceof Error) { + prepareError(ret, parser!, d); + socketOnError.$call(socket, ret); + } else if (req?.upgrade) { + // Upgrade or CONNECT + const eventName = req.method === "CONNECT" ? "connect" : "upgrade"; + + let upgradeStream: $Node.Duplex; + if (req.complete) { + socket.removeListener("data", state.onData!); + socket.removeListener("end", state.onEnd!); + // The responses that still wait for their turn learn of the close from it. + if (state.outgoing.length === 0) socket.removeListener("close", state.onClose!); + socket.removeListener("drain", state.onDrain!); + socket.removeListener("error", socketOnError); + socket.removeListener("timeout", socketOnTimeout); + socket.removeListener("resume", onSocketResume); + + parser!.finish(); + freeParser(parser, req, socket); + parser = null; + + upgradeStream = socket; + } else { + // Already emitted, with the body still arriving. + if (socket[kUpgradeStream]) return; + + // The wrapper hides what arrives until the request body has ended. + d = kEmptyBuffer; + upgradeStream = new UpgradeStream(socket, req); + socket[kUpgradeStream] = upgradeStream as UpgradeStream; } - // Not destroy() on 'finish' like net.Socket: 'finish' does not wait for bytes uWS still queues. - destroySoon() { - if (this[kDestroySoon]) return; - const handle = this[kHandle]; - if (this.writable && handle && !handle.closed) { - this[kDestroySoon] = true; - // Native code closes behind the end, and until it has closed only the flags of the response say so. - this.$isOpen = false; - this.end(); - return; + if (server.listenerCount(eventName) > 0) { + const bodyHead = d.slice(ret, d.length); + + if (req.complete && socket[kUpgradeStream]) { + // Previously emitted, now completed - just activate the stream + socket[kUpgradeStream].requestBodyCompleted(bodyHead); + } else { + socket.readableFlowing = null; + server.emit(eventName, req, upgradeStream, bodyHead); } - super.destroySoon(); + } else { + // Got upgrade or CONNECT method, but have no handler. + socket.destroy(); } + } else if (req && req.method === "PRI") { + socket.destroy(); + } - get localAddress() { - return this[kHandle]?.localAddress?.address; - } + if (socket._paused) { + // onIncoming paused the socket, we should pause the parser as well + (socket.parser as $Node.HTTPParser | null | undefined)?.pause(); + } +} - get localFamily() { - return this[kHandle]?.localAddress?.family; +function clearIncoming(this: $Node.IncomingMessage | void, req?: $Node.IncomingMessage, socket?: $Node.HttpSocket) { + req ||= this as $Node.IncomingMessage; + // stream.pipeline() takes the socket off a request that it destroys. + const parser = (socket ?? req.socket)?.parser; + // Reset the .incoming property so that the request object can be gc'ed. + if (parser && parser.incoming === req) { + // A destroyed request gets no 'end'. In Node it stays, and its 'timeout' listeners can keep the idle connection open. + if (req.readableEnded || req.destroyed) { + parser.incoming = null; + } else { + req.on("end", clearIncoming); } + } +} - get localPort() { - return this[kHandle]?.localAddress?.port; - } +function resOnFinish( + req: $Node.IncomingMessage, + res: $Node.ServerResponse, + socket: $Node.HttpSocket, + state: ConnectionState, + server: $Node.HttpServer, +) { + // abortIncoming() may have emptied the array. + $assert(state.incoming.length === 0 || state.incoming[0] === req); - get pending() { - return this.connecting; - } + state.incoming.shift(); - #resumeSocket(readStart: boolean) { - const handle = this[kHandle]; - // A tunnel reads again: response.resume() below does nothing for it. - if (readStart && this[kStreamingEnabled]) handle?.readStart(); - const response = handle?.response; - const upgradeIncoming = this[kUpgradeIncoming]; - if (upgradeIncoming) { - // Upgrade with a body: reading the raw socket resumes the request so its - // body keeps draining (Node's UpgradeStream._read). Request-body bytes - // belong to the request stream, never to the raw upgrade stream, and the - // socket does not end when the request body does. - response?.resume(); - // Not paused: req.complete is false in the listener, so it can wait for 'end' with no reader. - if (upgradeIncoming.readableFlowing !== false) upgradeIncoming.resume(); - // The 'upgrade' listener runs before the rest of its read is parsed; that rest can complete the body. - else if (response?.notifyWhenReadParsed()) this[kOnReadParsed] = resumePausedUpgradeIncoming; - else resumePausedUpgradeIncoming(this); - return; - } - response?.resume(); - } + // Nothing read the body, so pull it off the wire. + if (!req._consuming && !req._readableState.resumeScheduled) req._dump(); - _read(_size: number) { - // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L779-L792 - this.#resumeSocket(true); - } + // uWS learns that the response it counted for this request is out. The handle has nothing of its own to write. + const request = res[kNativeRequest]; + if (request !== undefined && !request.finished) request.end(); + res.detachSocket(socket); + clearIncoming(req, socket); + process.nextTick(emitCloseNT, res); - get readyState() { - if (this.connecting) return "opening"; - if (this.readable) { - return this.writable ? "open" : "readOnly"; - } else { - return this.writable ? "writeOnly" : "closed"; - } + if (res._last) { + closeBehindLastWrite(socket); + } else if (state.outgoing.length === 0) { + const idleTimeout = keepAliveIdleTimeout(server); + if (idleTimeout && typeof socket.setTimeout === "function") { + socket.setTimeout(idleTimeout); + socket[kKeepAliveTimeoutSet] = true; } + } else { + sendNextResponse(socket, state); + } +} - // 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; - } +function closeBehindLastWrite(socket: $Node.HttpSocket) { + if (typeof socket.destroySoon === "function") { + socket.destroySoon(); + } else { + socket.end(); + } +} - // 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; - } +function sendNextResponse(socket: $Node.HttpSocket, state: ConnectionState) { + const m = state.outgoing.shift() as ($Node.ServerResponse & ResponseTurn) | undefined; + // Nothing can fill a destroyed response's slot, so close (Node assigns it). + if (m === undefined) return; + if (m[kDispatchThrew] && !m.finished) m.destroy(); + if (m.destroyed) { + if (typeof socket.destroySoon === "function") socket.destroySoon(); + else socket.destroy(); + } else if (m.handedOff === undefined) giveSocket(m, socket); + // The turn of a 'connect' that came behind responses. It got the rest of its read as `head`, and the socket got every later read. + else if (socket[kHandle]!.startRawResponse(m.handedOff, false) && socket[kHandle]!.leaveHttp(true)) + socket[kHandle] = undefined; +} - get authorizationError() { - if (!this.encrypted) return undefined; - if (!this.server?.[tlsSymbol]?.requestCert) return null; - return (this[kHandle] ?? this.#peer)?.authorizationError ?? null; - } +function giveSocket(res: $Node.ServerResponse, socket: $Node.HttpSocket) { + const request = res[kNativeRequest]; + if (request !== undefined && !socket[kHandle]?.startRawResponse(request, !res.shouldKeepAlive)) + return void res.destroy(); + res.assignSocket(socket); +} - ref() { - return this; - } +function connectionState(socket: $Node.HttpServerSocket) { + let state = socket[kConnectionState]; + if (state === undefined) { + state = socket[kConnectionState] = { outgoing: [], incoming: [], outgoingData: 0, onPendingData: undefined }; + socket.on("drain", socketOnDrain.bind(undefined, socket, state)); + } + return state; +} - get remoteAddress() { - return this.address()?.address; - } +// Longer than the timeout the response advertised, so a client that uses all of it sees fewer ECONNRESETs. 0 if there is none. +function keepAliveIdleTimeout(server: $Node.HttpServer) { + const { keepAliveTimeout, keepAliveTimeoutBuffer } = server; + if (!(Number.isFinite(keepAliveTimeout) && keepAliveTimeout > 0)) return 0; + return ( + keepAliveTimeout + + (Number.isFinite(keepAliveTimeoutBuffer) && keepAliveTimeoutBuffer >= 0 ? keepAliveTimeoutBuffer : 1e3) + ); +} - set remoteAddress(val: string | undefined) { - // initialize the object so that other properties wouldn't be lost - this.address()!.address = val!; - } +function hasBodyHeaders(headers: $Node.IncomingHttpHeaders) { + return "content-length" in headers || "transfer-encoding" in headers; +} - get remotePort() { - return this.address()?.port; - } +function parserOnIncoming( + server: $Node.HttpServer, + socket: $Node.HttpSocket, + state: ConnectionState, + req: $Node.IncomingMessage, + keepAlive: boolean, + dispatchBits?: number, +) { + resetSocketTimeout(server, socket); - set remotePort(val: number | undefined) { - // initialize the object so that other properties wouldn't be lost - this.address()!.port = val!; + if (req.upgrade) { + req.upgrade = req.method === "CONNECT" || !!server.shouldUpgradeCallback(req); + if (req.upgrade) { + return 0; } + } - get remoteFamily() { - return this.address()?.family; - } + state.incoming.push(req); - set remoteFamily(val: string | undefined) { - // initialize the object so that other properties wouldn't be lost - this.address()!.family = val!; + // Stop reading while the client doesn't take the responses, or pipelined requests could flood the connection. + if (!socket._paused) { + const ws = socket._writableState; + if (ws.needDrain || state.outgoingData >= socket.writableHighWaterMark) { + socket._paused = true; + // onParserExecuteCommon() pauses the parser, once execute() is done with this chunk. + socket.pause(); } + } - resetAndDestroy() { - if (this[kHandle]) { - if (!this.#resetSupported) { - throw $ERR_INVALID_HANDLE_TYPE(); - } - this.resetAndClosing = true; - this.destroy(); - } else { - this.destroy($ERR_SOCKET_CLOSED()); + const res: $Node.ServerResponse = new server[kServerResponse]!(req, { + [kHandle]: null, + highWaterMark: socket.writableHighWaterMark, + rejectNonStandardBodyWrites: server.rejectNonStandardBodyWrites, + }); + if (req[kHandle] !== undefined) res[kNativeRequest] = req[kHandle]; + res._keepAliveTimeout = server.keepAliveTimeout; + res._maxRequestsPerSocket = server.maxRequestsPerSocket; + res._onPendingData = state.onPendingData ??= updateOutgoingData.bind(undefined, socket, state); + + res.shouldKeepAlive = keepAlive; + res[kUniqueHeaders] = server[kUniqueHeaders]; + observeRequest(req, res); + + if (server[kOptimizeEmptyRequests] === true && !hasBodyHeaders(req.headers)) { + req._dumpAndCloseReadable(); + (req as any)._read(); + } + + if (socket._httpMessage) { + // There are already pending outgoing res, append. + state.outgoing.push(res); + res[fakeSocketSymbol] = null; + } else { + giveSocket(res, socket); + } + + res.on("finish", resOnFinish.bind(undefined, req, res, socket, state, server)); + + let expectation = kNoExpectation; + if (req.httpVersionMajor === 1 && req.httpVersionMinor === 1) { + // uWS reported what its request has, so req.headers stays unbuilt until the program reads it. + if (dispatchBits === undefined) { + const { host, expect } = req.headers; + dispatchBits = host === undefined ? 0 : DispatchBits.has_host; + if (expect !== undefined) { + dispatchBits |= continueExpression.test(expect as string) + ? DispatchBits.has_expect | DispatchBits.expect_continue + : DispatchBits.has_expect; } - return this; } + expectation = expectationFromBits(server, res, socket, dispatchBits); + } + emitRequest(server, socket, req, res, expectation); - setKeepAlive(_enable = false, _initialDelay = 0) { - return this; - } + return 0; // No special treatment. +} - setNoDelay(_noDelay = true) { - return this; - } +const kNoExpectation = 0; +const kMissingHost = 1; +const kOverRequestsLimit = 2; +const kExpectContinue = 3; +const kExpectOther = 4; - // Like Node.js's net.Socket#setTimeout (setStreamTimeout): an unref'd - // inactivity timer that emits 'timeout' on this socket. server.setTimeout, - // server.keepAliveTimeout, req.setTimeout and res.setTimeout all funnel here. - setTimeout(msecs: number, callback?: () => void) { - if (this.destroyed) { - return this; - } +function expectationFromBits( + server: $Node.HttpServer, + res: $Node.ServerResponse, + socket: $Node.HttpSocket, + dispatchBits: number, +) { + if (server.requireHostHeader && (dispatchBits & DispatchBits.has_host) === 0) return kMissingHost; + if (isOverRequestsLimit(server, res, socket)) return kOverRequestsLimit; + if ((dispatchBits & DispatchBits.has_expect) === 0) return kNoExpectation; + return (dispatchBits & DispatchBits.expect_continue) !== 0 ? kExpectContinue : kExpectOther; +} - msecs = getTimerDuration(msecs, "msecs"); - // Assigned after validation (unlike setStreamTimeout): onSocketTimeoutTimerExpired reads this.timeout. - this.timeout = msecs; +// Counts an HTTP/1.1 request against server.maxRequestsPerSocket. +function isOverRequestsLimit(server: $Node.HttpServer, res: $Node.ServerResponse, socket: $Node.HttpSocket) { + const { maxRequestsPerSocket } = server; + if (typeof maxRequestsPerSocket !== "number" || maxRequestsPerSocket <= 0) return false; + const requestsCount = (socket.requestsCount = (socket.requestsCount! | 0) + 1); + res._maxRequestsPerSocket = maxRequestsPerSocket; + res.maxRequestsOnConnectionReached = maxRequestsPerSocket <= requestsCount; + return maxRequestsPerSocket < requestsCount; +} - const existingTimer = this[kSocketTimeoutTimer]; - if (msecs === 0) { - if (existingTimer) { - clearTimeout(existingTimer); - this[kSocketTimeoutTimer] = undefined; - this.$armedTimeout = 0; - } - if (callback !== undefined) { - validateFunction(callback, "callback"); - this.removeListener("timeout", callback); - } +// How Node's parserOnIncoming answers or announces a request, for both parsers. +function emitRequest( + server: $Node.HttpServer, + socket: $Node.HttpSocket, + req: $Node.IncomingMessage, + res: $Node.ServerResponse, + expectation: number, +) { + switch (expectation) { + case kNoExpectation: + server.emit("request", req, res); + break; + case kMissingHost: { + // A header in the dictionary asks the server about leniency, which a server behind a Proxy sees. + const exchange = res.$exchange; + if (exchange !== undefined) spillHeaders(exchange); + res.writeHead(400, ["Connection", "close"]); + res.end(); + break; + } + case kOverRequestsLimit: + server.emit("dropRequest", req, socket); + res.writeHead(503); + res.end(); + break; + case kExpectContinue: + res._expect_continue = true; + if (server.listenerCount("checkContinue") > 0) { + server.emit("checkContinue", req, res); } else { - // The keep-alive cycle re-arms the same idle interval after every - // response: refresh the existing timer instead of clear+allocate. - if (existingTimer && (this.$armedTimeout >= 0 ? this.$armedTimeout : existingTimer._idleTimeout) === msecs) { - existingTimer.refresh(); - } else { - if (existingTimer) clearTimeout(existingTimer); - const timer: $Node.Timeout = setTimeout(onSocketTimeoutTimerExpired, msecs, this); - timer.unref(); - this[kSocketTimeoutTimer] = timer; - this.$armedTimeout = msecs; - } - if (callback !== undefined) { - validateFunction(callback, "callback"); - this.once("timeout", callback); - } + res.writeContinue(); + server.emit("request", req, res); } - return this; - } + break; + default: + if (server.listenerCount("checkExpectation") > 0) { + server.emit("checkExpectation", req, res); + } else { + res.writeHead(417); + res.end(); + } + } +} - setEncoding(_encoding: string): never { - const err = new Error("Changing the socket encoding is not allowed per RFC7230 Section 3."); - err.code = "ERR_HTTP_SOCKET_ENCODING"; - throw err; - } +// Node's parserOnMessageComplete: a message without a body is complete once the listener returns, and the reads go on unless http paused them. EOF stays lazy (_read). +function completeWithoutBody(req: $Node.IncomingMessage, socket: $Node.HttpServerSocket) { + req.complete = true; + if (!socket._paused && socket.readable && socket.isPaused()) socket.resume(); +} - unref() { - return this; - } +function resetSocketTimeout(server: $Node.HttpServer, socket: $Node.HttpSocket) { + if (!socket[kKeepAliveTimeoutSet]) return; - _write(_chunk: $Node.HttpChunk, _encoding: string, _callback: $Node.ErrorCallback) { - flushSocketHeldChunk(this); - const handle = this[kHandle]; - let err; - this._unrefTimer(); - try { - if (handle) { - const flushed = handle.write(_chunk, _encoding); - if (!flushed && handle.ondrain) { - // Streaming mode (CONNECT tunnels): wait for the native drain - // callback before completing the write. - this.#pendingCallback = _callback; - return false; - } - } - } catch (e) { - err = e; - } - if (err) _callback(err); - else _callback(); - } + socket.setTimeout!(server.timeout || 0); + socket[kKeepAliveTimeoutSet] = false; +} - pause() { - const handle = this[kHandle]; - const response = handle?.response; - if (response) { - response.pause(); - } +function onSocketResume(this: $Node.HttpSocket) { + // 'resume' is emitted a tick after the call, and parserOnIncoming() may have paused the socket since. + if (this._paused) this.pause(); +} - return super.pause(); +function onSocketTimeoutTimerExpired(this: $Node.HttpServerSocket) { + const socket = this; + // The keep-alive idle timer stays armed across requests. A fire while a request is in flight is stale: re-arm and bail. + if (socket.timeout === 0 && !socket[kKeepAliveTimeoutSet]) { + socket[kTimeout]?.refresh(); + return; + } + // 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 timer = socket[kTimeout]; + if (timer != null && lastWrite > timer._idleStart) { + timer.refresh(); + timer._idleStart = lastWrite; + return; } + } + NetSocket!.prototype._onTimeout.$call(socket); +} - resume() { - // Like Node's onSocketResume: the pipeline read gate outranks stream flow. - if (this._paused) return this; - // A full buffer keeps the reads of a tunnel stopped: _read() starts them when the reader has made room. - this.#resumeSocket(this.readableLength < this.readableHighWaterMark); - return super.resume(); - } +let NetSocket: $Node.SocketConstructor | undefined; +let TLSSocket: $Node.TLSSocketConstructor | undefined; +let assignablePeerAddress: PropertyDescriptorMap | undefined; +let netSocketPrototype: object | undefined; +// No method equals it. A saved side that is a function or an object is compared by its bits. +const noMethod = {}; +let netSocketResume: Function | object = noMethod; +let netSocketSetTimeout: unknown; +function assignablePeerMember(name: string, member: "address" | "port" | "family"): PropertyDescriptor { + return { + configurable: true, + // As net.Socket's getter, but for an object that got this one by a descriptor and is no socket. + get(this: $Node.Socket) { + const getPeerName = this._getpeername; + return getPeerName === undefined ? undefined : getPeerName.$call(this)[member]; + }, + set(value) { + Object.defineProperty(this, name, { value, writable: true, configurable: true, enumerable: true }); + }, + }; +} +function loadNet(tls: $Node.HttpServerTLSOptions | null) { + if (NetSocket === undefined) { + NetSocket = require("node:net").Socket; + // Where a program has stored another class in `net.Socket`, no socket has this prototype and no short road is taken. + const ownPrototype = netSymbols.constructors.Socket!.prototype; + netSocketPrototype = ownPrototype; + const resumeOfSocket = netSymbols.resumeOfSocket; + netSocketResume = typeof resumeOfSocket === "function" ? resumeOfSocket : noMethod; + netSocketSetTimeout = ownPrototype.setTimeout; + assignablePeerAddress = { + remoteAddress: assignablePeerMember("remoteAddress", "address"), + remotePort: assignablePeerMember("remotePort", "port"), + remoteFamily: assignablePeerMember("remoteFamily", "family"), + }; + } + if (tls) TLSSocket ??= require("node:tls").TLSSocket; +} - get [kInternalSocketData]() { - return this[kHandle]?.response; +function attachConnection(server: $Node.HttpServer, socket: $Node.HttpServerSocket, http: $Node.NodeHTTPServerSocket) { + socket[kHandle] = http; + http.duplex = socket; + http.onclose = onConnectionClosed.bind(http, server[kTrackedConnections]); + if (socket.encrypted) { + // The native SNI dispatch asks the object in the handle's `data`, which is this socket. + socket._SNICallback = server[tlsSymbol]?._SNICallback; + socket.resetAndDestroy = resetAndDestroyNotTCP; + } else { + if (server[kListenerGeneration]?.isUnix) socket.resetAndDestroy = resetAndDestroyNotTCP; + nativeConnectionListener(server, socket); + } +} + +// https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L826-L829 +function resetAndDestroyNotTCP(): never { + throw $ERR_INVALID_HANDLE_TYPE(); +} + +// Node's connectionListenerInternal, for a connection that uWS parses. +function nativeConnectionListener(server: $Node.HttpServer, socket: $Node.HttpServerSocket) { + server[kTrackedConnections].add(socket); + // Keep a shorter timeout that a 'connection' listener armed. It may have fired during the handshake, so restart it. + const serverTimeout = server.timeout; + if (serverTimeout && !(socket.timeout > 0 && socket.timeout < serverTimeout)) socket.setTimeout(serverTimeout); + else socket._unrefTimer(); + socket.on("timeout", socketOnTimeout); + socket.parser = createServerParserShim(socket); + socket.on("error", socketOnError); + socket.on("close", nativeSocketOnClose); + socket.on("end", nativeSocketOnEnd); + // uWS reads for the parser. The listener keeps the socket flowing like Node's, so that 'end' comes at the peer's FIN. + socket.on("data", noopOnData); + socket.on("resume", nativeSocketOnResume); + socket.on("pause", onSocketPause); + socket.on("newListener", unconsumeForListener); + socket.setEncoding = socketSetEncoding; + socket._onTimeout = onSocketTimeoutTimerExpired; + socket._final = nativeSocketFinal; + socket._destroy = nativeSocketDestroy; + socket._paused = false; + // Declared here, so that a socket that a program seals still takes every later store. + socket[kBoundOnAbort] = undefined; + socket[kCloseCallback] = undefined; + socket[kKeepAliveTimeoutSet] = false; + socket[kLastResponseWrite] = undefined; + socket[kFinishedAtClose] = undefined; + socket[kDetachedWhileClosing] = undefined; + // In Node an assignment to one of these throws in strict mode. Here a server's socket has always kept the value. + Object.defineProperties(socket, assignablePeerAddress!); + socket.$isPrepared = false; + socket.$ownReadableState = socket._readableState; + if (IS_COMPILED_AHEAD_OF_TIME) socket.$resumeUnlessFlowing = resumeUnlessFlowing; +} + +// resume(), left out where it would find the socket flowing and change nothing. +function resumeSocketUnlessFlowing(socket: $Node.HttpServerSocket, asksWhetherReadable: boolean) { + if (!readsFlowUnseen(socket)) { + ("unlikely"); + if (!asksWhetherReadable || socket.readable) socket.resume(); + return; + } + if ( + socket.resume !== netSocketResume || + DuplexPrototype.resume !== duplexResume || + !readsItsEndUnseen(socket) || + socket._readableState !== socket.$ownReadableState || + (flagsOf(socket._readableState) & (kFlowing | kHasPaused | kPaused)) !== (kFlowing | kHasPaused) + ) { + socket.resume(); + } +} + +function resumeUnlessFlowing(this: $Node.HttpServerSocket, asksWhetherReadable: boolean) { + resumeSocketUnlessFlowing(this, asksWhetherReadable); +} + +// Node stores functions of its own in socket.on and its like for this: https://github.com/nodejs/node/blob/v26.5.0/lib/_http_server.js#L1432-L1448 +function unconsumeForListener(this: $Node.HttpServerSocket, event: $Node.EventName) { + if (event === "data" || event === "readable") unconsume(this); +} +function noopOnData() {} +// From here on the socket gets every read before the HTTP parser does, like Node's parser.unconsume(). +function unconsume(socket: $Node.HttpServerSocket) { + const parser = socket.parser; + if (parser == null || !parser._consumed) return; + parser._consumed = false; + socket.removeListener("pause", onSocketPause); + socket.removeListener("resume", nativeSocketOnResume); + // Gone once uWS has closed the connection, which is before the socket's 'close'. + socket[kHandle]?.setRawReads(true); +} + +function nativeSocketOnResume(this: $Node.HttpServerSocket) { + if (this._paused) { + this.pause(); + return; + } + this._handle?.resume(); +} +function onSocketPause(this: $Node.HttpServerSocket) { + this._handle?.pause(); +} + +// Without net.Socket's tick: what the response writes after socket.end() is dropped, as in Node, where both share the socket's Writable. +function nativeSocketFinal(this: $Node.HttpServerSocket, callback: $Node.ErrorCallback) { + this._handle?.shutdown(); + callback(); +} + +// net.Socket queues 'close' from here. Node's comes from the handle, so it doesn't run in the AsyncLocalStorage store of the request whose end closes the connection. +function nativeSocketDestroy(this: $Node.HttpServerSocket, err: Error | null, callback: $Node.ErrorCallback) { + const scheduleClose = this[kHandle] !== undefined ? closeWithConnection : undefined; + const frame = AsyncContextFrame.currentGraphFrame(); + AsyncContextFrame.run(frame, NetSocket!.prototype._destroy, this, err, callback, scheduleClose); +} + +// Like in Node, 'close' comes when the connection has closed: behind the read in progress, ahead of what a client in this process does about it. An immediate, if that is sooner. +function closeWithConnection( + emitClose: (socket: $Node.HttpServerSocket, isException: boolean) => void, + socket: $Node.HttpServerSocket, + isException: boolean, +) { + const http = socket[kHandle]!; + const onclose = http.onclose!; + let isOwed = true; + function emit() { + if (!isOwed) return; + isOwed = false; + emitClose(socket, isException); + } + http.onclose = function (this: $Node.NodeHTTPServerSocket, left?: $Node.HttpServerSocket) { + onclose.$call(this, left); + emit(); + }; + setImmediate(emit); +} + +// Node's socketOnEnd. What becomes of the connection after a clean end is uWS's decision, right behind this. +function nativeSocketOnEnd(this: $Node.HttpServerSocket) { + if (!this[kHandle]?.finishParsing()) return; + const err: any = $HPE_INVALID_EOF_STATE("Parse Error: Invalid EOF state"); + err.reason = "Invalid EOF state"; + err.rawPacket = kEmptyBuffer; + socketOnError.$call(this, err); +} + +// uWS closed the connection. A response's writes don't pass through the socket's Writable, so they fail here, where Node's destroy() fails them. +function onConnectionClosed( + this: $Node.NodeHTTPServerSocket, + tracked: Set<$Node.HttpServerSocket>, + left?: $Node.HttpServerSocket, +) { + const http = this; + const socket = left ?? http.duplex!; + if (socket[kHandle] !== http) return; + socket[kHandle] = undefined; + socket.$isPrepared = false; + if (left !== undefined) { + // Not closed: the body of an accepted Upgrade request has ended, and the connection is the socket's from here on. + socket.removeListener("end", socket.end); + const req = socket[kUpgradeRequest]; + if (req !== undefined) { + socket[kUpgradeRequest] = undefined; + emitUpgrade(socket, req, true); } + return; + } + tracked.delete(socket); + const pending = socket[kDetachedWhileClosing]; + const message = socket._httpMessage ?? (pending?.destroyed ? pending : undefined); + if (message) { + failPendingWriteCallbacks(message, socket.errored ?? $ERR_STREAM_DESTROYED("write")); + // An ended response that was still draining finishes first, like Node.js's failed last write: its request is not aborted. + if (message[kPendingFinish] !== undefined) { + flushPendingFinish.$call(message); + socket[kFinishedAtClose] = message; + } + } + if (socket.destroyed || http.closeError) { + // The handle's own close, right behind this, reports the error. + } else if (!http.peerEnded) { + // This side closed it, so there's no EOF to report. + socket.destroy(); + } else if (socket.readableEnded) { + // Node's socketOnEnd. net.Socket leaves a half-open socket whose peer is gone to its owner. + socket.end(); + } else if (socket.readableFlowing) { + socket.once("end", socket.end); + } else { + // Nobody reads the EOF, and the connection is gone. + socket.destroy(); + } + // Handed to 'connect' behind responses that were still owed: its 'close' listener went with the hand-off. + if (socket[kHandedOff]) nativeSocketOnClose.$call(socket); +} - get [kHasQueuedReads]() { - return this[kHandle]?.hasQueuedTunnelReads === true; +// Node's socketOnClose: free the parser, abort what is in flight and what is queued. +function nativeSocketOnClose(this: $Node.HttpServerSocket) { + const socket = this; + releaseServerParserShim(socket); + const pending = socket[kDetachedWhileClosing]; + socket[kDetachedWhileClosing] = undefined; + const message = socket._httpMessage ?? (pending?.destroyed ? pending : undefined); + const req = message != null && socket[kFinishedAtClose] !== message ? message.req : undefined; + if (req && !req.destroyed) { + req[kHandle] = undefined; + if (req.listenerCount("error") > 0) { + req.destroy(new ConnResetException("aborted")); + } else { + req.destroy(); } - }; - Object.defineProperty(NodeHTTPServerSocket, "name", { value: "Socket" }); - serverSocketResume = NodeHTTPServerSocket.prototype.resume; - serverSocketSetTimeout = NodeHTTPServerSocket.prototype.setTimeout; - const resumeOfSocket = require("internal/net/symbols").resumeOfSocket; - netSocketResume = typeof resumeOfSocket === "function" ? resumeOfSocket : noMethod; - netSocketPrototype = NetSocket.prototype; - serverSocketPrototype = NodeHTTPServerSocket.prototype; - if (StoredNetSocket !== NetSocket) { - ("unlikely"); - extendStoredNetSocket(NodeHTTPServerSocket, StoredNetSocket as NetSocketConstructor); } - return NodeHTTPServerSocket; + if (message && !message._closed) emitCloseNT(message); + const state = socket[kConnectionState]; + if (state !== undefined) { + abortIncoming(state.incoming); + abortOutgoing(state.outgoing); + } +} + +// The close of a TLS handle waits for ciphertext that write() reported and a stalled peer does not take, but only once. +function destroyNow(socket: $Node.HttpServerSocket) { + const http = socket[kHandle]; + socket.destroy(); + if (!http || http.closed) return; + http.close(); + // In the middle of its own request a handle only takes note, and closes behind it, for the first time. + if (!http.closed) setImmediate(closeHandle, http); } // Node validates the `Trailer` header inside _storeHeader, after the body framing has @@ -3108,18 +3309,26 @@ function willBeChunked(response: $Node.ServerResponse) { return response.useChunkedEncodingByDefault === true; } +// Returns true if _storeHeader() will frame the body with neither a length nor chunks, so that it ends where the connection does (RFC 9112 6.3). +function bodyEndsAtClose(response: $Node.ServerResponse) { + if (response._hasBody === false) return false; + const outHeaders = response[kOutHeaders]; + if (outHeaders !== null) { + if (outHeaders["content-length"] !== undefined) return false; + const te = outHeaders["transfer-encoding"]; + if (te !== undefined) return !chunkExpression.test(String(te[1])); + if (outHeaders["trailer"] !== undefined) return response._removedTE; + } + return response._removedTE && (response._removedContLen || typeof response._contentLength !== "number"); +} + // A non-chunked message is terminated by the first empty line after the header // fields, so it can carry neither a body nor trailers. function hasInvalidTrailer(response: $Node.ServerResponse, exchange: Exchange | undefined) { - if ( - exchange !== undefined && - (exchange.headerBits & (HeaderBits.trailer | HeaderBits.spilled)) === 0 && - !response._trailer - ) { + if (exchange !== undefined && (exchange.headerBits & (HeaderBits.trailer | HeaderBits.spilled)) === 0) { return false; } if (willBeChunked(response)) return false; - if (response._trailer) return true; const outHeaders = response[kOutHeaders]; return outHeaders !== null && outHeaders["trailer"] !== undefined; } @@ -3133,17 +3342,35 @@ function validateStatusCode(statusCode: number) { return statusCode; } +// What Node's _implicitHeader() checks and derives. write() and end() send the head without a writeHead() call. +function validateImplicitHead(response: $Node.ServerResponse) { + const statusCode = (response.statusCode = validateStatusCode(response.statusCode)); + const statusMessage = response.statusMessage; + if (statusMessage !== undefined && checkInvalidHeaderChar(statusMessage)) throw $ERR_INVALID_CHAR("statusMessage"); + updateHasBody(response, 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: $Node.HttpHeaderValue): $Node.HttpHeaderValue { + if (!$isArray(value)) return value; + const length = value.length; + const copy: (number | string)[] = $newArrayWithSize(length); + for (let i = 0; i < length; i++) $putByValDirect(copy, i, value[i]); + return copy; +} + +// `explicit`: the program called writeHead(). write() and end() send a `Trailer` header as it is, whatever the request's method or version. // `exchange` is there where `response` is entered itself, and not where another object stands in its place. function _writeHead( statusCode: number, reason: string | $Node.HttpHeadersInit | null | undefined, obj: $Node.HttpHeadersInit | null | undefined, response: $Node.ServerResponse, + explicit: boolean, exchange: Exchange | undefined, ) { statusCode = validateStatusCode(statusCode); - let statusMessage: string | undefined; if (exchange === undefined) { ("unlikely"); if (typeof reason === "string") { @@ -3158,7 +3385,7 @@ function _writeHead( // writeHead(statusCode, reasonPhrase[, headers]) // What is looked at is what reads back: the name is no field of a response until it is stored, so an accessor of the program's may lie under it. response.statusMessage = reason; - statusMessage = response.statusMessage!; + const statusMessage = response.statusMessage!; if (checkInvalidHeaderChar(statusMessage)) throw $ERR_INVALID_CHAR("statusMessage"); } else { // writeHead(statusCode[, headers]) @@ -3189,7 +3416,7 @@ function _writeHead( 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) { @@ -3213,7 +3440,7 @@ function _writeHead( for (let n = 0; n < length; n += 2) { k = obj[n]; - if (k) response.appendHeader(k, (obj as string[])[n + 1]); + if (k) response.appendHeader(k, snapshotHeaderValue((obj as string[])[n + 1])); } } } else if (obj) { @@ -3228,25 +3455,19 @@ function _writeHead( if ( IS_COMPILED_AHEAD_OF_TIME && exchange !== undefined && - ((IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(response, "setHeader")) || - response.setHeader === responseSetHeader) + ($findsDeclaredMember(response, "setHeader") || response.setHeader === outgoingSetHeader) ) { - const value = obj[k]; + const value = snapshotHeaderValue(obj[k]); // What setHeader() asks, behind the value and in its order. Where it would only store, the header is stored. - if ( - exchange.response === response && - !isHTTPServerHeaderStateSentOrAssigned(response[headerStateSymbol]) && - outgoingSetHeaderIsOurs() && - !response._header - ) { + if (!response._header && exchange.response === response) { if (exchange.shape.count === 0) storeOnMessageAsFirstHeaderDoes(response, exchange, k); storeHeader(exchange, k, value); - } else responseSetHeader.$call(response, k, value); - } else response.setHeader(k, obj[k]); + } else outgoingSetHeader.$call(response, k, value); + } else response.setHeader(k, snapshotHeaderValue(obj[k])); } } } - if (hasInvalidTrailer(response, exchange)) { + if (explicit && hasInvalidTrailer(response, exchange)) { ("unlikely"); // The message is not chunk-framed, so `Trailer` is the offending header; drop it // so a caller that swallows the throw cannot put it on the wire. @@ -3257,37 +3478,26 @@ function _writeHead( } const enum ExchangeFlags { - tick_queued = 1 << 0, - holds_chunk = 1 << 1, ended_clean = 1 << 2, has_stand_in = 1 << 3, - skips_a_tick = 1 << 4, has_end_callback = 1 << 5, has_pending_callbacks = 1 << 6, - waits_its_turn = 1 << 7, - // The exchange's tick stands for the tick that flushes the accounting of writes. - owes_flush = 1 << 8, finishes_here = 1 << 9, request_waits = 1 << 10, others_wait = 1 << 11, - // end() came while the tick that write() had queued was still to run, so that tick finds a response without a socket. - ended_before_its_tick = 1 << 12, // In the place of ExchangeFlags.ended_clean once assignSocket() has come behind end(): the response finishes by the ticks of the full road. ended_in_steps = 1 << 13, // What lies under the private names of a response lies there alone. Each of these is set wherever the library stores under its names, and stays: where it is clear and nobody else has reached those names, a read would find nothing. took_head = 1 << 14, - must_close = 1 << 15, is_detached = 1 << 16, counts_bytes = 1 << 17, was_implicit_from_end = 1 << 19, // Somebody has reached one of those names with a key that is a value. is_looked_at = 1 << 20, may_have_taken_head = took_head | is_looked_at | has_stand_in, - may_have_to_close = must_close | is_looked_at | has_stand_in, may_be_detached = is_detached | is_looked_at | has_stand_in, may_count_bytes = counts_bytes | is_looked_at | has_stand_in, may_be_implicit_from_end = was_implicit_from_end | is_looked_at | has_stand_in, - bars_a_further_chunk = holds_chunk | has_pending_callbacks | waits_its_turn, } const enum RequestKind { unseen = 0, @@ -3344,6 +3554,7 @@ function ServerResponse( nativeRejectsBodyWrites?: boolean, nativeKeepAliveTimeout?: number, nativeHasBody?: boolean, + nativeIsLast?: boolean, ): void { if (options === kResponseMadeOverHandle) { // In front of every store and every read: a branch amid the stores of a birth makes each of them dearer. @@ -3358,6 +3569,7 @@ function ServerResponse( nativeRejectsBodyWrites, nativeKeepAliveTimeout, nativeHasBody, + nativeIsLast, ); } const events = makeResponseEvents(); @@ -3373,10 +3585,11 @@ function ServerResponse( } this.outputData = []; this.outputSize = 0; + this.writable = true; this.destroyed = false; this._last = false; - this[kChunkedEncoding] = false; - this[kShouldKeepAlive] = true; + this.chunkedEncoding = false; + this.shouldKeepAlive = true; this.maxRequestsOnConnectionReached = false; this._defaultKeepAlive = true; this.useChunkedEncodingByDefault = true; @@ -3406,19 +3619,18 @@ function ServerResponse( this[kRejectNonStandardBodyWrites] = nativeRejectsBodyWrites; this[kLenientValidation] = undefined; this.$exchange = exchange; - this.useChunkedEncodingByDefault = true; this.req = req; this.sendDate = true; this._sent100 = false; - this[headerStateSymbol] = NodeHTTPHeaderState.none; + this._expect_continue = false; this[kPendingCallbacks] = noCallbacks; - this.finished = false; if (nativeHasBody === undefined) takeMethodAndVersion(this, req); else if (!nativeHasBody) this._hasBody = false; this[kHandle] = nativeHandle; this.statusCode = 200; this.statusMessage = undefined; this._keepAliveTimeout = nativeKeepAliveTimeout; + if (nativeIsLast) this._last = true; const on = this.on; if (on !== eventEmitterOn) { ("unlikely"); @@ -3455,10 +3667,11 @@ function ServerResponse( } this.outputData = []; this.outputSize = 0; + this.writable = true; this.destroyed = false; this._last = false; - this[kChunkedEncoding] = false; - this[kShouldKeepAlive] = true; + this.chunkedEncoding = false; + this.shouldKeepAlive = true; this.maxRequestsOnConnectionReached = false; this._defaultKeepAlive = true; this.useChunkedEncodingByDefault = true; @@ -3488,28 +3701,15 @@ function ServerResponse( this[kRejectNonStandardBodyWrites] = options?.rejectNonStandardBodyWrites ?? false; this[kLenientValidation] = undefined; this.$exchange = exchange; - this.useChunkedEncodingByDefault = true; this.req = req; this.sendDate = true; this._sent100 = false; - this[headerStateSymbol] = NodeHTTPHeaderState.none; + this._expect_continue = false; this[kPendingCallbacks] = []; - this.finished = false; - - if (req.method === "HEAD") this._hasBody = false; - if (req.httpVersionMajor! < 1 || req.httpVersionMinor! < 1) { - this.useChunkedEncodingByDefault = chunkExpression.test((req.headers?.te as string | undefined) ?? ""); - this.shouldKeepAlive = false; - } + takeMethodAndVersion(this, req); - if (options) { - this[kHandle] = options[kHandle] || req[kHandle]; - const rejectNonStandardBodyWrites = options[kRejectNonStandardBodyWrites]; - if (rejectNonStandardBodyWrites !== undefined) this[kRejectNonStandardBodyWrites] = rejectNonStandardBodyWrites; - } else { - this[kHandle] = req[kHandle]; - } + this[kHandle] = handleFor(req, options); this.statusCode = 200; this.statusMessage = undefined; } @@ -3537,8 +3737,14 @@ function takeMethodAndVersion(res: $Node.ServerResponse, req: $Node.IncomingMess res.shouldKeepAlive = false; } } -function isOfHttp11(req: $Node.IncomingMessage): boolean { - return req.httpVersionMajor === 1 && req.httpVersionMinor === 1; + +// The native response of a request that 'upgrade' or 'connect' handed over is done. This one writes to the socket it's assigned, like in Node. +function handleFor(req: $Node.IncomingMessage, options: $Node.ServerResponseOptions | undefined) { + let handle = options?.[kHandle]; + if (handle === undefined && (handle = req[kHandle]) && handle.flags & NodeHTTPResponseFlags.closed_or_completed) { + handle = undefined; + } + return handle; } // A class that extends ServerResponse may have an accessor under any of these names, so each store is made, in this order. @@ -3550,35 +3756,18 @@ function initializeStoreByStore( ): void { OutgoingMessage.$call(res, options); - res.useChunkedEncodingByDefault = true; - res.req = req; res.sendDate = true; res._sent100 = false; - res[headerStateSymbol] = NodeHTTPHeaderState.none; + res._expect_continue = false; res[kPendingCallbacks] = []; - res.finished = false; - - if (req.method === "HEAD") res._hasBody = false; - if (req.httpVersionMajor! < 1 || req.httpVersionMinor! < 1) { - res.useChunkedEncodingByDefault = chunkExpression.test((req.headers?.te as string | undefined) ?? ""); - res.shouldKeepAlive = false; - } + takeMethodAndVersion(res, req); - if (options) { - res[kHandle] = options[kHandle] || req[kHandle]; - const rejectNonStandardBodyWrites = options[kRejectNonStandardBodyWrites]; - if (rejectNonStandardBodyWrites !== undefined) { - res[kRejectNonStandardBodyWrites] = rejectNonStandardBodyWrites; - } - } else { - res[kHandle] = req[kHandle]; - } + res[kHandle] = handleFor(req, options); res.statusCode = 200; res.statusMessage = undefined; - res.chunkedEncoding = false; } // The stores of the birth over a handle, in their order, for a response above which something of the program's may stand: the emitter's part asks, and `finish` is heard through on(). function bearAsking( @@ -3590,6 +3779,7 @@ function bearAsking( nativeRejectsBodyWrites: boolean | undefined, nativeKeepAliveTimeout: number | undefined, nativeHasBody: boolean | undefined, + nativeIsLast: boolean | undefined, ): void { const events = makeResponseEvents(); const capturesRejections = EventEmitterPrototype[kCapture]; @@ -3607,10 +3797,11 @@ function bearAsking( } res.outputData = []; res.outputSize = 0; + res.writable = true; res.destroyed = false; res._last = false; - res[kChunkedEncoding] = false; - res[kShouldKeepAlive] = true; + res.chunkedEncoding = false; + res.shouldKeepAlive = true; res.maxRequestsOnConnectionReached = false; res._defaultKeepAlive = true; res.useChunkedEncodingByDefault = true; @@ -3640,19 +3831,18 @@ function bearAsking( res[kRejectNonStandardBodyWrites] = nativeRejectsBodyWrites; res[kLenientValidation] = undefined; res.$exchange = exchange; - res.useChunkedEncodingByDefault = true; res.req = req; res.sendDate = true; res._sent100 = false; - res[headerStateSymbol] = NodeHTTPHeaderState.none; + res._expect_continue = false; res[kPendingCallbacks] = noCallbacks; - res.finished = false; if (nativeHasBody === undefined) takeMethodAndVersion(res, req); else if (!nativeHasBody) res._hasBody = false; res[kHandle] = nativeHandle; res.statusCode = 200; res.statusMessage = undefined; res._keepAliveTimeout = nativeKeepAliveTimeout; + if (nativeIsLast) res._last = true; const on = res.on; on.$call(res, "finish", emitResponseFinish); } @@ -3670,6 +3860,8 @@ function adoptResponse(res: $Node.ServerResponse): void { Object.$defineProperty(ServerResponse, "length", { __proto__: null, value: 2, configurable: true }); // A response whose own statusCode was deleted sends 200, as in Node.js. ServerResponse.prototype.statusCode = 200; +// A connection that closes under the ended response it carries completes it. Node 26.12 emits no 'finish' and fails the end() callback. +ServerResponse.prototype[kFinishesWithoutLastWrite] = true; function isUnheard(res: $Node.ServerResponse, exchange: Exchange): boolean { const events = exchange.events; @@ -3727,6 +3919,10 @@ const enum AutoHeaders { // line, so it is rendered natively with the other auto headers rather than being // pushed into the flat array (which goes out first). transfer_encoding_chunked = 1 << 4, + // Bits 5 and 6, no header line: how the body is framed. 0 leaves it to the native side (Content-Length, or end() against write()). + body_chunked = 1 << 5, + body_until_close = 2 << 5, + body_none = 3 << 5, } // The largest Keep-Alive timeout, in seconds, handed to the native writeHead (it takes a uint32). const kMaxNativeKeepAliveSecs = 0x7fffffff; @@ -3747,13 +3943,12 @@ function takesChunks(req: $Node.IncomingMessage): boolean { } // Walks the headers into `flat`. Answers with the bits of those that frame the response. -function renderHeadersInto(flat: unknown[], res: $Node.ServerResponse, exchange: Exchange | undefined): number { +function renderHeadersInto(flat: unknown[], res: $Node.ServerResponse, entries: unknown[] | null): number { let bits = 0; - const entries = headerEntriesOf(exchange); if (entries !== null) { for (let i = 0; i < entries.length; i += 3) { const value = entries[i + 2] as $Node.HttpHeaderValue; - bits |= renderHeaderInto(flat, res, exchange, entries[i] as string, entries[i + 1] as string, value); + bits |= renderHeaderInto(flat, res, entries[i] as string, entries[i + 1] as string, value); } return bits; } @@ -3763,7 +3958,7 @@ function renderHeadersInto(flat: unknown[], res: $Node.ServerResponse, exchange: // Code of the program may run at each value. A header that it removes ahead of the walk is not written, and one that it adds is not walked. for (const key in headers) { const entry = headers[key]; - bits |= renderHeaderInto(flat, res, exchange, key, entry[0], entry[1]); + bits |= renderHeaderInto(flat, res, key, entry[0], entry[1]); } // The two that frame the body are looked for when the map has been walked. bits &= ~(HeaderBits.transfer_encoding | HeaderBits.content_length); @@ -3775,7 +3970,6 @@ function renderHeadersInto(flat: unknown[], res: $Node.ServerResponse, exchange: function renderHeaderInto( flat: unknown[], res: $Node.ServerResponse, - exchange: Exchange | undefined, key: string, name: string, value: $Node.HttpHeaderValue, @@ -3785,7 +3979,7 @@ function renderHeaderInto( else if (key === "connection") { bit = HeaderBits.connection; if (RE_CONN_CLOSE.test($isArray(value) ? value.join(", ") : String(value))) { - storeMustClose(res, exchange); + res._last = true; } } else if (key === "keep-alive") bit = HeaderBits.keep_alive; else if (key === "transfer-encoding") bit = HeaderBits.transfer_encoding; @@ -3794,16 +3988,25 @@ function renderHeaderInto( return bit; } +function transferEncodingAmong(res: $Node.ServerResponse, entries: unknown[] | null): unknown { + if (entries === null) return res[kOutHeaders]!["transfer-encoding"][1]; + for (let i = 0; i < entries.length; i += 3) { + if (entries[i] === "transfer-encoding") return entries[i + 2]; + } +} + // `isItself`: `res` is the response of `exchange`, and no proxy or heir of it. function renderNativeHeaders(res: $Node.ServerResponse, exchange: Exchange | undefined, isItself: boolean): unknown[] { let autoHeaders = 0; let keepAliveSecs = 0; let bits = isItself ? exchange!.headerBits : HeaderBits.spilled; + let entries: unknown[] | null = null; // The bits of headers in the compact form say which of them frame the response: nobody walks them, unless writing a value runs code of the program. let flat = (bits & HeaderBits.unusual) === 0 ? flatHeadersOf(exchange!) : null; if (flat === null) { flat = []; - bits = renderHeadersInto(flat, res, exchange); + entries = headerEntriesOf(exchange); + bits = renderHeadersInto(flat, res, entries); } // The common answer, asked in the order of what follows. if ( @@ -3813,10 +4016,10 @@ function renderNativeHeaders(res: $Node.ServerResponse, exchange: Exchange | und IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(res, ServerResponse) && res.sendDate === true && + res._expect_continue === false && res._removedConnection === false && res.maxRequestsOnConnectionReached === false && - reachesShouldKeepAlive(res) && - res[kShouldKeepAlive] === true && + res.shouldKeepAlive === true && lastKeepAliveIsNative && res.req?.[kReqShouldKeepAlive] === true && res._keepAliveTimeout === lastKeepAliveTimeout && @@ -3843,7 +4046,7 @@ function renderNativeHeaders(res: $Node.ServerResponse, exchange: Exchange | und const statusCode = statusCodeToSend(res); if (statusCode === 204 || statusCode === 304) { defectiveNoBodyResponse = true; - storeMustClose(res, exchange); + res._last = true; } } @@ -3855,10 +4058,10 @@ function renderNativeHeaders(res: $Node.ServerResponse, exchange: Exchange | und if (res._hasBody === false) { } else if (!chunkedByDefault) { closeDelimited = true; - storeMustClose(res, exchange); + res._last = true; } else if (res._removedTE) { closeDelimited = true; - storeMustClose(res, exchange); + res._last = true; } else if ( res._removedContLen || (!( @@ -3873,14 +4076,26 @@ function renderNativeHeaders(res: $Node.ServerResponse, exchange: Exchange | und forceChunked = true; } else { closeDelimited = true; - storeMustClose(res, exchange); + res._last = true; } } + } else if (hasTransferEncoding && res._hasBody !== false) { + // Node's matchHeader frames the body by the Transfer-Encoding value. + if (chunkExpression.test(String(transferEncodingAmong(res, entries)))) { + autoHeaders |= AutoHeaders.body_chunked; + } else if (!hasContentLength) { + // 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._last = 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 && (bits & HeaderBits.connection) === 0) res.shouldKeepAlive = false; + if (res._removedConnection) { if (res.shouldKeepAlive === false) { - storeMustClose(res, exchange); + res._last = true; } } else if ((bits & HeaderBits.connection) === 0) { if ( @@ -3888,7 +4103,7 @@ function renderNativeHeaders(res: $Node.ServerResponse, exchange: Exchange | und !closeDelimited && canPersist && !res.maxRequestsOnConnectionReached && - (isItself && reachesShouldKeepAlive(res) ? res[kShouldKeepAlive] : res.shouldKeepAlive) !== false && + res.shouldKeepAlive !== false && requestShouldKeepAlive(res.req) ) { const keepAliveTimeout = res._keepAliveTimeout; @@ -3920,22 +4135,19 @@ function renderNativeHeaders(res: $Node.ServerResponse, exchange: Exchange | und } } else { if (res.shouldKeepAlive === false || !canPersist) { - storeMustClose(res, exchange); + res._last = true; } autoHeaders |= AutoHeaders.conn_close; } } if (res._hasBody === false) { - flat = withRoomFor(flat); - flat.push("\u0000", "2"); - } - - if (closeDelimited) { - flat = withRoomFor(flat); - flat.push("\u0000", "1"); + // HEAD, 204, 304, 1xx. The framing headers the user set are still sent, but the response ends at the first empty line (RFC 9112 6.3). + autoHeaders |= AutoHeaders.body_none; + } else if (closeDelimited) { + autoHeaders |= AutoHeaders.body_until_close; } else if (forceChunked) { - autoHeaders |= AutoHeaders.transfer_encoding_chunked; + autoHeaders |= AutoHeaders.transfer_encoding_chunked | AutoHeaders.body_chunked; } renderedAutoHeaders = autoHeaders; renderedKeepAliveSecs = keepAliveSecs; @@ -3975,26 +4187,17 @@ const emitResponseFinish = function emitResponseFinish(this: $Node.ServerRespons if (req && !req._consuming && !isResumeScheduled(req._readableState)) { req._dump(); } - // req.socket is nulled by the stream destroyer (pipeline/compose cleanup); - // the response's own socket (set by assignSocket, cleared only by - // detachSocket) still references the connection then. + // The stream destroyer nulls req.socket (pipeline, compose). The response's own socket still is the connection then. const detachedFrom = this[kDetachedFrom] ?? null; const socket: $Node.HttpServerSocket | null | undefined = (detachedFrom ?? this.req?.socket ?? this.socket) as any; - // Node's clearIncoming: a request that ended before its response did. Any other one is cleared at its EOF. + // Node's clearIncoming: a request that ended before its response did, or that gets no EOF. Any other one is cleared at its EOF. const parser = socket?.parser; - if (parser != null && parser.incoming === req && req.readableEnded) parser.incoming = null; - const server = socket?.server; - onResponseFinishHandleSocket(server, socket, this); - // The dispatcher detached a synchronously-finished response itself; - // advancing the pipeline again here would skip a queued response. + if (parser != null && parser.incoming === req && (req.readableEnded || req.destroyed)) parser.incoming = null; + onResponseFinishHandleSocket(socket?.server as unknown as $Node.HttpServer | null | undefined, socket, this); + // The dispatcher detached a response that finished synchronously. Sending the next one again here would skip one. if (detachedFrom !== null) return; - if (socket != null) { - this.detachSocket(socket); - if (socket.$pipelinedResponses !== undefined || !readsItsEndUnseen(socket)) { - ("unlikely"); - advanceResponsePipeline(server, socket); - } - } + if (socket != null) this.detachSocket(socket); + sendNextResponseIfAny(socket); }; function isResumeScheduled(state: $Node.ReadableState | null | undefined) { @@ -4002,500 +4205,60 @@ function isResumeScheduled(state: $Node.ReadableState | null | undefined) { return state != null && state.resumeScheduled; } -// Runs when a response has finished, like the transport-related half of -// Node.js's resOnFinish: either end the connection (responses that must -// close) or arm the keep-alive idle timeout so an idle kept-alive connection -// is eventually closed. +// After a response that the native handle wrote. +function sendNextResponseIfAny(socket: $Node.HttpServerSocket | null | undefined) { + const state = socket?.[kConnectionState]; + if (state !== undefined && !socket!.writableEnded && !socket!.destroyed) sendNextResponse(socket!, state); +} + +function sendNextResponseIfIdle(socket: $Node.HttpServerSocket) { + if (socket._httpMessage == null) sendNextResponseIfAny(socket); +} + +// Holds the turn of a request that has no response. If none comes, nothing can be sent in its place. +const kNoResponse: ResponseTurn = { destroyed: true, destroy() {} }; + +// The transport half of Node's resOnFinish: end the connection, or arm the keep-alive idle timeout. function onResponseFinishHandleSocket( server: $Node.HttpServer | null | undefined, socket: $Node.HttpServerSocket | null | undefined, res: $Node.ServerResponse, ) { - if (res[kMustCloseConnection]) { - if (socket != null) { - if (typeof socket.destroySoon === "function") { - socket.destroySoon(); - } else { - socket.end(); - } - } + if (res._last) { + socket?.destroySoon(); return; } - if (!socket || socket.destroyed || typeof socket.setTimeout !== "function") { + if (!socket || socket.destroyed) { return; } - // Another response is already in flight on this connection (pipelining): - // it is not idle, so leave its timeout alone. + // Another response is in flight on this connection (pipelining), so it isn't idle. const inFlight = socket._httpMessage; if (inFlight && inFlight !== res) { return; } - // Pipelined responses are still queued behind this one: the connection is - // not idle, so do not arm the keep-alive idle timeout yet. - if (pipelinedResponsesIfAny(socket)?.length) { + // Pipelined responses are queued behind this one, so the connection isn't idle yet. + if (socket[kConnectionState]?.outgoing.length) { return; } - const rawKeepAliveTimeout = server!.keepAliveTimeout; - const keepAliveTimeout = Number.isFinite(rawKeepAliveTimeout) && rawKeepAliveTimeout! >= 0 ? rawKeepAliveTimeout! : 0; - const rawKeepAliveBuffer = server!.keepAliveTimeoutBuffer; - const keepAliveTimeoutBuffer = - Number.isFinite(rawKeepAliveBuffer) && rawKeepAliveBuffer! >= 0 ? rawKeepAliveBuffer! : 1000; - if (keepAliveTimeout) { - // Extend the internal timeout by the configured buffer to reduce - // the likelihood of ECONNRESET errors, like Node.js's resOnFinish. - const total = keepAliveTimeout + keepAliveTimeoutBuffer; - // Rescheduling the socket timer on every response finish was the single - // largest per-request cost of the keep-alive path. Once the timer is - // armed with this exact interval (every response after the first on a - // kept-alive connection), leave it in place and only record when this - // idle period started; onSocketTimeoutTimerExpired grants the remaining - // budget if the timer fires early, so the socket still closes after - // exactly `total` ms of idle. - if (socket.$armedTimeout === total || isArmedWith(socket, total)) { + const total = keepAliveIdleTimeout(server!); + if (total) { + // A timer armed with this interval stays, which saves a reschedule per response. onSocketTimeoutTimerExpired grants what is left of the idle period. + socket[kLastResponseWrite] = timerClockMs(); + const timer = socket[kTimeout]; + if (timer != null && !timer._destroyed && timer._idleTimeout === total) { socket.timeout = total; } else { socket.setTimeout(total); } - socket[kKeepAliveIdleStart] = DateNow(); socket[kKeepAliveTimeoutSet] = true; } } -// 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. +// Node's socketOnClose frees the parser, which stops the parsing of pipelined requests in the buffer, even when a program emitted 'close'. function onSocketCloseStopParsing(this: $Node.HttpServerSocket) { this[kHandle]?.stopParsing?.(); } -// The second half of Node's read gate (lib/_http_server.js): uWS already pauses -// reads when the transport has unsent bytes (Node's `ws.needDrain`); these bytes -// sit in the JS queue and never reach the socket while the response is queued. -function pausePipelineReads(socket: $Node.HttpServerSocket) { - const response = socket[kHandle]?.response; - if (!response) return; - socket._paused = true; - // Not response.pause(): that is request-body flow control and no-ops once the in-flight - // response has ended (always true when the pipeline backs up). pauseReads() pauses the - // connection regardless and native stops consuming already-received pipelined requests. - response.pauseReads(); -} - -// Node's parserOnIncoming read gate, for fallback connections (the native -// sibling is the kOutgoingData check before pausePipelineReads at the -// dispatcher): stop reading when the transport or the bytes buffered on -// queued pipelined responses are backed up, so a pipelining client cannot -// flood the connection's memory. -function maybePauseFallbackReads(socket: $Node.HttpSocket) { - if (socket._paused) return; - if (socket._writableState?.needDrain || (socket[kOutgoingData] ?? 0) >= socket.writableHighWaterMark) { - socket._paused = true; - socket.pause(); - } -} - -// Node's socketOnDrain: the transport drained, so resume reads unless queued -// response bytes still hold the gate. -function resumeFallbackReadsOnDrain(socket: $Node.HttpSocket) { - if (socket._paused && (socket[kOutgoingData] ?? 0) <= socket.writableHighWaterMark) { - socket._paused = false; - socket.resume(); - } -} - -function addPipelineOutgoingData(queued: PipelinedQueuedState, bytes: number) { - const socket = queued.socket; - socket[kOutgoingData] = (socket[kOutgoingData] ?? 0) + bytes; -} - -// Like Node's socketOnDrain: the buffered bytes are handed to the transport when -// the response is flushed, so they stop gating reads (_flushOutput calls -// `_onPendingData(-outputSize)`). -function releasePipelineOutgoingData(socket: $Node.HttpSocket, bytes: number) { - const outgoing = (socket[kOutgoingData] ?? 0) - bytes; - socket[kOutgoingData] = outgoing > 0 ? outgoing : 0; - if (socket._paused && outgoing <= socket.writableHighWaterMark) { - socket._paused = false; - const response = socket[kHandle]?.response; - if (response) { - response.resume(); - } else if (!(NodeHTTPServerSocket && socket instanceof NodeHTTPServerSocket)) { - // Fallback duplex paused by maybePauseFallbackReads: plain stream flow - // control is the only way to restart it. - socket.resume(); - } - } -} - -// Like the tail of Node.js's resOnFinish: when a response finishes and -// pipelined responses are queued behind it, the next one becomes the -// connection's current response, is assigned the socket, and its buffered -// output is flushed. -function kickPipelineIfIdle(server: $Node.HttpServer, socket: $Node.HttpServerSocket) { - if (socket._httpMessage == null && !socket[kPipelineKickScheduled]) { - socket[kPipelineKickScheduled] = true; - queueTick2(advancePipelineIfIdleNT, server, socket); - } -} -function advancePipelineIfIdleNT(server: $Node.HttpServer, socket: $Node.HttpServerSocket) { - socket[kPipelineKickScheduled] = false; - if (socket._httpMessage == null && pipelinedResponsesIfAny(socket)?.length) { - advanceResponsePipeline(server, socket); - } -} - -// Like the dispatcher's pipelined branch and Node.js's parserOnIncoming -// outgoing queue: park the response behind the connection's in-flight one. -// Its write()/end() buffer (kPipelinedQueuedState) until -// advanceResponsePipeline assigns it the socket and replays them. -// The list is read under the symbol alone, as with the other text. Few connections are pipelined on: the private field spares the others the look. -const kPipelinedResponses = Symbol("kPipelinedResponses"); -function pipelinedResponsesIfAny(socket: $Node.HttpSocket): $Node.PipelinedResponse[] | undefined { - return socket.$pipelinedResponses !== undefined ? (socket as any)[kPipelinedResponses] : undefined; -} -function pipelinedResponsesOf(socket: $Node.HttpSocket): $Node.PipelinedResponse[] { - let list = pipelinedResponsesIfAny(socket); - if (list == null) { - list = socket.$pipelinedResponses = []; - storeRarely(socket, kPipelinedResponses, list); - } - return list; -} - -function queuePipelinedResponse(socket: $Node.HttpSocket, res: $Node.ServerResponse, isAncient: boolean) { - res[kPipelinedQueuedState] = { - ops: [], - bytes: 0, - headerBytes: 0, - needDrain: false, - ended: false, - isAncient, - socket, - }; - const exchange = res.$exchange; - if (exchange !== undefined) exchange.flags |= ExchangeFlags.waits_its_turn; - (socket as any).$hasWaitingResponses = true; - pipelinedResponsesOf(socket).push(res); -} - -// Like Node.js's socketOnEnd: the response that gets `_last` after the client's FIN is the last one in the queue. -function lastPipelinedResponse(socket: $Node.HttpSocket) { - const queue = pipelinedResponsesIfAny(socket); - return queue?.[queue.length - 1]; -} - -// When the connection dies with pipelined responses still queued behind the -// in-flight one, abort them and their requests, like Node.js's socketOnClose -// (abortIncoming). Runs from the native socket's close path and from the -// http1 fallback's socket 'close' listener. -function abortQueuedPipelinedResponses(socket: $Node.HttpSocket, error: Error = $ERR_STREAM_DESTROYED("write")) { - const pipelined = pipelinedResponsesIfAny(socket); - const pipelinedLength = pipelined ? pipelined.length : 0; - if (pipelinedLength) { - storeRarely(socket, kPipelinedResponses, undefined); - for (let i = 0; i < pipelinedLength; i++) { - const queuedRes = pipelined![i]; - // A turn that the native handle still holds: the native close path notifies that one. - if (queuedRes[kPipelinedQueuedState] === undefined) continue; - const queuedReq = queuedRes.req; - failQueuedPipelinedWriteCallbacks(queuedRes[kPipelinedQueuedState], error); - if (queuedReq && !queuedReq.destroyed) { - queuedReq[kHandle] = undefined; - if (queuedReq.listenerCount("error") > 0) { - queuedReq.destroy(new ConnResetException("aborted")); - } else { - queuedReq.destroy(); - } - } - if (!queuedRes.destroyed) { - queuedRes.destroy(); - } else if (!queuedRes._closed) { - process.nextTick(emitCloseNT, queuedRes); - } - } - } -} - -// The head of the queue can never be sent: close once the bytes of the responses ahead of it have left. -function closeAfterLastSendableResponse(socket: $Node.HttpSocket) { - if (NodeHTTPServerSocket && socket instanceof NodeHTTPServerSocket) { - socket[kHandle]?.closeWhenDrained(); - } else if (!socket.destroyed) { - socket.destroy(); - } -} - -function failQueuedPipelinedWriteCallbacks(queued: PipelinedQueuedState | undefined, error: Error) { - const ops = queued?.ops; - const length = ops?.length ?? 0; - if (length === 0) return; - queued!.ops = []; - for (let i = 0; i < length; i++) { - const callback = ops![i][3]; - if (typeof callback === "function") process.nextTick(callback, error); - } -} - -function advanceResponsePipeline( - server: $Node.HttpSocketServer | null | undefined, - socket: $Node.HttpSocket | null | undefined, -) { - // The previous response on this connection closed it (Connection: close, - // HTTP/1.0, maxRequestsPerSocket): like Node.js's resOnFinish, advancing - // the pipeline is mutually exclusive with closing the socket - the queued - // responses are aborted by the socket close path instead of being replayed - // onto a half-closed connection. - if (!socket || socket.writableEnded || socket.destroyed) { - return; - } - const queue = pipelinedResponsesIfAny(socket); - if (!queue || queue.length === 0) { - return; - } - // The current response has ended but is still draining; its 'finish' advances the pipeline. - const current = socket[kHandle]?.response; - if ( - current && - (current.flags & (NodeHTTPResponseFlags.ended | NodeHTTPResponseFlags.closed_or_completed)) === - NodeHTTPResponseFlags.ended - ) { - return; - } - const res = queue[0]; - const queued = res[kPipelinedQueuedState]; - if (queued === undefined) { - // The native handle still holds this turn: its dispatch queued no response, and after a throw none can come. - if (((res as $Node.PipelinedTurn).flags & NodeHTTPResponseFlags.dispatch_threw_while_queued) !== 0) { - closeAfterLastSendableResponse(socket); - } - return; - } - const handle = res[kHandle]; - - if ( - res.destroyed || - !handle || - // Its dispatch threw and nothing ended it since: natively only the current response is answered for a throw. - (!queued.ended && (handle.flags & NodeHTTPResponseFlags.dispatch_threw_while_queued) !== 0) - ) { - failQueuedPipelinedWriteCallbacks(queued, res.errored ?? socket.errored ?? $ERR_STREAM_DESTROYED("write")); - // The queued response was destroyed before it could be sent; the - // connection cannot produce a response for this slot, so it is unusable. - // Deliberate divergence from Node v26, which assigns the destroyed - // message and wedges the connection until requestTimeout: an HTTP/1.1 - // connection cannot skip a response slot, so reset it instead. - closeAfterLastSendableResponse(socket); // it stays queued for the close path - return; - } - - queue.shift(); - res[kPipelinedQueuedState] = undefined; - const exchange = res.$exchange; - if (exchange !== undefined) exchange.flags &= ~ExchangeFlags.waits_its_turn; - releasePipelineOutgoingData(socket, queued.bytes); - - if (NodeHTTPServerSocket && socket instanceof NodeHTTPServerSocket) { - const socketHandle = socket[kHandle]; - if ( - !socketHandle || - socket.destroyed || - !socketHandle.startPipelinedResponse(handle, !!queued.isAncient, !requestShouldKeepAlive(res.req)) - ) { - // The connection is already gone; the socket close path destroys queued - // responses, but make sure this (already dequeued) one is not skipped. - failQueuedPipelinedWriteCallbacks(queued, socket.errored ?? $ERR_STREAM_DESTROYED("write")); - if (!res.destroyed) { - res.destroy(); - } - return; - } - - if (res.assignSocket === ServerResponse.prototype.assignSocket) { - assignSocketInternal(res, socket); - } else { - res.assignSocket(socket); - } - } else { - // internal/http1_server_fallback connection (a foreign duplex): the - // response's JS handle writes to the socket itself (nothing to switch - // natively), and the prototype assignSocket installs the 'close' listener - // a plain stream needs. Clear `finished` first, or assignSocket's _flush - // emits 'prefinish' before the ops replay below. - if (queued.ended) { - res.finished = false; - } - res.assignSocket(socket); - } - - // Replay the writes buffered while the response was queued. - // The buffered bytes are handed to the native handle below, so they no - // longer count as pending output (Node's _flush does the same). - res.outputSize = 0; - const ops = queued.ops; - const opsLength = ops.length; - let hitBackpressure = false; - if (opsLength) { - // `finished` was set when the user called end() on the queued response; - // clear it for the replay so the real write()/end() (end re-sets it) do - // not treat the buffered calls as write-after-end. - if (queued.ended) { - res.finished = false; - } - if (exchange !== undefined) exchange.isReplaying = true; - storeRarely(res, kReplayingPipelinedOps, true); - try { - for (let i = 0; i < opsLength; i++) { - const op = ops[i]; - const kind = op[0]; - if (kind === "raw") { - // Buffered 1xx bytes: route through the same AsyncSocket buffer the - // response's own writeHead/end use so they precede the final response. - handle.writeInformational(op[1]!, op[2]); - if (typeof op[3] === "function") process.nextTick(op[3]); - } else if (kind === "write") { - if (ServerResponsePrototypeWrite.$call(res, op[1], op[2], op[3]) === false) hitBackpressure = true; - } else { - ServerResponsePrototypeEnd.$call(res, op[1], op[2], op[3]); - } - } - } finally { - if (exchange !== undefined) exchange.isReplaying = false; - storeRarely(res, kReplayingPipelinedOps, false); - } - } - if (queued.needDrain && !queued.ended) { - // write() reported backpressure while the response was queued: the native drain callback emits 'drain' if the flush hit backpressure, else emit it now. - if (!hitBackpressure) { - process.nextTick(emitPipelinedDrainNT, res); - } - } -} - -function emitPipelinedDrainNT(res: $Node.ServerResponse) { - if (!res.destroyed && !res.finished) { - res.emit("drain"); - } -} - -// write()/end() on a response that is still queued behind an in-flight -// pipelined response: buffer the call (Node.js buffers through outputData -// while no socket is assigned) and report backpressure against the high water -// mark so 'drain' semantics match. -// Node's _storeHeader pushes the serialized header block through outputData, so a -// queued response's outputSize - and the connection's outgoingData that gates -// pipelined reads - counts header bytes, not just body chunks. Bun renders the -// block natively at replay, so account its size when the first buffered op fixes -// the headers. Without it every queued response contributes only its chunk -// length; power-of-two chunks can then land outgoingData exactly on -// writableHighWaterMark and pause reads one request earlier than Node would, -// deadlocking clients that pipeline the unblocking request behind the crossing -// one (test-http-pipeline-socket-parser-typeerror). -function accountQueuedHeaderBytes(res: $Node.ServerResponse, queued: PipelinedQueuedState) { - if (queued.headerBytes !== 0) { - return; - } - // Status line: "HTTP/1.1 NNN \r\n". - let bytes = 15 + String(res.statusMessage ?? STATUS_CODES[res.statusCode] ?? "unknown").length; - spillHeadersOf(res); - const outHeaders = res[kOutHeaders]; - if (outHeaders !== null && outHeaders !== undefined) { - for (const key in outHeaders) { - const entry = outHeaders[key]; - if (!entry) continue; - const value = entry[1]; - if ($isJSArray(value)) { - for (let i = 0; i < value.length; i++) { - bytes += entry[0].length + 4 + String(value[i]).length; - } - } else { - bytes += entry[0].length + 4 + String(value).length; - } - } - } - // Headers the native writer adds when absent, with their literal serialized - // lengths: "Date: <29-byte IMF-fixdate>\r\n" (37), "Connection: keep-alive\r\n" - // (24), "Transfer-Encoding: chunked\r\n" (28), plus the terminating "\r\n". - if (!res.hasHeader("date")) bytes += 37; - if (!res.hasHeader("connection")) bytes += 24; - if (!res.hasHeader("content-length") && !res.hasHeader("transfer-encoding")) bytes += 28; - bytes += 2; - queued.headerBytes = bytes; - queued.bytes += bytes; - res.outputSize += bytes; - addPipelineOutgoingData(queued, bytes); -} - -function bufferPipelinedWrite( - res: $Node.ServerResponse, - queued: PipelinedQueuedState, - chunk: $Node.HttpChunk | null | undefined, - encoding: string | null | undefined, - callback: $Node.HttpWriteCallback | undefined, -) { - 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]) { - throw $ERR_HTTP_BODY_NOT_ALLOWED(); - } - // Node's write_(): a response that cannot have a body discards the write, calls back and returns true. - queued.ops.push(["write", undefined, encoding, undefined]); - if (typeof callback === "function") process.nextTick(callback); - return true; - } - queued.ops.push(["write", chunk, encoding, callback]); - if (chunk) { - const bytes = typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.length; - queued.bytes += bytes; - // Node buffers these writes through outputData while no socket is - // assigned, so outputSize reflects them until the response is flushed. - res.outputSize += bytes; - addPipelineOutgoingData(queued, bytes); - } - if (queued.bytes >= res.writableHighWaterMark) { - queued.needDrain = true; - return false; - } - return true; -} - -function bufferPipelinedEnd( - res: $Node.ServerResponse, - queued: PipelinedQueuedState, - chunk: $Node.HttpChunk | null | undefined, - encoding: string | null | undefined, - callback: $Node.HttpWriteCallback | undefined, -) { - callWriteHeadIfObservable(res, res[headerStateSymbol], true); - if (res[headerStateSymbol] === NodeHTTPHeaderState.none) { - updateHasBody(res, (res.statusCode = validateStatusCode(res.statusCode))); - } - accountQueuedHeaderBytes(res, queued); - if (chunk && !res._hasBody) { - if (res[kRejectNonStandardBodyWrites]) { - throw $ERR_HTTP_BODY_NOT_ALLOWED(); - } - chunk = undefined; - } - queued.ops.push(["end", chunk, encoding, callback]); - if (chunk) { - const bytes = typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.length; - queued.bytes += bytes; - // Node buffers these writes through outputData while no socket is - // assigned, so outputSize reflects them until the response is flushed. - res.outputSize += bytes; - addPipelineOutgoingData(queued, bytes); - } - queued.ended = true; - res.finished = true; - return res; -} - -const RE_CONN_CLOSE = /(?:^|\W)close(?:$|\W)/i; - const enum DispatchBits { // Native dispatch bitfield: presence/token bits for the request headers the // dispatcher consults, computed in one native pass over the raw headers @@ -4531,9 +4294,7 @@ function shouldKeepAliveForConnection( return !(typeof connection === "string" && RE_CONN_CLOSE.test(connection)); } -// Result of shouldKeepAliveForConnection, computed once at dispatch and -// reused by renderNativeHeaders / the pipelined-response path so neither has -// to re-read req.headers (which would materialize the lazy header object). +// shouldKeepAliveForConnection(), computed once at dispatch: reading req.headers again would materialize the lazy header object. function requestShouldKeepAlive(req: $Node.IncomingMessage | null | undefined) { if (!req) return true; @@ -4542,139 +4303,6 @@ function requestShouldKeepAlive(req: $Node.IncomingMessage | null | undefined) { return (req[kReqShouldKeepAlive] = shouldKeepAliveForConnection(req, req.headers.connection)); } -function isHTTPServerHeaderStateSentOrAssigned(state: NodeHTTPHeaderState) { - return state === NodeHTTPHeaderState.sent || state === NodeHTTPHeaderState.assigned; -} - -// In Node.js writeHead() renders the header block immediately, so mutating -// headers afterwards throws ERR_HTTP_HEADERS_SENT. Our headers are rendered -// lazily right before the native writeHead, so mirror the throwing behavior -// by also checking the header state, then defer to the OutgoingMessage -// implementations. -function headerStateOf(res: $Node.ServerResponse): NodeHTTPHeaderState { - return res[headerStateSymbol]; -} - -function throwIfServerHeadersSent(self: $Node.ServerResponse, action: string) { - if (isHTTPServerHeaderStateSentOrAssigned(headerStateOf(self))) { - throw $ERR_HTTP_HEADERS_SENT(action); - } -} - -function outgoingSetHeaderIsOurs(): boolean { - return ( - (IS_COMPILED_AHEAD_OF_TIME && - $isPlainInstanceOf(plainOutgoingMessage, PlainOutgoingMessage) && - $findsDeclaredMember(plainOutgoingMessage, "setHeader")) || - readsOutgoingSetHeader() - ); -} - -function readsOutgoingSetHeader(): boolean { - return OutgoingMessagePrototype.setHeader === outgoingSetHeader; -} - -const responseSetHeader = function setHeader(this: $Node.ServerResponse, name: string, value: $Node.HttpHeaderValue) { - const exchange = this.$exchange; - const isItself = exchange !== undefined && exchange.response === this; - if (isHTTPServerHeaderStateSentOrAssigned(this[headerStateSymbol])) { - throw $ERR_HTTP_HEADERS_SENT("set"); - } - if (!IS_COMPILED_AHEAD_OF_TIME || !isItself || !outgoingSetHeaderIsOurs()) { - return OutgoingMessagePrototype.setHeader.$call(this, name, value); - } - const header = this._header; - if (header !== null && header) throw $ERR_HTTP_HEADERS_SENT("set"); - if (exchange.shape.count === 0) storeOnMessageAsFirstHeaderDoes(this, exchange, name); - storeHeader(exchange, name, value); - return this; -}; - -function settlesLeniencyWithoutServer(response: $Node.ServerResponse): boolean { - const insecureHTTPParser = response.insecureHTTPParser; - response[kLenientValidation] = typeof insecureHTTPParser === "boolean" ? insecureHTTPParser : isLenient(); - return true; -} - -// computeLenientHeaderValidation() for a plain response whose first name the shape expects: its reads in its order; a name that a response lacks is asked for and not read. -function settlesLeniencyInPlace(response: $Node.ServerResponse, exchange: Exchange, name: string): boolean { - if ( - !( - IS_COMPILED_AHEAD_OF_TIME && - $isPlainInstanceOf(response, ServerResponse) && - $findsDeclaredMember(response, "_isLenientHeaderValidation") && - $findsNoMember(response, "httpValidation") - ) || - exchange.shape.nextName !== name - ) { - return false; - } - // From here on nothing is read anew: what an accessor on the way lays is seen where plain sees it, and nowhere else. - const req = response.req; - if (req === undefined || req === null) return settlesLeniencyWithoutServer(response); - const socket = req.socket; - if (socket === undefined || socket === null) return settlesLeniencyWithoutServer(response); - const server = socket.server; - if (server === undefined || server === null) return settlesLeniencyWithoutServer(response); - const serverHttpValidation = server.httpValidation; - if (serverHttpValidation !== undefined) { - response[kLenientValidation] = serverHttpValidation !== "strict"; - return true; - } - if (!( - IS_COMPILED_AHEAD_OF_TIME && - $isPlainInstanceOf(response, ServerResponse) && - $findsNoMember(response, "insecureHTTPParser") - )) { - ("unlikely"); - const insecureHTTPParser = response.insecureHTTPParser; - if (typeof insecureHTTPParser === "boolean") { - response[kLenientValidation] = insecureHTTPParser; - return true; - } - } - const serverOption = server.insecureHTTPParser; - response[kLenientValidation] = typeof serverOption === "boolean" ? serverOption : isLenient(); - return true; -} - -// The first header of a message stores on the message. One that is frozen refuses that, where any other message stores it. -function storeOnMessageAsFirstHeaderDoes(response: $Node.ServerResponse, exchange: Exchange, name: string): void { - const lenient = response[kLenientValidation]; - if (lenient === undefined) { - if (!settlesLeniencyInPlace(response, exchange, name)) settleLeniency(exchange, name); - return; - } - try { - response[kLenientValidation] = lenient; - } catch { - response._isLenientHeaderValidation(); - } -} -ServerResponse.prototype.setHeader = responseSetHeader; - -ServerResponse.prototype.appendHeader = function appendHeader( - this: $Node.ServerResponse, - name: string, - value: $Node.HttpHeaderValue, -) { - throwIfServerHeadersSent(this, "append"); - return plainOutgoingMessage.appendHeader.$call(this, name, value); -}; - -ServerResponse.prototype.setHeaders = function setHeaders( - this: $Node.ServerResponse, - headers: Iterable<[name: string, value: $Node.HttpHeaderValue]>, -) { - throwIfServerHeadersSent(this, "set"); - return plainOutgoingMessage.setHeaders.$call(this, headers); -}; - -ServerResponse.prototype.removeHeader = function removeHeader(this: $Node.ServerResponse, name: string) { - throwIfServerHeadersSent(this, "remove"); - return plainOutgoingMessage.removeHeader.$call(this, name); -}; - // res.headers / res.headers= are Bun-specific conveniences kept for backwards // compatibility; they are views over the Node.js-style header storage. Object.defineProperty(ServerResponse.prototype, "headers", { @@ -4690,18 +4318,18 @@ Object.defineProperty(ServerResponse.prototype, "headers", { | null | undefined, ) { - throwIfServerHeadersSent(this, "set"); + if (this._header) throw $ERR_HTTP_HEADERS_SENT("set"); spillHeadersOf(this); this[kOutHeaders] = null; if (!value) return; 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); @@ -4710,30 +4338,17 @@ Object.defineProperty(ServerResponse.prototype, "headers", { } } }, -}); - -function waitsItsTurn(res: $Node.ServerResponse): boolean { - const exchange = res.$exchange; - return exchange !== undefined && exchange.response === res - ? (exchange.flags & ExchangeFlags.waits_its_turn) !== 0 - : res[kPipelinedQueuedState] !== undefined; -} +}); Object.defineProperty(ServerResponse.prototype, "socket", { get(this: $Node.ServerResponse) { - if (waitsItsTurn(this)) { - // Queued pipelined response: like Node.js, res.socket is null until the - // response is assigned the socket. - return null; - } - return (this[fakeSocketSymbol] ??= new FakeSocket(this)); + // null while the response is queued behind a pipelined one, like Node.js. + const socket = this[fakeSocketSymbol]; + return socket !== undefined ? socket : (this[fakeSocketSymbol] = new FakeSocket(this)); }, set(this: $Node.ServerResponse, value: $Node.HttpSocket | null) { - this[fakeSocketSymbol] = value; - // Keep the OutgoingMessage storage in sync: the standalone no-handle path - // routes through OutgoingMessage.write/end, whose kSocket-gated checks - // (_finish()/'prefinish', _flushOutput) must see the assigned socket. - this[kSocket] = value; + this[fakeSocketSymbol] = value ?? undefined; + outgoingSocketSetter.$call(this, value); }, }); @@ -4746,79 +4361,29 @@ Object.defineProperty(ServerResponse.prototype, "connection", { }, }); -// The native-handle path ignores this flag (uWS does the chunk framing), but -// the standalone path (no kHandle, assignSocket()) goes through the upstream -// _storeHeader/write_/end machinery, which sets and reads it to frame the -// body - so it needs real storage. -Object.defineProperty(ServerResponse.prototype, "chunkedEncoding", { - get(this: $Node.ServerResponse) { - return this[kChunkedEncoding] ?? false; - }, - set(this: $Node.ServerResponse, value) { - this[kChunkedEncoding] = value; - }, -}); - Object.defineProperty(ServerResponse.prototype, "writableCorked", { get(this: $Node.ServerResponse) { + if (!this[kHandle]) return outgoingWritableCorked!.$call(this); return this.socket?.writableCorked ?? 0; }, set(_value) {}, }); ServerResponse.prototype.cork = function cork(this: $Node.ServerResponse) { - sendWhatIsHeld(this); - // socket is null while this response is queued behind a pipelined response. + if (!this[kHandle]) return OutgoingMessagePrototype.cork.$call(this); this.socket?.cork(); }; ServerResponse.prototype.uncork = function uncork(this: $Node.ServerResponse) { + if (!this[kHandle]) return OutgoingMessagePrototype.uncork.$call(this); this.socket?.uncork(); }; -ServerResponse.prototype.setTimeout = function setTimeout( - this: $Node.ServerResponse, - msecs: number, - callback?: () => void, -) { - // Like OutgoingMessage.prototype.setTimeout: the callback listens on the - // response's 'timeout' event (emitted by onNodeHTTPServerSocketTimeout); - // only msecs is delegated to the socket. - if (callback) this.on("timeout", callback); - if (!this[fakeSocketSymbol]) { - this.once("socket", function socketSetTimeoutOnConnect(socket: $Node.HttpSocket) { - socket.setTimeout!(msecs); - }); - } else { - this.socket!.setTimeout!(msecs); - } - - return this; -}; - -// The end() callback (or null) while the body end() buffered is still draining. -ServerResponse.prototype._removedConnection = false; - -ServerResponse.prototype._removedContLen = false; - -ServerResponse.prototype._hasBody = true; - ServerResponse.prototype._ended = false; // The end() callback (or null) while the body end() buffered is still draining. ServerResponse.prototype[kPendingFinish] = undefined; -ServerResponse.prototype[kRejectNonStandardBodyWrites] = undefined; - -Object.defineProperty(ServerResponse.prototype, "headersSent", { - get(this: $Node.ServerResponse) { - return this[headerStateSymbol] === NodeHTTPHeaderState.sent || headerStateOf(this) === NodeHTTPHeaderState.assigned; - }, - set(this: $Node.ServerResponse, value: boolean) { - storeHeaderState(this, this.$exchange, value ? NodeHTTPHeaderState.sent : NodeHTTPHeaderState.none); - }, -}); - ServerResponse.prototype._writeRaw = function ( this: $Node.ServerResponse, chunk: $Node.HttpChunk, @@ -4832,24 +4397,11 @@ ServerResponse.prototype._writeRaw = function ( // `socket` getter here would drop the bytes into a FakeSocket. return OutgoingMessagePrototype._writeRaw.$apply(this, arguments); } - const queued = this[kPipelinedQueuedState]; - if (queued !== undefined) { - // Queued pipelined response: like Node.js (which buffers to outputData - // while no socket is assigned and flushes on assignSocket), buffer the - // raw 1xx bytes and write them ahead of the buffered body once this - // response reaches the head of the pipeline. - queued.ops.push(["raw", chunk, encoding, callback]); - const bytes = typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.length; - queued.bytes += bytes; - this.outputSize += bytes; - addPipelineOutgoingData(queued, bytes); - return queued.bytes < this.writableHighWaterMark; - } - sendWhatIsHeld(this); // Write through the response handle's AsyncSocket buffer (same path as // writeHead/end) so 1xx lines share ordering with the final response bytes; // socket.write() would land in the socket handle's separate stream buffer. this[kHandle].writeInformational(chunk, encoding); + noteResponseWrite(this); if (typeof callback === "function") process.nextTick(callback); return true; }; @@ -4950,128 +4502,29 @@ ServerResponse.prototype.writeContinue = function (this: $Node.ServerResponse, c if (this.headersSent) { throw $ERR_HTTP_HEADERS_SENT("write"); } - if (!this[kHandle] || this[kPipelinedQueuedState] !== undefined) { - // Standalone path (no native handle) or queued pipelined response (no - // socket yet): route through writeInformation like Node.js v26.3.0 so the - // 100 Continue line is buffered and written once a socket is assigned. + if (!this[kHandle]) { + // Through writeInformation() like Node.js v26.3.0, so the line waits in outputData until a socket is assigned. this.writeInformation(100, null, cb); this._sent100 = true; return; } - const native = this.socket?.[kHandle]?.response; - if (native) native.writeContinue(); - else this[kHandle]?.writeContinue?.(); + this[kHandle].writeContinue(); + noteResponseWrite(this); this._sent100 = true; - cb?.(); + if (typeof cb === "function") process.nextTick(cb); }; -// For write(), which has asked ticksAreUnseen(). -function queueExchangeTick(exchange: Exchange, flags: number): void { - exchange.flags = flags | ExchangeFlags.tick_queued; - exchange.queuedTick = ring.write!; - enqueueTick1(finishExchange, exchange); -} - -// For end(), which has asked ticksAreUnseen(). Nobody asks for the tick that end() queued. -function queueExchangeTickUnseen(exchange: Exchange, flags: number): void { - exchange.flags = flags | ExchangeFlags.tick_queued; - enqueueTick1(finishExchange, exchange); -} - -// A first chunk that write() has not sent, with the head as write() rendered it. Its socket holds it. -class HeldChunk { - declare exchange: Exchange; - declare chunk: string; - declare headers: unknown[]; - declare autoHeaders: number; - declare keepAliveSecs: number; - declare statusCode: number; - declare statusMessage: string | undefined; - - constructor( - exchange: Exchange, - chunk: string, - headers: unknown[], - autoHeaders: number, - keepAliveSecs: number, - statusCode: number, - statusMessage: string | undefined, - ) { - this.exchange = exchange; - this.chunk = chunk; - this.headers = headers; - this.autoHeaders = autoHeaders; - this.keepAliveSecs = keepAliveSecs; - this.statusCode = statusCode; - this.statusMessage = statusMessage; - } -} - -declare global { - namespace $Node { - type HttpHeldChunk = HeldChunk; - } -} - -// Hands the head on the way renderNativeHeaders() does. -function headOf(held: HeldChunk): unknown[] { - renderedAutoHeaders = held.autoHeaders; - renderedKeepAliveSecs = held.keepAliveSecs; - return held.headers; -} - -function sendHeldChunk(res: $Node.ServerResponse, exchange: Exchange): void { - const socket = exchange.connection!; - const held = socket.$heldChunk!; - exchange.flags &= ~ExchangeFlags.holds_chunk; - socket.$heldChunk = null; - // Where a build has the looks under kHandle go by value, this one calls sendWhatIsHeldBeforeHandleIsSeen() too. - const handle = res[kHandle]!; - if ((handle.flags & NodeHTTPResponseFlags.closed_or_completed) !== 0) return; - handle.cork(() => { - const renderedHeaders = headOf(held); - handle.writeHead( - held.statusCode, - held.statusMessage, - renderedHeaders as string[], - renderedAutoHeaders, - renderedKeepAliveSecs, - ); - handle.write(held.chunk, undefined, allowWritesToContinue.bind(res), undefined); - }); -} - -// Whoever looks under the symbol of the handle with a key that is a value finds a handle that has been given all that was written. -function sendWhatIsHeldBeforeHandleIsSeen(this: $Node.ServerResponse): void { - const exchange = this.$exchange; - if (exchange !== undefined && exchange.response === this && (exchange.flags & ExchangeFlags.holds_chunk) !== 0) - sendHeldChunk(this, exchange); -} - -if (IS_COMPILED_AHEAD_OF_TIME) { - $newCppFunction( - "BunCompiledModules.cpp", - "jsFunctionPrepareHoldersBeforeLookupsUnder", - 2, - )(kHandle, sendWhatIsHeldBeforeHandleIsSeen); -} - -function sendWhatIsHeld(res: $Node.ServerResponse | null | undefined): void { - if (res == null) return; - const exchange = res.$exchange; - if (exchange !== undefined && (exchange.flags & ExchangeFlags.holds_chunk) !== 0) - sendHeldChunk(exchange.response, exchange); -} - -// Reads through to the target so that a Proxy does not observe this internal read. -function flushSocketHeldChunk(socket: any): void { - if ($isProxyObject(socket)) { - ("unlikely"); - do socket = $getProxyInternalField(socket, $proxyFieldTarget); - while ($isProxyObject(socket)); +// A response takes what its native handle takes, whatever carries it. OutgoingMessage takes a string or a Uint8Array. +function toOutgoingChunk(res: $Node.ServerResponse, chunk: any) { + if (typeof chunk === "string" || typeof chunk === "function" || chunk == null) return chunk; + if (!res._header) updateHasBody(res, res.statusCode); + if (!res._hasBody) return ""; + if (ArrayBufferIsView(chunk)) { + return chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength); } - const held: HeldChunk | null | undefined = socket?.$heldChunk; - if (held != null) sendHeldChunk(held.exchange.response, held.exchange); + const { isAnyArrayBuffer, isStringObject } = require("node:util/types"); + if (isAnyArrayBuffer(chunk)) return new Uint8Array(chunk); + return isStringObject(chunk) ? `${chunk}` : chunk; } function finishExchangeInSteps(exchange: Exchange, flags: number): void { @@ -5090,66 +4543,14 @@ function finishExchangeTickByTick(exchange: Exchange, flags: number): void { const res = exchange.response; queueTickInFront(finishExchangeInSteps, 2, exchange, flags); queueTickInFront(markResponseEndedNT, 1, res, undefined); - if ((exchange.flags & ExchangeFlags.owes_flush) !== 0) queueTickInFront(flushWriteAccountingNT, 1, res, undefined); -} - -const flushWriteAccountingOfResponse = flushWriteAccountingNT; -const forgetBytesBuffered = function flushWriteAccountingNT(exchange: Exchange): void { - const flags = exchange.flags; - if ((flags & ExchangeFlags.owes_flush) === 0) return; - const res = exchange.response; - if (!reachesWritableHighWaterMark(res)) { - ("unlikely"); - return flushWriteAccountingOfResponse(res); - } - res[kAccountingFlushScheduled] = false; - res[kBytesBuffered] = 0; - exchange.flags &= ~ExchangeFlags.owes_flush; -}; - -// flushWriteAccountingNT() asks for writableHighWaterMark, which asks for the socket, and a response that has let go of its socket makes one to answer with. forgetBytesBuffered() asks for neither. Whoever looks under the symbol of the socket, which a program finds by listing the keys of a response, finds the socket that the question would have left there. -function showSocketMadeForAccounting(this: $Node.ServerResponse): void { - const exchange = this.$exchange; - if (exchange === undefined) return; - const response = exchange.response; - if (response !== this) return; - if ( - (exchange.flags & ExchangeFlags.ended_before_its_tick) !== 0 && - response[kAccountingFlushScheduled] === false && - response[fakeSocketSymbol] === null - ) { - response[fakeSocketSymbol] = new FakeSocket(response); - } -} - -if (IS_COMPILED_AHEAD_OF_TIME) { - $newCppFunction( - "BunCompiledModules.cpp", - "jsFunctionPrepareHoldersBeforeLookupsUnder", - 2, - )(fakeSocketSymbol, showSocketMadeForAccounting); } const finishExchange = function emitResponseFinished(exchange: Exchange): void { const flags = exchange.flags; - if ((flags & (ExchangeFlags.skips_a_tick | ExchangeFlags.ended_clean)) !== ExchangeFlags.ended_clean) { + if ((flags & ExchangeFlags.ended_clean) === 0) { ("unlikely"); - if ((flags & ExchangeFlags.skips_a_tick) !== 0) { - exchange.flags = flags & ~ExchangeFlags.skips_a_tick; - if ((flags & ExchangeFlags.owes_flush) === 0) return; - const response = exchange.response; - if (!(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(response, ServerResponse))) { - return flushWriteAccountingNT(response); - } - return forgetBytesBuffered(exchange); - } - exchange.flags = flags & ~ExchangeFlags.tick_queued; - if ((flags & ExchangeFlags.ended_in_steps) !== 0) return finishExchangeTickByTick(exchange, flags); - const res = exchange.response; - if ((flags & ExchangeFlags.holds_chunk) !== 0) sendHeldChunk(res, exchange); - return flushWriteAccountingNT(res); + return finishExchangeTickByTick(exchange, flags); } - exchange.flags = flags & ~ExchangeFlags.tick_queued; const res = exchange.response; if (!(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(res, ServerResponse))) { ("unlikely"); @@ -5157,7 +4558,6 @@ const finishExchange = function emitResponseFinished(exchange: Exchange): void { } let takesStores = true; try { - forgetBytesBuffered(exchange); res._ended = true; } catch { takesStores = false; @@ -5170,9 +4570,7 @@ const finishExchange = function emitResponseFinished(exchange: Exchange): void { const socket = exchange.connection; const requestState = exchange.requestState; if ( - (((exchange.flags & ExchangeFlags.may_have_to_close) !== 0 || - !(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(res, ServerResponse))) && - res[kMustCloseConnection]) || + res._last || socket === null || req == null || requestState == null || @@ -5180,6 +4578,7 @@ const finishExchange = function emitResponseFinished(exchange: Exchange): void { req._readableState !== requestState || req._events == null || res._closed || + !holdsNothing(res) || !ticksAreUnseen() || !emitsUnseen(res) || ((((exchange.flags & ExchangeFlags.may_be_detached) === 0 && @@ -5194,8 +4593,9 @@ const finishExchange = function emitResponseFinished(exchange: Exchange): void { ("unlikely"); return finishExchangeInSteps(exchange, flags); } - const server = socket.server; - if (server == null) { + const inFlight = socket._httpMessage; + const server = socket.server as unknown as $Node.HttpServer; + if (server == null || (inFlight && inFlight !== res)) { ("unlikely"); return finishExchangeInSteps(exchange, flags); } @@ -5212,7 +4612,7 @@ const finishExchange = function emitResponseFinished(exchange: Exchange): void { return finishExchangeInSteps(exchange, flags); } const idleTimeout = keepAliveTimeout + keepAliveTimeoutBuffer; - if (socket.$armedTimeout !== idleTimeout && socket.setTimeout !== serverSocketSetTimeout) { + if (!isArmedFor(socket, idleTimeout) && socket.setTimeout !== netSocketSetTimeout) { ("unlikely"); return finishExchangeInSteps(exchange, flags); } @@ -5255,17 +4655,6 @@ const finishExchange = function emitResponseFinished(exchange: Exchange): void { return finishHeardExchange(exchange, res, req, flags); }; -// No tick runs once the process has begun to exit. -function sendWhatIsHeldForTicks(): void { - for (let tick = ring.read; tick !== ring.write; tick = tick!.next) { - if (tick!.callback === finishExchange) sendWhatIsHeld((tick!.first as Exchange).response); - } -} - -if (IS_COMPILED_AHEAD_OF_TIME) { - $newCppFunction("BunProcess.cpp", "jsFunctionRunWhenProcessExits", 1)(sendWhatIsHeldForTicks); -} - function finishesFirst(res: $Node.ServerResponse): boolean { const events = res._events as any; if (events == null) return false; @@ -5275,6 +4664,12 @@ function finishesFirst(res: $Node.ServerResponse): boolean { ); } +// Whether the timer of the socket runs with this interval, so that it stays as it is. +function isArmedFor(socket: $Node.HttpServerSocket, idleTimeout: number): boolean { + const timer = socket[kTimeout]; + return timer != null && !timer._destroyed && timer._idleTimeout === idleTimeout; +} + const finishConnection = function onResponseFinishHandleSocket( exchange: Exchange, res: $Node.ServerResponse, @@ -5295,7 +4690,7 @@ const finishConnection = function onResponseFinishHandleSocket( if (!beginDump(req)) { ("unlikely"); exchange.flags &= ~ExchangeFlags.finishes_here; - if (parser != null && parser.incoming === req && req.readableEnded) parser.incoming = null; + if (parser != null && parser.incoming === req && (req.readableEnded || req.destroyed)) parser.incoming = null; } else if ((exchange.flags & ExchangeFlags.others_wait) !== 0) { exchange.flags &= ~ExchangeFlags.finishes_here; queueDump(req, exchange.requestState!); @@ -5305,15 +4700,15 @@ const finishConnection = function onResponseFinishHandleSocket( } else { exchange.flags &= ~ExchangeFlags.finishes_here; if (kind === RequestKind.other && !isResumeScheduled(req._readableState)) req._dump(); - if (parser != null && parser.incoming === req && req.readableEnded) parser.incoming = null; + if (parser != null && parser.incoming === req && (req.readableEnded || req.destroyed)) parser.incoming = null; } const idleTimeout = exchange.idleTimeout; - if (socket.$armedTimeout === idleTimeout || isArmedWith(socket, idleTimeout)) socket.timeout = idleTimeout; + socket[kLastResponseWrite] = timerClockMs(); + if (isArmedFor(socket, idleTimeout)) socket.timeout = idleTimeout; else { ("unlikely"); socket.setTimeout(idleTimeout); } - socket[kKeepAliveIdleStart] = DateNow(); socket[kKeepAliveTimeoutSet] = true; if ( ((exchange.flags & ExchangeFlags.may_be_detached) === 0 && @@ -5324,9 +4719,8 @@ const finishConnection = function onResponseFinishHandleSocket( if (socket._httpMessage === res) { socket[kCloseCallback] = undefined; socket._httpMessage = null; - socket[kRequest] = undefined; } - res[fakeSocketSymbol] = null; + res[fakeSocketSymbol] = undefined; res[kSocket] = null; } }; @@ -5425,6 +4819,7 @@ const finishHeardExchange = function emitResponseFinished( ring.read === ring.write && req._readableState === state && resumesUnseen(exchange.connection!) && + isAtRest(exchange.connection!) && staysUnseen(req) ) { const parser = req.socket?.parser; @@ -5443,23 +4838,25 @@ function emitsUnseen(res: $Node.ServerResponse): boolean { ); } -function resumesUnseen(socket: $Node.HttpServerSocket): boolean { +// Whether emit("close") would find nothing whose callbacks it has to fail first. +function holdsNothing(res: $Node.ServerResponse): boolean { return ( - ((IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(socket, "resume")) || socket.resume === serverSocketResume) && - netSocketPrototype!.resume === netSocketResume && - DuplexPrototype.resume === duplexResume && - socket[kUpgradeIncoming] === undefined && - readsFlowUnseen(socket) + IS_COMPILED_AHEAD_OF_TIME && + $isPlainHeirOf(res, ServerResponse) && + res[kFailHeldWrites] === failHeldWrites && + res.outputData.length === 0 && + res[kChunkedBuffer].length === 0 && + res[kPendingCallbacks].length === 0 ); } +function resumesUnseen(socket: $Node.HttpServerSocket): boolean { + return socket.resume === netSocketResume && DuplexPrototype.resume === duplexResume && readsFlowUnseen(socket); +} + // Whether a read of that name on the socket reaches the accessor of Duplex.prototype: neither the socket nor a prototype in front of it has such a property. function reachesDuplexPrototype(socket: $Node.HttpServerSocket, name: string): boolean { - return ( - !ObjectHasOwn(socket, name) && - !ObjectHasOwn(serverSocketPrototype!, name) && - !ObjectHasOwn(netSocketPrototype!, name) - ); + return !ObjectHasOwn(socket, name) && !ObjectHasOwn(netSocketPrototype!, name); } // The same for an accessor of Readable.prototype. @@ -5469,8 +4866,7 @@ function reachesReadablePrototype(socket: $Node.HttpServerSocket, name: string): function hasThePrototypesOfItsClass(socket: $Node.HttpServerSocket): boolean { return ( - ObjectGetPrototypeOf(socket) === serverSocketPrototype && - ObjectGetPrototypeOf(serverSocketPrototype) === netSocketPrototype && + ObjectGetPrototypeOf(socket) === netSocketPrototype && ObjectGetPrototypeOf(netSocketPrototype) === DuplexPrototype && ObjectGetPrototypeOf(DuplexPrototype) === ReadablePrototype ); @@ -5479,11 +4875,12 @@ function hasThePrototypesOfItsClass(socket: $Node.HttpServerSocket): boolean { function readsFlowUnseen(socket: $Node.HttpServerSocket): boolean { if (!( IS_COMPILED_AHEAD_OF_TIME && - ObjectGetPrototypeOf(socket) === serverSocketPrototype && + ObjectGetPrototypeOf(socket) === netSocketPrototype && $findsDeclaredMember(socket, "readable") && $findsDeclaredMember(socket, "readableLength") && $findsDeclaredMember(socket, "readableHighWaterMark") && - $findsDeclaredMember(socket, "readableEnded") + $findsDeclaredMember(socket, "readableEnded") && + $findsDeclaredMember(socket, "destroyed") )) { ("unlikely"); return walksToAccessorsOfFlow(socket); @@ -5497,14 +4894,15 @@ function walksToAccessorsOfFlow(socket: $Node.HttpServerSocket): boolean { reachesReadablePrototype(socket, "readable") && reachesReadablePrototype(socket, "readableLength") && reachesReadablePrototype(socket, "readableHighWaterMark") && - reachesReadablePrototype(socket, "readableEnded") + reachesReadablePrototype(socket, "readableEnded") && + reachesDuplexPrototype(socket, "destroyed") ); } function readsHighWaterMarksUnseen(socket: $Node.HttpServerSocket): boolean { if (!( IS_COMPILED_AHEAD_OF_TIME && - ObjectGetPrototypeOf(socket) === serverSocketPrototype && + ObjectGetPrototypeOf(socket) === netSocketPrototype && $findsDeclaredMember(socket, "readableHighWaterMark") && $findsDeclaredMember(socket, "writableHighWaterMark") )) { @@ -5532,7 +4930,7 @@ function walksToHighWaterMarks(socket: $Node.HttpServerSocket): boolean { function readsItsEndUnseen(socket: $Node.HttpServerSocket): boolean { if (!( IS_COMPILED_AHEAD_OF_TIME && - ObjectGetPrototypeOf(socket) === serverSocketPrototype && + ObjectGetPrototypeOf(socket) === netSocketPrototype && $findsDeclaredMember(socket, "destroyed") && $findsDeclaredMember(socket, "writableEnded") )) { @@ -5556,7 +4954,8 @@ function detachesItself(res: $Node.ServerResponse): boolean { return ( ((IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(res, "detachSocket")) || res.detachSocket === responseDetachSocket) && - reachesSocket(res) + reachesSocket(res) && + !(res[kCorked] > 0) ); } @@ -5573,17 +4972,6 @@ function inheritsFromResponsePrototype(res: $Node.ServerResponse, name: string): return true; } -function reachesShouldKeepAlive(res: $Node.ServerResponse): boolean { - return !ObjectHasOwn(res, "shouldKeepAlive") && inheritsFromResponsePrototype(res, "shouldKeepAlive"); -} - -function reachesHeadersSent(res: $Node.ServerResponse): boolean { - return ( - (IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(res, "headersSent")) || - (!ObjectHasOwn(res, "headersSent") && inheritsFromResponsePrototype(res, "headersSent")) - ); -} - function reachesSocket(res: $Node.ServerResponse): boolean { return ( (IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(res, "socket")) || @@ -5591,16 +4979,6 @@ function reachesSocket(res: $Node.ServerResponse): boolean { ); } -function reachesWritableHighWaterMark(res: $Node.ServerResponse): boolean { - const connection = res.$exchange.connection; - return ( - ((IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(res, "writableHighWaterMark")) || - (!ObjectHasOwn(res, "writableHighWaterMark") && inheritsFromResponsePrototype(res, "writableHighWaterMark"))) && - reachesSocket(res) && - (connection === null || readsHighWaterMarksUnseen(connection)) - ); -} - function callsPendingCallbacksUnseen(res: $Node.ServerResponse): boolean { return ( (IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(res, "_callPendingCallbacks")) || @@ -5628,58 +5006,25 @@ function readsDestroyedUnseen(socket: $Node.HttpServerSocket): boolean { } function isAtRest(socket: $Node.HttpServerSocket): boolean { + const state = socket.$ownReadableState; return ( - socket.$isOpen && + socket[kHandle] !== undefined && !socket._paused && - !socket.$hasWaitingResponses && - !socket[kStreamingEnabled] && + socket.parser != null && + socket[kConnectionState] === undefined && !socket[kHandedOff] && - socket._readableState === socket.$ownReadableState && - (flagsOf(socket.$ownReadableState) & kSocketFlowMask) === kSocketFlows && + state !== undefined && + socket._readableState === state && + (flagsOf(state) & kSocketFlowMask) === kSocketFlows && readsItsEndUnseen(socket) ); } -function endAfterNativeThrow( - res: $Node.ServerResponse, - exchange: Exchange | undefined, - handle: $Node.NodeHTTPResponse | $Node.Http1ResponseHandle, - error: unknown, - mayAskHandleAfterwards: boolean, -): $Node.ServerResponse { - const code = (error as { code?: string } | null | undefined)?.code; - if (code === "ERR_STREAM_ALREADY_FINISHED") { - // This stands in for the question that the short road did not ask in front of the head: it skips it only over a connection that is open. - // A connection that code of the program shut amid the call was open there. One that the peer shut still counts as open here. - if ( - mayAskHandleAfterwards && - (handle.flags & NodeHTTPResponseFlags.closed_or_completed) !== 0 && - exchange?.connection?.$isOpen === true && - !res.finished && - !res.destroyed - ) { - return endWithoutSocket(res, exchange, true); - } - } else if ( - code !== "ERR_HTTP_HEADERS_SENT" && - code !== "ERR_INVALID_CHAR" && - code !== "ERR_INVALID_ARG_TYPE" && - !(error instanceof RangeError) - ) { - storeHeaderState(res, exchange, NodeHTTPHeaderState.sent); - } - throw error; -} - // Like Node, 'prefinish' fires but 'finish' never does, and the close of the socket aborts the request. -function endWithoutSocket( - res: $Node.ServerResponse, - exchange: Exchange | undefined, - countsAsSent: boolean, -): $Node.ServerResponse { +function endWithoutSocket(res: $Node.ServerResponse, countsAsSent: boolean): $Node.ServerResponse { if (countsAsSent) { - storeHeaderState(res, exchange, NodeHTTPHeaderState.sent); res._header = " "; + res._headerSent = true; } res.finished = true; queueTick1(markResponseEndedNT, res); @@ -5687,17 +5032,15 @@ function endWithoutSocket( return res; } -function sendTrailers(res: $Node.ServerResponse, trailer: string): boolean { - if ( - res._hasBody && - !res.hasHeader("content-length") && - res.req?.httpVersionMajor === 1 && - (res.req?.httpVersionMinor as number) >= 1 - ) { - res.socket?.[kHandle]?.setResponseTrailers(trailer); - return true; +// Like Node's end(), fully uncork the socket. A leftover cork would buffer a later 'connect' or 'upgrade' listener's writes forever. +function uncorkSocketOf(res: $Node.ServerResponse): void { + const socket = res[kSocket]; + const socketWritableState = socket?._writableState; + if (socketWritableState?.corked) { + ("unlikely"); + socketWritableState.corked = 1; + socket!.uncork(); } - return false; } ServerResponse.prototype.end = function ( @@ -5706,6 +5049,22 @@ ServerResponse.prototype.end = function ( encoding?: string | null | $Node.HttpWriteCallback, callback?: $Node.HttpWriteCallback | null, ) { + const exchange = this.$exchange; + // The short road is for a response as its class made it: no field of it has become an accessor, so what is read off it below runs no code of the program. + if ( + exchange === undefined || + exchange.response !== this || + !(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(this, ServerResponse)) + ) { + ("unlikely"); + return endInFull(this, exchange, chunk, encoding, callback); + } + const handle = this[kHandle]; + if (!handle || this.finished || this.destroyed) { + ("unlikely"); + return endInFull(this, exchange, chunk, encoding, callback); + } + if ($isCallable(chunk)) { callback = chunk; chunk = undefined; @@ -5717,50 +5076,8 @@ ServerResponse.prototype.end = function ( callback = undefined; } - const exchange = this.$exchange; - // The short road is for a response as its class made it: no field of it has become an accessor, so what is read off it below runs no code of the program. - let flags = - exchange !== undefined && - exchange.response === this && - IS_COMPILED_AHEAD_OF_TIME && - $isPlainInstanceOf(this, ServerResponse) - ? exchange.flags - : ExchangeFlags.waits_its_turn; - if ((flags & ExchangeFlags.waits_its_turn) !== 0 || this.finished || this.destroyed) { - ("unlikely"); - sendWhatIsHeld(this); - return endInFull(this, exchange, chunk, encoding, callback); - } - - const handle = this[kHandle] as $Node.NodeHTTPResponse | undefined; - if (!handle) { - ("unlikely"); - return OutgoingMessagePrototype.end.$call(this, chunk, encoding, callback); - } - // The look may have sent what was held. - flags = exchange.flags; - - let headerState = this[headerStateSymbol]; - let held: HeldChunk | null = null; - if ((flags & ExchangeFlags.holds_chunk) !== 0) { - // Where the length must be kept to, native code may throw between the head and the chunk. - const sendsAtOnce = chunk || this.strictContentLength; - // That read may have been an accessor of the program's that sent what was held. - flags = exchange.flags; - if ((flags & ExchangeFlags.holds_chunk) !== 0) { - if (sendsAtOnce) { - sendHeldChunk(this, exchange); - flags = exchange.flags; - } else { - // It stays in its place until native code is called: whoever writes before that sends it first. - held = exchange.connection!.$heldChunk!; - headerState = NodeHTTPHeaderState.assigned; - } - } - } else if (headerState === NodeHTTPHeaderState.none) { - updateHasBody(this, (this.statusCode = validateStatusCode(this.statusCode))); - } - if (held === null && chunk && !this._hasBody) { + if (!this._header) validateImplicitHead(this); + if (chunk && !this._hasBody) { ("unlikely"); if (this[kRejectNonStandardBodyWrites]) { throw $ERR_HTTP_BODY_NOT_ALLOWED(); @@ -5773,139 +5090,82 @@ ServerResponse.prototype.end = function ( let hasRunCode = false; if (trailer) { ("unlikely"); - sendTrailers(this, trailer); + if (this._hasBody && willBeChunked(this)) this.socket?.[kHandle]?.setResponseTrailers(trailer); hasRunCode = true; } - // What was read off the response since may have been an accessor of the program's that sent the head, or wrote a chunk that is held now. - if (held === null) { - headerState = this[headerStateSymbol]; - flags = exchange.flags; - if ((flags & ExchangeFlags.holds_chunk) !== 0) { - ("unlikely"); - sendHeldChunk(this, exchange); - flags = exchange.flags; - } - } else flags = exchange.flags; - if (headerState === NodeHTTPHeaderState.none) { - if (callWriteHeadIfObservable(this, headerState, true)) hasRunCode = true; - flags = exchange.flags; - } + if (callWriteHeadIfObservable(this, true)) hasRunCode = true; if (hasRunCode && !(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(this, ServerResponse))) { ("unlikely"); - if (held !== null) { - sendWhatIsHeld(this); - headerState = NodeHTTPHeaderState.sent; - } - return endInFullBehindHead(this, exchange, handle, chunk, encoding, callback, false, headerState); + return endInFullBehindHead(this, exchange, handle, chunk, encoding, callback, false); } - // Here the full road asks the handle. Neither finished nor destroyed at the entry: a writeHead() of the program's own, or an accessor, has done it since. - const socket = exchange.connection; - if (socket === null || !socket.$isOpen || this.finished || this.destroyed) { + if ((handle.flags & NodeHTTPResponseFlags.closed_or_completed) !== 0) { ("unlikely"); - if ((handle.flags & NodeHTTPResponseFlags.closed_or_completed) !== 0) { - return endWithoutSocket(this, exchange, true); - } + return endWithoutSocket(this, true); } let draining = false; - native: { - whole: { - if (headerState === NodeHTTPHeaderState.sent) break whole; - const renderedHeaders = (held !== null ? headOf(held) : renderNativeHeaders(this, exchange, true)) as string[]; - // The value of a header may be an object of the program's whose toString() took another head. The head of what is held is the one of its write(). - let statusCode: number; - let statusMessage: string | undefined; - if (held !== null) { - statusCode = held.statusCode; - statusMessage = held.statusMessage; - } else { - statusCode = - (exchange.flags & ExchangeFlags.may_have_taken_head) !== 0 || - !(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(this, ServerResponse)) - ? (this[kSnapshotStatusCode] ?? this.statusCode) - : this.statusCode; - // That read may have been an accessor, laid amid the rendering, that took a head. - statusMessage = - (exchange.flags & ExchangeFlags.may_have_taken_head) !== 0 || - !(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(this, ServerResponse)) - ? (this[kSnapshotStatusMessage] ?? this.statusMessage) - : this.statusMessage; - } - const strict = this.strictContentLength ? contentLengthToKeepTo(this, headerState, true) : undefined; - const autoHeaders = renderedAutoHeaders; - const keepAliveSecs = renderedKeepAliveSecs; - // The last look. A writeHead() of the program's own, or an accessor among the names read since, may have written. - flags = exchange.flags; - if (held !== null) { - if ((flags & ExchangeFlags.holds_chunk) === 0) { - // What was held went out in front of what that code wrote, and the head with it. - ("unlikely"); - held = null; - headerState = NodeHTTPHeaderState.sent; - break whole; - } - chunk = held.chunk; - encoding = undefined; - exchange.connection!.$heldChunk = null; - flags &= ~ExchangeFlags.holds_chunk; - exchange.flags = flags; - } else if ((flags & ExchangeFlags.holds_chunk) !== 0) { - // Native code gets the head and that chunk, and refuses the second head as on the full road. - ("unlikely"); - sendHeldChunk(this, exchange); - flags = exchange.flags; - } - let contentLength: number; - try { - contentLength = - (socket !== null - ? handle.writeWholeResponse( - statusCode, - statusCode === 200 && statusMessage === "OK" ? undefined : statusMessage, - renderedHeaders, - chunk, - encoding, - strict, - autoHeaders, - keepAliveSecs, - ) - : undefined) ?? - handle.writeHeadAndEnd( - statusCode, - statusMessage, - renderedHeaders, - chunk, - encoding, - strict, - autoHeaders, - keepAliveSecs, - ); - } catch (error) { - return endAfterNativeThrow(this, exchange, handle, error, true); - } - this[headerStateSymbol] = NodeHTTPHeaderState.sent; - if (contentLength < 0) { - ("unlikely"); - draining = true; - contentLength = -contentLength - 1; - } - if (held === null) this._contentLength = contentLength; - break native; + // A replaced writeHead() can have sent the head itself, with write(). + if (!this._headerSent) { + const renderedHeaders = renderNativeHeaders(this, exchange, true) as string[]; + const statusCode = + (exchange.flags & ExchangeFlags.may_have_taken_head) !== 0 || + !(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(this, ServerResponse)) + ? (this[kSnapshotStatusCode] ?? this.statusCode) + : this.statusCode; + // That read may have been an accessor, laid amid the rendering, that took a head. + const statusMessage = + (exchange.flags & ExchangeFlags.may_have_taken_head) !== 0 || + !(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(this, ServerResponse)) + ? (this[kSnapshotStatusMessage] ?? this.statusMessage) + : this.statusMessage; + const strict = this.strictContentLength ? contentLengthToKeepTo(this, true, true) : undefined; + const autoHeaders = renderedAutoHeaders; + const keepAliveSecs = renderedKeepAliveSecs; + let contentLength = + handle.writeWholeResponse( + statusCode, + statusCode === 200 && statusMessage === "OK" ? undefined : statusMessage, + renderedHeaders, + chunk, + encoding, + strict, + autoHeaders, + keepAliveSecs, + ) ?? + handle.writeHeadAndEnd( + statusCode, + statusMessage, + renderedHeaders, + chunk, + encoding, + strict, + autoHeaders, + keepAliveSecs, + ); + this._headerSent = true; + if (contentLength < 0) { + ("unlikely"); + draining = true; + contentLength = -contentLength - 1; } + this._contentLength = contentLength; + } else { const handleFlags = handle.flags; if (!!(handleFlags & NodeHTTPResponseFlags.closed_or_completed)) { ("unlikely"); - return endWithoutSocket(this, exchange, true); + return endWithoutSocket(this, true); } if ( !(!chunk && handleFlags & NodeHTTPResponseFlags.ended) && !(handleFlags & NodeHTTPResponseFlags.socket_closed) ) { - draining = handle.end(chunk, encoding, undefined, strictContentLength(this, headerState, true)) < 0; + draining = handle.end(chunk, encoding, undefined, strictContentLength(this, true, true))! < 0; } } this._header = " "; + noteResponseWrite(this); + uncorkSocketOf(this); this.finished = true; if (!draining && (exchange.flags & ExchangeFlags.has_pending_callbacks) === 0 && ticksAreUnseen()) { @@ -5922,7 +5182,7 @@ ServerResponse.prototype.end = function ( (!isUnheard(this, exchange) && (this._events === undefined || this._events.prefinish !== undefined)) ) { ("unlikely"); - return finishInSteps(this, exchange, handle, draining, callback); + return finishInSteps(this, handle, draining, callback); } if (!ticksAreUnseen()) { // What was read has put a process.nextTick of the program's own in place. The full road has queued one tick in front of those reads and queues the other behind them. @@ -5937,29 +5197,17 @@ ServerResponse.prototype.end = function ( exchange.flags |= ExchangeFlags.has_end_callback; } // The word as it is now: nothing of the program's runs from here to the stores. - const now = exchange.flags; - if ((now & ExchangeFlags.tick_queued) === 0) queueExchangeTickUnseen(exchange, now | ExchangeFlags.ended_clean); - // write() queued the tick, and a tick runs in the async context that it was queued in. `finish`, `close` and the callback are end()'s. - else if ( - exchange.queuedTick!.next === ring.write && - exchange.queuedTick!.frame === $getInternalField(asyncContext, 0) - ) - exchange.flags = now | ExchangeFlags.ended_clean | ExchangeFlags.ended_before_its_tick; - else - queueExchangeTickUnseen( - exchange, - now | ExchangeFlags.ended_clean | ExchangeFlags.ended_before_its_tick | ExchangeFlags.skips_a_tick, - ); + exchange.flags |= ExchangeFlags.ended_clean; + enqueueTick1(finishExchange, exchange); return this; } - return finishInSteps(this, exchange, handle, draining, callback); + return finishInSteps(this, handle, draining, callback); }; function finishInSteps( res: $Node.ServerResponse, - exchange: Exchange | undefined, - handle: $Node.NodeHTTPResponse | $Node.Http1ResponseHandle, + handle: $Node.NodeHTTPResponse, draining: boolean, callback: $Node.HttpWriteCallback | null | undefined, ): $Node.ServerResponse { @@ -5967,6 +5215,7 @@ function finishInSteps( res.emit("prefinish"); if (draining) { + // Native calls back once the body is out; a dying connection finishes from emit("close"). res[kPendingFinish] = callback ?? null; handle.onwritable = flushPendingFinish.bind(res); } else { @@ -5980,11 +5229,13 @@ function markResponseEndedNT(res: $Node.ServerResponse) { res._ended = true; } +// 'finish' waits a tick: the dispatcher sets kDetachedFrom after a sync handler returns, and 'finish' reads it. function queueResponseFinished(res: $Node.ServerResponse, callback: $Node.HttpWriteCallback | null | undefined) { res._callPendingCallbacks(); queueTick2(emitResponseFinished, res, callback); } +// 'close' is queued before the 'finish' listeners run, like Node.js's resOnFinish does. function emitResponseFinished(res: $Node.ServerResponse, callback: $Node.HttpWriteCallback | null | undefined) { queueTick1(emitCloseNT, res); res.emit("finish"); @@ -6001,64 +5252,10 @@ function flushPendingFinish(this: $Node.ServerResponse) { const callback = this[kPendingFinish]; if (callback === undefined) return; this[kPendingFinish] = undefined; + noteResponseWrite(this); queueResponseFinished(this, callback); } -// For internal/http1_server_fallback. Like Node.js's onFinish it emits 'finish' in place: a tick later, a closing socket's 'close' listeners would run first. -function finishDrainedResponse(res: $Node.ServerResponse) { - const callback = res[kPendingFinish]; - if (callback === undefined) return; - res[kPendingFinish] = undefined; - res._callPendingCallbacks(); - emitResponseFinished(res, callback); -} - -Object.defineProperty(ServerResponse.prototype, "writable", { - // Node.js's OutgoingMessage assigns `this.writable = true` in the - // constructor and never flips it back to false - not on end(), not on - // destroy(), not when the peer aborts (see upstream - // test-http-writable-true-after-close). - get() { - return true; - }, - set(_value) {}, -}); - -function writeHeadAndFirstChunk( - res: $Node.ServerResponse, - exchange: Exchange, - handle: $Node.NodeHTTPResponse | $Node.Http1ResponseHandle, - chunk: $Node.HttpChunk | null | undefined, - encoding: string | null | undefined, - onWritable: (() => void) | undefined, - strict: number | undefined, -): number { - let result = 0; - handle.cork(() => { - const renderedHeaders = renderNativeHeaders(res, exchange, true); - const statusCode = - (exchange.flags & ExchangeFlags.may_have_taken_head) !== 0 || - !(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(res, ServerResponse)) - ? (res[kSnapshotStatusCode] ?? res.statusCode) - : res.statusCode; - const statusMessage = - (exchange.flags & ExchangeFlags.may_have_taken_head) !== 0 || - !(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(res, ServerResponse)) - ? (res[kSnapshotStatusMessage] ?? res.statusMessage) - : res.statusMessage; - handle.writeHead( - statusCode, - statusMessage, - renderedHeaders as string[], - renderedAutoHeaders, - renderedKeepAliveSecs, - ); - res[headerStateSymbol] = NodeHTTPHeaderState.sent; - result = handle.write(chunk, encoding, onWritable, strict); - }); - return result; -} - ServerResponse.prototype.write = function ( this: $Node.ServerResponse, chunk?: $Node.HttpChunk | null | $Node.HttpWriteCallback, @@ -6066,340 +5263,113 @@ ServerResponse.prototype.write = function ( callback?: $Node.HttpWriteCallback | null, ) { const exchange = this.$exchange; - const isItself = - exchange !== undefined && - exchange.response === this && - IS_COMPILED_AHEAD_OF_TIME && - $isPlainInstanceOf(this, ServerResponse); - let connection: $Node.HttpServerSocket | null; - let hasChunk: boolean; - head: { - // A further chunk over a connection: the head is out, nothing is held, nobody waits, and the response is as its class made it: what is read off it here is read unseen. - if ( - isItself && - encoding === undefined && - callback === undefined && - (exchange.flags & ExchangeFlags.bars_a_further_chunk) === 0 && - this[headerStateSymbol] === NodeHTTPHeaderState.sent && - $findsDeclaredMember(this, "_callPendingCallbacks") - ) { - const socket = exchange.connection; - const onWritable = exchange.onWritable; - const handle = this[kHandle] as $Node.NodeHTTPResponse | undefined; - if ( - socket !== null && - onWritable !== null && - typeof handle === "object" && - handle !== null && - // Neither is converted by code of the program: a String object would be, amid the native call. - ((typeof chunk === "string" && chunk.length !== 0) || $isTypedArrayView(chunk)) && - !this.finished && - !this.destroyed && - this._hasBody === true && - !this.strictContentLength - ) { - let result: number; - try { - result = handle.write(chunk, undefined, onWritable, undefined); - } catch (error) { - if ((handle.flags & NodeHTTPResponseFlags.closed_or_completed) !== 0) return false; - throw error; - } - if (result <= 0) { - ("unlikely"); - if (result < 0 || (handle.flags & NodeHTTPResponseFlags.closed_or_completed) !== 0) return false; - } - connection = socket; - hasChunk = true; - break head; - } - } - - if (typeof chunk !== "string" && $isCallable(chunk)) { - callback = chunk; - chunk = undefined; - encoding = undefined; - } else if ($isCallable(encoding)) { - callback = encoding; - encoding = undefined; - } else if (!$isCallable(callback)) { - callback = undefined; - } + if ( + exchange === undefined || + exchange.response !== this || + !(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(this, ServerResponse)) + ) { + ("unlikely"); + return writeInFull(this, exchange, chunk, encoding, callback); + } + const handle = this[kHandle]; + if (!handle || this.finished || this.destroyed) { + ("unlikely"); + return writeInFull(this, exchange, chunk, encoding, callback); + } - let flags = isItself ? exchange.flags : ExchangeFlags.waits_its_turn; - if ((flags & ExchangeFlags.waits_its_turn) !== 0 || this.finished || this.destroyed) { - ("unlikely"); - sendWhatIsHeld(this); - return writeInFull(this, exchange, chunk, encoding, callback); - } + if ($isCallable(chunk)) { + callback = chunk; + chunk = undefined; + encoding = undefined; + } else if ($isCallable(encoding)) { + callback = encoding; + encoding = undefined; + } else if (!$isCallable(callback)) { + callback = undefined; + } - // Whatever is read off the response may be an accessor of the program's that sends the head, so the state of the head is read anew wherever it is asked for. - const headerState = this[headerStateSymbol]; - const hasRunCode = headerState === NodeHTTPHeaderState.none && callWriteHeadIfObservable(this, headerState); + const hadHeader = !!this._header; + if (callWriteHeadIfObservable(this) && !(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(this, ServerResponse))) { + ("unlikely"); + return writeInFullBehindHead(this, exchange, handle, chunk, encoding, callback, hadHeader); + } - const handle = this[kHandle] as $Node.NodeHTTPResponse | undefined; - if (!handle) { - ("unlikely"); - return OutgoingMessagePrototype.write.$call(this, chunk!, encoding, callback); - } - // The look may have sent what was held. - flags = exchange.flags; - if (hasRunCode && !(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(this, ServerResponse))) { - ("unlikely"); - return writeInFullBehindHead(this, exchange, handle, chunk, encoding, callback, headerState); - } + if (!this._header) validateImplicitHead(this); - if (this[headerStateSymbol] === NodeHTTPHeaderState.none) { - updateHasBody(this, (this.statusCode = validateStatusCode(this.statusCode))); - } - hasChunk = typeof chunk === "string" ? chunk.length !== 0 : !!chunk; - if (hasChunk && !this._hasBody) { - ("unlikely"); - if (this[kRejectNonStandardBodyWrites]) { - throw $ERR_HTTP_BODY_NOT_ALLOWED(); - } else { - chunk = undefined; - hasChunk = false; - } + const handleFlags = handle.flags; + if (!!(handleFlags & NodeHTTPResponseFlags.closed_or_completed)) { + // Socket already gone, and 'close' is still to come. + if (callback) { + addPendingCallback(this, callback); + failPendingWriteCallbacks(this, this[kSocket]?.errored); } + return false; + } - if ( - hasChunk && - typeof chunk === "string" && - callback === undefined && - encoding == null && - this[headerStateSymbol] !== NodeHTTPHeaderState.sent && - (exchange.headerBits & HeaderBits.content_length) !== 0 && - exchange.oddMask === 0 && - !this.strictContentLength && - // Native code refuses some status messages, and this call is the one to throw. writeHead() has looked at its own. - ((((exchange.flags & ExchangeFlags.may_have_taken_head) !== 0 || - !(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(this, ServerResponse))) && - this[kSnapshotStatusCode] !== undefined) || - this.statusMessage === undefined) && - this.getHeader === outgoingGetHeader && - reachesWritableHighWaterMark(this) && - ticksAreUnseen() && - callsPendingCallbacksUnseen(this) - ) { - const socket = exchange.connection; - if ( - socket !== null && - socket.$isOpen && - this[kSocket] === socket && - chunk.length * 3 < socket.$ownWritableState.highWaterMark && - // The names asked for since may have been accessors of the program's that sent the head. - this[headerStateSymbol] !== NodeHTTPHeaderState.sent - ) { - const renderedHeaders = renderNativeHeaders(this, exchange, true); - flags = exchange.flags; - if (this[headerStateSymbol] === NodeHTTPHeaderState.sent) { - // An accessor among the names that the head is made of has sent the head, or written a chunk that is held. Native code refuses the second head, as on the full road. - ("unlikely"); - sendWhatIsHeld(this); - handle.writeHead( - statusCodeToSend(this), - statusMessageToSend(this), - renderedHeaders as string[], - renderedAutoHeaders, - renderedKeepAliveSecs, - ); - } - // The head counts as sent, so what is assigned to statusCode or statusMessage from here on has no effect on the wire. - socket.$heldChunk = new HeldChunk( - exchange, - chunk, - renderedHeaders, - renderedAutoHeaders, - renderedKeepAliveSecs, - statusCodeToSend(this), - statusMessageToSend(this), - ); - this[kBytesBuffered] = - ((exchange.flags & ExchangeFlags.may_count_bytes) !== 0 || - !(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(this, ServerResponse)) - ? (this[kBytesBuffered] ?? 0) - : 0) + Buffer.byteLength(chunk); - this[kAccountingFlushScheduled] = true; - this[headerStateSymbol] = NodeHTTPHeaderState.sent; - const held = ExchangeFlags.holds_chunk | ExchangeFlags.owes_flush | ExchangeFlags.counts_bytes; - if ((exchange.flags & ExchangeFlags.tick_queued) !== 0) exchange.flags |= held; - else if (ticksAreUnseen()) { - queueExchangeTick(exchange, exchange.flags | held); - // Nothing was queued: the process exits. - if (exchange.queuedTick === ring.write) sendHeldChunk(this, exchange); - } else { - // What was asked since has put a process.nextTick of the program's own in place: it gets the tick of the full road. - ("unlikely"); - exchange.flags = (exchange.flags | held) & ~ExchangeFlags.owes_flush; - sendHeldChunk(this, exchange); - queueTick1(flushWriteAccountingNT, this); - } - return true; - } - } - if ((exchange.flags & ExchangeFlags.holds_chunk) !== 0) sendHeldChunk(this, exchange); - - connection = exchange.connection; - // Behind the head, the native response of a connection drops what comes after the close and refuses what comes after the end by itself, so it is asked only where it has done one of the two. - const asksHandleAfterwards = - this[headerStateSymbol] === NodeHTTPHeaderState.sent && - connection !== null && - connection.$isOpen && - !this.finished && - !this.destroyed; - if (!asksHandleAfterwards && (handle.flags & NodeHTTPResponseFlags.closed_or_completed) !== 0) { - return false; - } + if (!this._hasBody) { + ("unlikely"); + if ((chunk || chunk === "") && this[kRejectNonStandardBodyWrites]) throw $ERR_HTTP_BODY_NOT_ALLOWED(); + // Node's write_(): the chunk is dropped, and the head waits for end() or flushHeaders(). + this._implicitHeader(); + if (callback) process.nextTick(callback); + return true; + } - let strict: number | undefined; - if (this.strictContentLength) { - ("unlikely"); - strict = contentLengthToKeepTo(this, headerState, false); - } - const hasBody = this._hasBody; - let onWritable: (() => void) | undefined; - if (hasBody) { - const bound = exchange.onWritable; - onWritable = bound !== null ? bound : (exchange.onWritable = allowWritesToContinue.bind(this)); - } + const result = writeToHandle(this, exchange, handle, chunk, encoding, strictContentLength(this, hadHeader, false)); - let result: number; - if (this[headerStateSymbol] !== NodeHTTPHeaderState.sent) { - result = writeHeadAndFirstChunk(this, exchange, handle, chunk, encoding, onWritable, strict); - } else if (asksHandleAfterwards) { - try { - result = handle.write(chunk, encoding, onWritable, strict); - } catch (error) { - // Neither finished nor destroyed at the entry: where it is now, an accessor of the program's has ended or destroyed it amid this call, behind the place where the full road asks the handle. - if ( - (handle.flags & NodeHTTPResponseFlags.closed_or_completed) !== 0 && - connection!.$isOpen && - !this.finished && - !this.destroyed - ) { - return false; - } - throw error; - } - if ( - result === 0 && - (handle.flags & NodeHTTPResponseFlags.closed_or_completed) !== 0 && - connection!.$isOpen && - !this.finished && - !this.destroyed - ) { - return false; - } - } else { - result = handle.write(chunk, encoding, onWritable, strict); - } + if (result < 0) { + ("unlikely"); + // Bound only for a write that the transport didn't take whole. + handle.onwritable = exchange.onWritable ??= allowWritesToContinue.bind(this); + // The write was buffered due to backpressure. Its callback waits until the write goes through. + if (callback) addPendingCallback(this, callback); + return false; + } - if (result < 0 && hasBody) { - ("unlikely"); - if (callback) addPendingCallback(this, callback); - return false; - } + noteResponseWrite(this); + this._callPendingCallbacks(); + if (callback && !hasSentForCallback(this, handle, callback)) return false; - // The guards are asked and the name is not read: it is read once, by the call. + // Like in Node, bytes handed off in this turn count against the high water mark even if the transport took them: at the mark write() returns false and 'drain' follows. + let written = 0; + if (chunk) { + written = typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.length; if ( - (exchange.flags & ExchangeFlags.has_pending_callbacks) !== 0 || - !(IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(this, "_callPendingCallbacks")) + written > 0 && + ((IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(this, "getHeader")) || this.getHeader === outgoingGetHeader + ? !hasHeaderUnderKey(exchange, this, "content-length", HeaderBits.content_length) + : this.getHeader("content-length") === undefined) ) { - ("unlikely"); - this._callPendingCallbacks(); - } - if (callback && !hasSentForCallback(this, handle, hasBody, callback)) return false; - } - - let written = 0; - if (hasChunk) { - const length = typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk!.length; - if (typeof length === "number" && length > 0) { - written = length; - if ( - (IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(this, "getHeader")) || - this.getHeader === outgoingGetHeader - ) { - if (!hasHeaderUnderKey(exchange, this, "content-length", HeaderBits.content_length)) { - written += ((31 - Math.clz32(written)) >> 2) + 5; - } - } else { - ("unlikely"); - if (this.getHeader("content-length") === undefined) written += ((31 - Math.clz32(written)) >> 2) + 5; - } + // Chunked framing overhead: \r\n\r\n + written += ((31 - Math.clz32(written)) >> 2) + 5; } } - if (written > 0 && exchange.isReplaying !== true) { - const counts = + if (written > 0) { + this[kBytesBuffered] = (exchange.flags & ExchangeFlags.may_count_bytes) !== 0 || - !(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(this, ServerResponse)); - this[kBytesBuffered] = counts ? (this[kBytesBuffered] ?? 0) + written : written; - if (!counts || !this[kAccountingFlushScheduled]) { - this[kAccountingFlushScheduled] = true; - if ((exchange.flags & ExchangeFlags.tick_queued) !== 0) { - exchange.flags |= ExchangeFlags.counts_bytes | ExchangeFlags.owes_flush; - } else { - exchange.flags |= ExchangeFlags.counts_bytes; - queueTick1(flushWriteAccountingNT, this); - } - } - } else if ( - (exchange.flags & ExchangeFlags.may_count_bytes) === 0 && - IS_COMPILED_AHEAD_OF_TIME && - $isPlainInstanceOf(this, ServerResponse) - ) - return true; + !(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(this, ServerResponse)) + ? (this[kBytesBuffered] ?? 0) + written + : written; + scheduleWriteAccountingFlush(this); + } + // An empty chunk reports the count too: Node returns state.length < highWaterMark for every write. const buffered = this[kBytesBuffered]; - if (!buffered) return true; - if ( - connection !== null && - this[kSocket] === connection && - ((IS_COMPILED_AHEAD_OF_TIME && - $findsDeclaredMember(this, "writableHighWaterMark") && - $findsDeclaredMember(this, "socket") && - ObjectGetPrototypeOf(connection) === serverSocketPrototype && - $findsDeclaredMember(connection, "writableHighWaterMark")) || - reachesWritableHighWaterMark(this)) - ) { - return buffered < (connection.$ownWritableState.highWaterMark ?? this[kHighWaterMark] ?? 64 * 1024); - } - return buffered < this.writableHighWaterMark; + return !buffered || buffered < this.writableHighWaterMark; }; function bytesBufferedOf(res: $Node.ServerResponse): number { return res[kBytesBuffered] ?? 0; } -function writeHeadAndChunk( - res: $Node.ServerResponse, - exchange: Exchange | undefined, - handle: $Node.NodeHTTPResponse | $Node.Http1ResponseHandle, - chunk: $Node.HttpChunk | null | undefined, - encoding: string | null | undefined, - onWritable: $Node.HttpWriteCallback | null | undefined, - strict: number | undefined, -): number { - let result = 0; - handle.cork(() => { - writeHeadToHandle(res, exchange, handle); - // If handle.writeHead throws, headersSent stays false. - storeHeaderState(res, exchange, NodeHTTPHeaderState.sent); - result = handle.write(chunk, encoding, onWritable, strict); - }); - return result; -} - // A write callback reports that its bytes reached the socket: they are uncorked before the callback can destroy the response. function hasSentForCallback( res: $Node.ServerResponse, - handle: $Node.NodeHTTPResponse | $Node.Http1ResponseHandle, - hasBody: boolean, + handle: $Node.NodeHTTPResponse, callback: $Node.HttpWriteCallback, ): boolean { handle.flushHeaders(); - if (hasBody && handle.bufferedAmount > 0) { + if (handle.bufferedAmount > 0) { addPendingCallback(res, callback); handle.onwritable = allowWritesToContinue.bind(res); return false; @@ -6419,24 +5389,23 @@ function addPendingCallback(res: $Node.ServerResponse, callback: $Node.HttpWrite res[kPendingCallbacks] = [callback]; return; } - const callbacks = res[kPendingCallbacks]!; + const callbacks = res[kPendingCallbacks]; if (callbacks === noCallbacks) res[kPendingCallbacks] = [callback]; else callbacks.push(callback); } -// advanceResponsePipeline replays buffered ops through these; patched res.write/res.end (compression middleware) must not see them again. -const ServerResponsePrototypeWrite: $Node.ServerResponse["write"] = ServerResponse.prototype.write; -const ServerResponsePrototypeEnd: $Node.ServerResponse["end"] = ServerResponse.prototype.end; - function flushWriteAccountingNT(res: $Node.ServerResponse) { - const exchange = res.$exchange; - if (exchange !== undefined && exchange.response === res) exchange.flags &= ~ExchangeFlags.owes_flush; 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) > 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: $Node.ServerResponse) { const exchange = res.$exchange; @@ -6456,25 +5425,21 @@ const responseCallPendingCallbacks = (ServerResponse.prototype._callPendingCallb (exchange.flags & ExchangeFlags.has_pending_callbacks) === 0 ) return; - const originalLength = this[kPendingCallbacks]!.length; + const originalLength = this[kPendingCallbacks].length; for (let i = 0; i < originalLength; ++i) { - process.nextTick(this[kPendingCallbacks]![i]); + process.nextTick(this[kPendingCallbacks][i]); } - if (this[kPendingCallbacks]!.length == originalLength) { + if (this[kPendingCallbacks].length == originalLength) { // If the array wasn't somehow appended to, just set it to an empty array this[kPendingCallbacks] = []; } else { // Otherwise, splice it. - this[kPendingCallbacks]!.splice(0, originalLength); + this[kPendingCallbacks].splice(0, originalLength); } }); -ServerResponse.prototype._finish = function (this: $Node.ServerResponse) { - this.emit("prefinish"); -}; - const responseDetachSocket = (ServerResponse.prototype.detachSocket = function ( this: $Node.ServerResponse, socket: $Node.HttpSocket | null | undefined, @@ -6495,33 +5460,34 @@ const responseDetachSocket = (ServerResponse.prototype.detachSocket = function ( socket.removeListener("close", onServerResponseClose); } socket._httpMessage = null; - // Drop the request reference so a kept-alive idle connection does not - // pin the last request in memory (Node.js frees the parser's incoming - // reference when the response finishes). - if (socket[kRequest] === this.req) { - socket[kRequest] = undefined; - } + if (this.destroyed && socket.destroyed) socket[kDetachedWhileClosing] = this; } this.socket = null; }); const OriginalImplicitHeadFn = (ServerResponse.prototype._implicitHeader = function (this: $Node.ServerResponse) { - if (this.headersSent) return; - this.writeHead(this.statusCode); + if (this._header) return; + if (this.writeHead === OriginalWriteHeadFn) writeHead(this, this.statusCode, undefined, undefined, false); + else this.writeHead(this.statusCode); }); +// A response without a native handle (new ServerResponse(req) and assignSocket()) writes through OutgoingMessage, which keeps its own accounting. +const outgoingWritableCorked = Object.getOwnPropertyDescriptor(OutgoingMessagePrototype, "writableCorked")!.get; +const outgoingWritableNeedDrain = Object.getOwnPropertyDescriptor(OutgoingMessagePrototype, "writableNeedDrain")!.get; +const outgoingWritableFinished = Object.getOwnPropertyDescriptor(OutgoingMessagePrototype, "writableFinished")!.get; +const outgoingWritableLength = Object.getOwnPropertyDescriptor(OutgoingMessagePrototype, "writableLength")!.get; + Object.defineProperty(ServerResponse.prototype, "writableNeedDrain", { get(this: $Node.ServerResponse) { // 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; return ( - !this.destroyed && - !this.finished && - ((handle?.bufferedAmount ?? 0) !== 0 || - handle?.onwritable !== undefined || - bytesBufferedOf(this) >= this.writableHighWaterMark || - (this[kPipelinedQueuedState]?.needDrain ?? false)) + handle.bufferedAmount !== 0 || + handle.onwritable !== undefined || + bytesBufferedOf(this) >= this.writableHighWaterMark ); }, }); @@ -6529,88 +5495,110 @@ Object.defineProperty(ServerResponse.prototype, "writableNeedDrain", { Object.defineProperty(ServerResponse.prototype, "writableFinished", { get(this: $Node.ServerResponse) { 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(this: $Node.ServerResponse) { + const handle = this[kHandle]; + if (!handle) return outgoingWritableLength!.$call(this); if (this.writableFinished) return 0; - sendWhatIsHeld(this); // 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 bytesBufferedOf(this) + (this[kHandle]?.bufferedAmount ?? 0) + (this[kPipelinedQueuedState]?.bytes ?? 0); - }, -}); - -Object.defineProperty(ServerResponse.prototype, "writableHighWaterMark", { - get(this: $Node.ServerResponse) { - // Like Node.js's OutgoingMessage: the socket's high water mark when one - // is assigned (the stream default differs by platform), otherwise the - // OutgoingMessage default set by the constructor. - return this.socket?.writableHighWaterMark ?? this[kHighWaterMark] ?? 64 * 1024; - }, -}); - -Object.defineProperty(ServerResponse.prototype, "closed", { - get(this: $Node.ServerResponse) { - return this._closed; + // has buffered. + return bytesBufferedOf(this) + handle.bufferedAmount; }, }); ServerResponse.prototype._send = function ( this: $Node.ServerResponse, data: $Node.HttpChunk, - encoding?: string | null, + encoding?: string | null | $Node.HttpWriteCallback, callback?: $Node.HttpWriteCallback | null, _byteLength?: number, ) { const exchange = this.$exchange; if (exchange === undefined || exchange.response !== this) spillStateReachedThrough(this); - else sendWhatIsHeld(this); const handle = this[kHandle]; if (!handle) { return OutgoingMessagePrototype._send.$apply(this, arguments); } - const strict = strictContentLength(this, this[headerStateSymbol], false); - if (this[headerStateSymbol] !== NodeHTTPHeaderState.sent) { - writeHeadAndChunk(this, exchange, handle, data, encoding, callback, strict); - } else { - handle.write(data, encoding, callback, strict); + if (typeof encoding === "function") { + callback = encoding; + encoding = null; } + const hadHeader = !!this._header; + if (!hadHeader) validateImplicitHead(this); + const result = writeToHandle(this, exchange, handle, data, encoding, strictContentLength(this, hadHeader, false)); + if (result < 0 && callback) handle.onwritable = callback; }; +// The first write takes the head with it, in one native call that throws before it sends anything. +function writeToHandle( + res: $Node.ServerResponse, + exchange: Exchange | undefined, + handle: $Node.NodeHTTPResponse, + chunk: $Node.HttpChunk | null | undefined, + encoding: string | null | undefined, + strict: number | undefined, +): number { + if (res._headerSent) return handle.write(chunk, encoding, undefined, strict); + const renderedHeaders = renderNativeHeaders(res, exchange, exchange !== undefined && exchange.response === res); + const result = handle.writeHeadAndWrite( + statusCodeToSend(res), + statusMessageToSend(res), + renderedHeaders as string[], + chunk, + encoding, + undefined, + strict, + renderedAutoHeaders, + renderedKeepAliveSecs, + ); + res._header = " "; + res._headerSent = true; + return result; +} + const OriginalWriteHeadFn = (ServerResponse.prototype.writeHead = function ( this: $Node.ServerResponse, statusCode: number, statusMessage?: string | $Node.HttpHeadersInit | null, headers?: $Node.HttpHeadersInit | null, ) { - const exchange = this.$exchange; + return writeHead(this, statusCode, statusMessage, headers, true); +}); + +function writeHead( + self: $Node.ServerResponse, + statusCode: number, + statusMessage: string | $Node.HttpHeadersInit | null | undefined, + headers: $Node.HttpHeadersInit | null | undefined, + explicit: boolean, +) { + const exchange = self.$exchange; if ( exchange === undefined || - exchange.response !== this || - !(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(this, ServerResponse)) || - !reachesHeadersSent(this) + exchange.response !== self || + !(IS_COMPILED_AHEAD_OF_TIME && $isPlainInstanceOf(self, ServerResponse)) ) { ("unlikely"); - return writeHeadInFull.$call(this, statusCode, statusMessage, headers); + return writeHeadInFull(self, statusCode, statusMessage, headers, explicit); + } + if (self._header) { + throw $ERR_HTTP_HEADERS_SENT("writeHead"); } - if (isHTTPServerHeaderStateSentOrAssigned(this[headerStateSymbol])) throw $ERR_HTTP_HEADERS_SENT("writeHead"); - _writeHead(statusCode, statusMessage, headers, this, exchange); + _writeHead(statusCode, statusMessage, headers, self, explicit, exchange); // Node.js renders the header block immediately in writeHead(), so mutating // res.statusCode/statusMessage afterwards has no effect on the wire. // Headers are flushed lazily here, so snapshot the status line now. - this[kSnapshotStatusCode] = this.statusCode; - this[kSnapshotStatusMessage] = this.statusMessage; + self[kSnapshotStatusCode] = self.statusCode; + self[kSnapshotStatusMessage] = self.statusMessage; exchange.flags |= ExchangeFlags.took_head; // Node's writeHead() freezes body framing: _storeHeader runs with _contentLength null and @@ -6619,34 +5607,36 @@ const OriginalWriteHeadFn = (ServerResponse.prototype.writeHead = function ( if ( (((exchange.flags & ExchangeFlags.may_be_implicit_from_end) === 0 && IS_COMPILED_AHEAD_OF_TIME && - $isPlainInstanceOf(this, ServerResponse)) || - !this[kImplicitHeaderFromEnd]) && + $isPlainInstanceOf(self, ServerResponse)) || + !self[kImplicitHeaderFromEnd]) && // Behind the guards' yes no accessor of the program's lies under the name, so nobody sees the read. They say yes over a function that the library has made and the program has laid. - (IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(this, "hasHeader") && this.hasHeader === outgoingHasHeader - ? !hasHeaderUnderKey(exchange, this, "content-length", HeaderBits.content_length) && - !hasHeaderUnderKey(exchange, this, "transfer-encoding", HeaderBits.transfer_encoding) - : !this.hasHeader("content-length") && !this.hasHeader("transfer-encoding")) + (IS_COMPILED_AHEAD_OF_TIME && $findsDeclaredMember(self, "hasHeader") && self.hasHeader === outgoingHasHeader + ? !hasHeaderUnderKey(exchange, self, "content-length", HeaderBits.content_length) && + !hasHeaderUnderKey(exchange, self, "transfer-encoding", HeaderBits.transfer_encoding) + : !self.hasHeader("content-length") && !self.hasHeader("transfer-encoding")) ) { - this[kFramingFrozenChunked] = true; + self[kFramingFrozenChunked] = true; } - this[headerStateSymbol] = NodeHTTPHeaderState.assigned; - - // Standalone responses (no native handle, e.g. new ServerResponse(req) + - // assignSocket(writable)) write through the OutgoingMessage machinery, so - // render the header block immediately like Node.js does. - if (!this[kHandle] && !this._header) { + if (self[kHandle]) { + // The handle renders the head when it sends it. + self._header = " "; + } else { ("unlikely"); - storeHeadWithoutHandle(this, exchange); + storeHeadWithoutHandle(self, exchange); } - return this; -}); + return self; +} function storeHeadWithoutHandle(res: $Node.ServerResponse, exchange: Exchange | undefined): void { - const statusLine = `HTTP/1.1 ${res.statusCode} ${res.statusMessage}\r\n`; if (exchange !== undefined) spillHeaders(exchange); - res._storeHeader(statusLine, res[kOutHeaders]); + if (res._expect_continue && !res._sent100) res.shouldKeepAlive = false; + // Node says keep-alive here, and after a Transfer-Encoding without "chunked" it keeps the connection too. + const endsAtClose = bodyEndsAtClose(res); + if (endsAtClose) res.shouldKeepAlive = false; + res._storeHeader(`HTTP/1.1 ${res.statusCode} ${res.statusMessage}\r\n`, res[kOutHeaders], true); + if (endsAtClose) res._last = true; } // The short roads let go of a socket without a look at its listeners. Only detachSocket() removes the one that assignSocket() adds. @@ -6664,7 +5654,7 @@ ServerResponse.prototype.assignSocket = function (this: $Node.ServerResponse, so throw $ERR_HTTP_SOCKET_ASSIGNED(); } socket._httpMessage = this; - socket.once("close", onServerResponseClose); + socket.on("close", onServerResponseClose); leaveShortRoads(this); this.socket = socket; this.emit("socket", socket); @@ -6672,31 +5662,14 @@ ServerResponse.prototype.assignSocket = function (this: $Node.ServerResponse, so this._flush(); }; -// Backed by real storage (the native response handle exposes no such flag): -// the standalone _storeHeader path's 204/304 handling assigns it, graceful -// shutdown helpers clear it on in-flight responses, and renderNativeHeaders -// consults it for the Connection header and close-on-finish. -Object.defineProperty(ServerResponse.prototype, "shouldKeepAlive", { - get(this: $Node.ServerResponse) { - return this[kShouldKeepAlive] ?? true; - }, - set(this: $Node.ServerResponse, value) { - this[kShouldKeepAlive] = value; - }, -}); - ServerResponse.prototype.destroy = function (this: $Node.ServerResponse, err?: Error | null) { if (this.destroyed) return this; - sendWhatIsHeld(this); const handle = this[kHandle]; this.destroyed = true; // Like Node.js's OutgoingMessage#destroy: remember the error (even when it // is undefined) so `res.errored` reports it, and forward it to the socket. this[kErrored] = err; - // A response that is still queued behind an in-flight pipelined response - // does not own the connection: destroying it must not touch the socket - // (Node.js destroys a socket-less OutgoingMessage without side effects). - if (handle && this[kPipelinedQueuedState] === undefined) { + if (handle) { handle.abort(); } const socket = this[kSocket]; @@ -6720,11 +5693,26 @@ const responseEmit = (ServerResponse.prototype.emit = function (this: $Node.Serv queueTick2(emitResponseFinishedThenClose, this, callback); return false; } + // The callbacks of what it couldn't send run ahead of 'close'. An heir of the prototype that this constructor didn't make has none. + const callbacks = this[kPendingCallbacks]; + if (callbacks !== undefined) { + const pending = callbacks.length !== 0; + if (pending) failPendingWriteCallbacks(this, undefined); + if (this[kFailHeldWrites]() || pending) { + ("unlikely"); + process.nextTick(emitClose, this); + return false; + } + } settleDispatchAtClose(this); } return plainStream.emit.$apply(this, arguments); }); +function emitClose(res: $Node.ServerResponse) { + res.emit("close"); +} + function emitResponseFinishedThenClose(res: $Node.ServerResponse, callback: $Node.HttpWriteCallback | null) { emitResponseFinished(res, callback); res.emit("close"); @@ -6733,28 +5721,22 @@ function emitResponseFinishedThenClose(res: $Node.ServerResponse, callback: $Nod ServerResponse.prototype.flushHeaders = function (this: $Node.ServerResponse) { const exchange = this.$exchange; if (exchange === undefined || exchange.response !== this) spillStateReachedThrough(this); - else sendWhatIsHeld(this); - if (this[headerStateSymbol] === NodeHTTPHeaderState.sent) return; // Should be idempotent. - if (this[headerStateSymbol] !== NodeHTTPHeaderState.assigned) this._implicitHeader(); - - if (this[kPipelinedQueuedState] !== undefined) { - // Queued pipelined response: its headers go out when it is assigned the - // socket (advanceResponsePipeline) - nothing can be flushed before then. - return; - } - const handle = this[kHandle]; - if (handle) { - if (this[headerStateSymbol] === NodeHTTPHeaderState.assigned) { - storeHeaderState(this, exchange, NodeHTTPHeaderState.sent); - writeHeadToHandle(this, exchange, handle); - } - handle.flushHeaders(); - } else { - // Standalone path: _storeHeader rendered this._header; _send('') pushes - // it to the assigned socket like OutgoingMessage.flushHeaders does. - this._send(""); + if (!this._header) { + if (this._implicitHeader === OriginalImplicitHeadFn) this.writeHead(this.statusCode); + else this._implicitHeader(); } + if (!handle) return OutgoingMessagePrototype.flushHeaders.$call(this); + // Should be idempotent. + if (this._headerSent) 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; + + this._headerSent = true; + writeHeadToHandle(this, exchange, handle); + handle.flushHeaders(); + noteResponseWrite(this); }; function updateHasBody(response: $Node.ServerResponse, statusCode: number) { @@ -6777,15 +5759,8 @@ function updateHasBody(response: $Node.ServerResponse, statusCode: number) { // status writeHead() picks. } -function callWriteHeadIfObservable( - self: $Node.ServerResponse, - headerState: NodeHTTPHeaderState, - fromEnd?: boolean, -): boolean { - if ( - headerState === NodeHTTPHeaderState.none && - !(self.writeHead === OriginalWriteHeadFn && self._implicitHeader === OriginalImplicitHeadFn) - ) { +function callWriteHeadIfObservable(self: $Node.ServerResponse, fromEnd?: boolean): boolean { + if (!self._header && !(self.writeHead === OriginalWriteHeadFn && self._implicitHeader === OriginalImplicitHeadFn)) { ("unlikely"); callObservableWriteHead(self, fromEnd); return true; @@ -6809,7 +5784,17 @@ function callObservableWriteHead(self: $Node.ServerResponse, fromEnd: boolean | } } +// A write that only reached the backpressure buffer doesn't count, so a peer that stopped reading still times out. +function noteResponseWrite(res: $Node.ServerResponse) { + const socket = res[kSocket] as $Node.HttpServerSocket | null; + 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] = timerClockMs(); +} + function allowWritesToContinue(this: $Node.ServerResponse) { + noteResponseWrite(this); this._callPendingCallbacks(); this.emit("drain"); } @@ -6821,7 +5806,7 @@ function failPendingWriteCallbacks(res: $Node.ServerResponse, error: Error | nul res[kPendingCallbacks] = []; const failure = error ?? $ERR_STREAM_DESTROYED("write"); for (let i = 0; i < length; i++) { - process.nextTick(callbacks![i], failure); + process.nextTick(callbacks[i], failure); } } @@ -6854,12 +5839,6 @@ function storeHTTPOptions(this: $Node.HttpServer, options: $Node.HttpServerOptio storeRarely(this, "httpValidation", httpValidation); } - // Node passes options.highWaterMark through to net.Server, which applies it - // to every accepted connection socket (and from there to req/res streams). - this[kHighWaterMark] = options.highWaterMark; - - this[kUniqueHeaders] = parseUniqueHeadersOption(options.uniqueHeaders); - const requestTimeout = options.requestTimeout; if (requestTimeout !== undefined) { validateInteger(requestTimeout, "requestTimeout", 0); @@ -6951,7 +5930,6 @@ function spillStateReachedThrough(receiver: $Node.ServerResponse): void { if (IS_COMPILED_AHEAD_OF_TIME) spillHeadersReachedThrough(receiver); spillHeaders(exchange); const flags = exchange.flags; - if ((flags & ExchangeFlags.holds_chunk) !== 0) sendHeldChunk(exchange.response, exchange); if ((flags & ExchangeFlags.has_stand_in) === 0) { exchange.flags |= ExchangeFlags.has_stand_in; if ((flags & ExchangeFlags.has_pending_callbacks) === 0) { @@ -6968,19 +5946,10 @@ function storeRarely(receiver: object, key: symbol | string, value: unknown): vo (receiver as any)[key] = value; } -function storeMustClose(res: $Node.ServerResponse, exchange: Exchange | undefined): void { - res[kMustCloseConnection] = true; - if (exchange !== undefined) exchange.flags |= ExchangeFlags.must_close; -} - -function storeHeaderState(res: $Node.ServerResponse, exchange: Exchange | undefined, state: NodeHTTPHeaderState): void { - res[headerStateSymbol] = state; -} - function writeHeadToHandle( res: $Node.ServerResponse, exchange: Exchange | undefined, - handle: $Node.NodeHTTPResponse | $Node.Http1ResponseHandle, + handle: $Node.NodeHTTPResponse, ): void { const renderedHeaders = renderNativeHeaders(res, exchange, exchange !== undefined && exchange.response === res); const statusCode = statusCodeToSend(res); @@ -6991,172 +5960,177 @@ function writeHeadToHandle( function endInFull( res: $Node.ServerResponse, exchange: Exchange | undefined, - chunk: $Node.HttpChunk | null | undefined, - encoding: string | null | undefined, - callback: $Node.HttpWriteCallback | undefined, + chunk: $Node.HttpChunk | null | $Node.HttpWriteCallback | undefined, + encoding: string | null | $Node.HttpWriteCallback | undefined, + callback: $Node.HttpWriteCallback | null | undefined, ) { if (exchange === undefined || exchange.response !== res) spillStateReachedThrough(res); const handle = res[kHandle]; - - const droppedChunk = !!chunk && res.destroyed && !res.finished; - if (droppedChunk) chunk = undefined; - - if (hasServerResponseFinished(res, chunk, callback, true)) { - return res; + // A late end() gets the same answer whatever carries the response. + if (!handle || res.finished) { + return OutgoingMessagePrototype.end.$call(res, toOutgoingChunk(res, chunk), encoding, callback); } - const queuedState = res[kPipelinedQueuedState]; - if (queuedState !== undefined) { - return bufferPipelinedEnd(res, queuedState, chunk, encoding, callback); + if ($isCallable(chunk)) { + callback = chunk; + chunk = undefined; + encoding = undefined; + } else if ($isCallable(encoding)) { + callback = encoding; + encoding = undefined; + } else if (!$isCallable(callback)) { + callback = undefined; } - if (!handle) { - return OutgoingMessagePrototype.end.$call(res, chunk, encoding, callback); - } + // Node's write_() drops the chunk of a destroyed response before _implicitHeader(), and end() goes on: https://github.com/nodejs/node/blob/v26.3.0/lib/_http_outgoing.js#L951-L964 + const droppedChunk = !!chunk && res.destroyed; + if (droppedChunk) chunk = undefined; - if (res[headerStateSymbol] === NodeHTTPHeaderState.none) { - updateHasBody(res, (res.statusCode = validateStatusCode(res.statusCode))); - } + if (!res._header) validateImplicitHead(res); if (chunk && !res._hasBody) { if (res[kRejectNonStandardBodyWrites]) { throw $ERR_HTTP_BODY_NOT_ALLOWED(); } else { + // node.js just ignores the write in this case chunk = undefined; } } + // Trailers follow the last chunk (RFC 9112 7.1.2), so they need chunked framing. Node drops them with a Content-Length, on HTTP/1.0 and without a body. const trailer = res._trailer; - if (trailer) sendTrailers(res, trailer); + if (trailer && res._hasBody && willBeChunked(res)) { + res.socket?.[kHandle]?.setResponseTrailers(trailer); + } - const headerState = res[headerStateSymbol]; - if (!droppedChunk) callWriteHeadIfObservable(res, headerState, true); - return endInFullBehindHead(res, exchange, handle, chunk, encoding, callback, droppedChunk, headerState); + if (!droppedChunk) callWriteHeadIfObservable(res, true); + return endInFullBehindHead(res, exchange, handle, chunk, encoding, callback, droppedChunk); } function endInFullBehindHead( res: $Node.ServerResponse, exchange: Exchange | undefined, - handle: $Node.NodeHTTPResponse | $Node.Http1ResponseHandle, + handle: $Node.NodeHTTPResponse, chunk: $Node.HttpChunk | null | undefined, encoding: string | null | undefined, - callback: $Node.HttpWriteCallback | undefined, + callback: $Node.HttpWriteCallback | null | undefined, droppedChunk: boolean, - headerState: NodeHTTPHeaderState, ) { const flags = handle.flags; if (!!(flags & NodeHTTPResponseFlags.closed_or_completed)) { - return endWithoutSocket(res, exchange, !droppedChunk || headerState !== NodeHTTPHeaderState.none); + // Node's _implicitHeader() ran unless the chunk was dropped above, so the head counts as sent like on the normal path below. + return endWithoutSocket(res, !droppedChunk || !!res._header); } + // Native end() returns -(length + 1) while part of the body is still draining; 'finish' waits for it. let draining = false; - if (headerState !== NodeHTTPHeaderState.sent) { + // A replaced writeHead() can have sent the head itself, with write(). + if (!res._headerSent) { const renderedHeaders = renderNativeHeaders( res, exchange, exchange !== undefined && exchange.response === res, ) as string[]; - let contentLength: number; - try { - contentLength = handle.writeHeadAndEnd( - statusCodeToSend(res), - statusMessageToSend(res), - renderedHeaders, - chunk, - encoding, - strictContentLength(res, headerState, true), - renderedAutoHeaders, - renderedKeepAliveSecs, - ); - } catch (error) { - return endAfterNativeThrow(res, exchange, handle, error, false); - } - storeHeaderState(res, exchange, NodeHTTPHeaderState.sent); + // It throws before it sends anything. + let contentLength = handle.writeHeadAndEnd( + statusCodeToSend(res), + statusMessageToSend(res), + renderedHeaders, + chunk, + encoding, + strictContentLength(res, true, true), + renderedAutoHeaders, + renderedKeepAliveSecs, + )!; + res._headerSent = true; if (contentLength < 0) { draining = true; contentLength = -contentLength - 1; } res._contentLength = contentLength; } else { + // No data after end() is nothing to do. `flags` was read above in the same tick. if (!(!chunk && flags & NodeHTTPResponseFlags.ended) && !(flags & NodeHTTPResponseFlags.socket_closed)) { - draining = handle.end(chunk, encoding, undefined, strictContentLength(res, headerState, true)) < 0; + draining = handle.end(chunk, encoding, undefined, strictContentLength(res, true, true))! < 0; } } res._header = " "; + noteResponseWrite(res); + uncorkSocketOf(res); + // Like in Node, res.socket stays assigned until the 'finish' machinery detaches it, so a handler can reach it after end(). res.finished = true; - return finishInSteps(res, exchange, handle, draining, callback); + return finishInSteps(res, handle, draining, callback); } function writeInFull( res: $Node.ServerResponse, exchange: Exchange | undefined, - chunk: $Node.HttpChunk | null | undefined, - encoding: string | null | undefined, - callback: $Node.HttpWriteCallback | undefined, + chunk: $Node.HttpChunk | null | $Node.HttpWriteCallback | undefined, + encoding: string | null | $Node.HttpWriteCallback | undefined, + callback: $Node.HttpWriteCallback | null | undefined, ) { if (exchange === undefined || exchange.response !== res) spillStateReachedThrough(res); const handle = res[kHandle]; - - if (hasServerResponseFinished(res, chunk, callback, false)) { - return false; + // So does a late write(). + if (!handle || res.finished || res.destroyed) { + return OutgoingMessagePrototype.write.$call(res, toOutgoingChunk(res, chunk) ?? "", encoding, callback); } - const queuedState = res[kPipelinedQueuedState]; - if (queuedState !== undefined) { - return bufferPipelinedWrite(res, queuedState, chunk, encoding, callback); + if ($isCallable(chunk)) { + callback = chunk; + chunk = undefined; + encoding = undefined; + } else if ($isCallable(encoding)) { + callback = encoding; + encoding = undefined; + } else if (!$isCallable(callback)) { + callback = undefined; } - const headerState = res[headerStateSymbol]; - callWriteHeadIfObservable(res, headerState); - - if (!handle) { - return OutgoingMessagePrototype.write.$call(res, chunk!, encoding, callback); - } - return writeInFullBehindHead(res, exchange, handle, chunk, encoding, callback, headerState); + const hadHeader = !!res._header; + callWriteHeadIfObservable(res); + return writeInFullBehindHead(res, exchange, handle, chunk, encoding, callback, hadHeader); } function writeInFullBehindHead( res: $Node.ServerResponse, exchange: Exchange | undefined, - handle: $Node.NodeHTTPResponse | $Node.Http1ResponseHandle, + handle: $Node.NodeHTTPResponse, chunk: $Node.HttpChunk | null | undefined, encoding: string | null | undefined, - callback: $Node.HttpWriteCallback | undefined, - headerState: NodeHTTPHeaderState, + callback: $Node.HttpWriteCallback | null | undefined, + hadHeader: boolean, ) { - let result = 0; - - if (res[headerStateSymbol] === NodeHTTPHeaderState.none) { - updateHasBody(res, (res.statusCode = validateStatusCode(res.statusCode))); - } - if (chunk && !res._hasBody) { - if (res[kRejectNonStandardBodyWrites]) { - throw $ERR_HTTP_BODY_NOT_ALLOWED(); - } else { - chunk = undefined; - } - } + if (!res._header) validateImplicitHead(res); const flags = handle.flags; if (!!(flags & NodeHTTPResponseFlags.closed_or_completed)) { + // Socket already gone, and 'close' is still to come. + if (callback) { + addPendingCallback(res, callback); + failPendingWriteCallbacks(res, res[kSocket]?.errored); + } return false; } - const strict = strictContentLength(res, headerState, false); - const hasBody = res._hasBody; - const onWritable = hasBody ? allowWritesToContinue.bind(res) : undefined; - - if (res[headerStateSymbol] !== NodeHTTPHeaderState.sent) { - result = writeHeadAndChunk(res, exchange, handle, chunk, encoding, onWritable, strict); - } else { - result = handle.write(chunk, encoding, onWritable, strict); + if (!res._hasBody) { + if ((chunk || chunk === "") && res[kRejectNonStandardBodyWrites]) throw $ERR_HTTP_BODY_NOT_ALLOWED(); + // Node's write_(): the chunk is dropped, and the head waits for end() or flushHeaders(). + res._implicitHeader(); + if (callback) process.nextTick(callback); + return true; } - if (result < 0 && hasBody) { + const result = writeToHandle(res, exchange, handle, chunk, encoding, strictContentLength(res, hadHeader, false)); + + if (result < 0) { + // Bound only for a write that the transport didn't take whole. + handle.onwritable = allowWritesToContinue.bind(res); if (callback) addPendingCallback(res, callback); return false; } + noteResponseWrite(res); res._callPendingCallbacks(); - if (callback && !hasSentForCallback(res, handle, hasBody, callback)) return false; + if (callback && !hasSentForCallback(res, handle, callback)) return false; let written = 0; if (chunk) { @@ -7165,7 +6139,7 @@ function writeInFullBehindHead( written += written.toString(16).length + 4; } } - if (written > 0 && !res[kReplayingPipelinedOps]) { + if (written > 0) { res[kBytesBuffered] = (res[kBytesBuffered] ?? 0) + written; scheduleWriteAccountingFlush(res); } @@ -7175,60 +6149,49 @@ function writeInFullBehindHead( } function writeHeadInFull( - this: $Node.ServerResponse, + self: $Node.ServerResponse, statusCode: number, - statusMessage?: string | $Node.HttpHeadersInit | null, - headers?: $Node.HttpHeadersInit | null, + statusMessage: string | $Node.HttpHeadersInit | null | undefined, + headers: $Node.HttpHeadersInit | null | undefined, + explicit: boolean, ) { - const exchange = this.$exchange; - spillStateReachedThrough(this); - if (this.headersSent) { + const exchange = self.$exchange; + spillStateReachedThrough(self); + if (self._header) { throw $ERR_HTTP_HEADERS_SENT("writeHead"); } _writeHead( statusCode, statusMessage, headers, - this, - exchange !== undefined && exchange.response === this ? exchange : undefined, + self, + explicit, + exchange !== undefined && exchange.response === self ? exchange : undefined, ); if (exchange !== undefined) exchange.flags |= ExchangeFlags.took_head; - this[kSnapshotStatusCode] = this.statusCode; - this[kSnapshotStatusMessage] = this.statusMessage; + self[kSnapshotStatusCode] = self.statusCode; + self[kSnapshotStatusMessage] = self.statusMessage; - if (!this[kImplicitHeaderFromEnd] && !this.hasHeader("content-length") && !this.hasHeader("transfer-encoding")) { - this[kFramingFrozenChunked] = true; + if (!self[kImplicitHeaderFromEnd] && !self.hasHeader("content-length") && !self.hasHeader("transfer-encoding")) { + self[kFramingFrozenChunked] = true; } - this[headerStateSymbol] = NodeHTTPHeaderState.assigned; - - if (!this[kHandle] && !this._header) { - storeHeadWithoutHandle(this, exchange); + if (self[kHandle]) { + self._header = " "; + } else { + storeHeadWithoutHandle(self, exchange); } - return this; + return self; } -// Share the pipelining machinery with internal/http1_server_fallback through -// internal/http instead of the user-visible module exports. -http.responseRoad.flushSocketHeldChunk = flushSocketHeldChunk; http.responseRoad.spillStateReachedThrough = spillStateReachedThrough as (receiver: $Node.OutgoingMessage) => void; http.requestRoad.readsDestroyedUnseen = readsDestroyedUnseen as (socket: $Node.HttpSocket) => boolean; http.requestRoad.resumeUnlessFlowing = resumeSocketUnlessFlowing as ( socket: $Node.HttpSocket, asksWhetherReadable: boolean, ) => void; -http1ServerPipeline.queuePipelinedResponse = queuePipelinedResponse; -http1ServerPipeline.advanceResponsePipeline = advanceResponsePipeline; -http1ServerPipeline.abortQueuedPipelinedResponses = abortQueuedPipelinedResponses; -http1ServerPipeline.lastPipelinedResponse = lastPipelinedResponse; -http1ServerPipeline.maybePauseFallbackReads = maybePauseFallbackReads; -http1ServerPipeline.resumeFallbackReadsOnDrain = resumeFallbackReadsOnDrain; -http1ServerPipeline.finishDrainedResponse = finishDrainedResponse; -http1ServerPipeline.mustCloseConnection = function (res: $Node.ServerResponse) { - return !!res[kMustCloseConnection]; -}; // The program finds these names among the own keys of a response, so it reaches them with a key that is a value. function noteLookUnderPrivateName(this: $Node.ServerResponse): void { @@ -7249,12 +6212,25 @@ if (IS_COMPILED_AHEAD_OF_TIME) { prepareHoldersBeforeLookupsUnder(kBytesBuffered, noteLookUnderPrivateName); prepareHoldersBeforeLookupsUnder(kAccountingFlushScheduled, noteLookUnderPrivateName); prepareHoldersBeforeLookupsUnder(kPendingFinish, noteLookUnderPrivateName); - prepareHoldersBeforeLookupsUnder(kMustCloseConnection, noteLookUnderPrivateName); prepareHoldersBeforeLookupsUnder(kDetachedFrom, noteLookUnderPrivateName); } +// Own properties of https.Server.prototype, captured before user code can patch them (node: lib/https.js#L115-L122). +const serverPrototypeDescriptors: PropertyDescriptorMap = Object.create(null); +for (const key of Reflect.ownKeys(Server.prototype)) { + if (key === "constructor") continue; + serverPrototypeDescriptors[key] = { __proto__: null, ...Object.getOwnPropertyDescriptor(Server.prototype, key) }; +} +http.httpServerParts.initHttpServer = initHttpServer; +http.httpServerParts.serverPrototypeDescriptors = serverPrototypeDescriptors; + export default { Server, ServerResponse, + setupConnectionsTracking, + // An Http2SecureServer that allows HTTP/1 calls these three on itself. + storeHTTPOptions: storeHTTPOptions as (this: $Node.EventEmitter, options: $Node.HttpServerOptions) => void, + _connectionListener: connectionListener as (this: $Node.EventEmitter, socket: $Node.Duplex) => void, + httpServerPreClose: httpServerPreClose as (server: $Node.EventEmitter) => void, kConnectionsCheckingInterval, } as const; diff --git a/src/js/ahead-of-time/node/http.ts b/src/js/ahead-of-time/node/http.ts index e1e2e38fa5c7..ea8090b85273 100644 --- a/src/js/ahead-of-time/node/http.ts +++ b/src/js/ahead-of-time/node/http.ts @@ -1,5 +1,5 @@ // What the compiler reads in place of src/js/node/http.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 00da691f682964c532730a08c0b7c7605ece1e07636025f9a75cd0283c4bbb1e. +// Written against that file with the SHA-256 e711819988cc5c66d009856c4f5973bcfd7e0909a15a207f06610c97f5ddc543. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. const { validateInteger, validateObject } = require("internal/validators"); @@ -9,7 +9,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"); @@ -51,6 +51,7 @@ function get(url?: ClientRequestParameters[0], options?: ClientRequestParameters } const http_exports = { + _connectionListener, Agent, Server, METHODS, diff --git a/src/js/ahead-of-time/node/http2.ts b/src/js/ahead-of-time/node/http2.ts index 8c80783b239c..4131e910cc5e 100644 --- a/src/js/ahead-of-time/node/http2.ts +++ b/src/js/ahead-of-time/node/http2.ts @@ -1,5 +1,5 @@ // What the compiler reads in place of src/js/node/http2.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 77bad1c37fbaade523ae0927321e5309992d5e7fcd6147cc97a79835ea39c5de. +// Written against that file with the SHA-256 3aab1a156ab0092f6c6553adfc526acf1c1b99a3e2031425930fc5daff87e330. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. // Hardcoded module "node:http2" @@ -46,7 +46,6 @@ const bunSocketServerOptions: typeof netSymbols.bunSocketServerOptions = CAN_REP ? (Symbol.for("::bunnetserveroptions::") as typeof netSymbols.bunSocketServerOptions) : netSymbols.bunSocketServerOptions; const kInfoHeaders = Symbol("sent-info-headers"); -const kStrictSingleValueFields = Symbol("strictSingleValueFields"); const kDeferWriteCallback = Symbol("deferWriteCallback"); const kTransportClosed = Symbol("transportClosed"); const kProxySocket = Symbol("proxySocket"); @@ -81,8 +80,8 @@ type Http2ConnectOptions = { ALPNProtocols?: string[]; maxConcurrentStreams?: number; maxOutstandingSettings?: number; - maxReservedRemoteStreams?: number; maxOriginSetSize?: number; + maxReservedRemoteStreams?: number; remoteCustomSettings?: number[]; strictFieldWhitespaceValidation?: boolean; strictSingleValueFields?: boolean; @@ -99,8 +98,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; @@ -151,11 +152,15 @@ function throwSettingTypeError(name: string, value: any) { throw err; } +// The one check of SETTINGS values. The native session trusts it. function validateSettings(settings: any) { + if (settings === undefined) return; if (typeof settings !== "object" || settings === null || $isArray(settings)) { throw $ERR_INVALID_ARG_TYPE("settings", "object", settings); } - + validateSettingValues(settings); +} +function validateSettingValues(settings: any) { if (settings.headerTableSize !== undefined) { const v = settings.headerTableSize; if (typeof v !== "number" || v < 0 || v > kMaxInt || Number.isNaN(v)) { @@ -226,7 +231,8 @@ function validateSettings(settings: any) { } for (const key of keys) { const id = Number(key); - if (!Number.isInteger(id) || id < 0 || id > 0xffff) { + // Only a plain decimal id, which is what the native session parses. + if (!Number.isInteger(id) || id < 0 || id > 0xffff || `${id}` !== key) { throwSettingRangeError(key, cs[key]); } const val = cs[key]; @@ -237,10 +243,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); @@ -370,6 +372,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; @@ -389,6 +392,7 @@ 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. // 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"); @@ -409,7 +413,10 @@ let priorityDeprecationWarned = false; 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. @@ -418,6 +425,8 @@ const kGoawayCode = Symbol("goawayCode"); const kGoawayLastStreamID = Symbol("goawayLastStreamID"); const kReleaseUnannouncedStream = Symbol("releaseUnannouncedStream"); const kGoawaySent = Symbol("goawaySent"); +// Set by destroySessionInternally(). +const kInternalDestroy = Symbol("internalDestroy"); // Node's socketOnError: once a GOAWAY has been received the peer is fully // within its rights to drop the connection, so an ECONNRESET behind it is @@ -458,18 +467,6 @@ function emitSessionCloseNT(self: Http2Session, frame: import("./async_hooks").F runInFrame(frame, self.emit, self, "close"); } } -function emitErrorNT(self: Http2Session | Http2Stream, error: Error, 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: Http2Stream) { self.destroy($ERR_HTTP2_OUT_OF_STREAMS()); } @@ -592,9 +589,6 @@ function isPseudoHeader(name: string) { } } -function isConnectionHeaderAllowed(name: string, value: unknown) { - return name !== HTTP2_HEADER_CONNECTION || value === "trailers"; -} let statusConnectionHeaderWarned = false; let statusMessageWarned = false; function statusMessageWarn() { @@ -626,7 +620,7 @@ function assertValidHeader(name: string, value) { if (value === undefined || value === null) { throw $ERR_HTTP2_INVALID_HEADER_VALUE(value, name); } - if (!isConnectionHeaderAllowed(name, value)) { + if (isConnectionSpecificHeader(name, value)) { connectionHeaderMessageWarn(); } } @@ -903,6 +897,7 @@ class Http2ServerResponse extends Stream { validateString(name, "name"); name = StringPrototypeToLowerCase.$call(StringPrototypeTrim.$call(name)); assertValidHeader(name, value); + if (isConnectionSpecificHeader(name, value)) return; this[kTrailers][name] = value; } @@ -962,7 +957,8 @@ class Http2ServerResponse extends Stream { name = StringPrototypeToLowerCase.$call(StringPrototypeTrim.$call(name)); assertValidHeader(name, value); - if (!isConnectionHeaderAllowed(name, value)) { + // Node throws from respond() for all of these but `connection`. They often come from an upstream's HTTP/1.1 response, so drop them all. + if (isConnectionSpecificHeader(name, value)) { return; } @@ -983,7 +979,8 @@ class Http2ServerResponse extends Stream { name = StringPrototypeToLowerCase.$call(StringPrototypeTrim.$call(name)); assertValidHeader(name, value); - if (!isConnectionHeaderAllowed(name, value)) { + // Node throws from respond() for all of these but `connection`. They often come from an upstream's HTTP/1.1 response, so drop them all. + if (isConnectionSpecificHeader(name, value)) { return; } @@ -1236,10 +1233,15 @@ class Http2ServerResponse extends Stream { } const stream = this[kStream]; if (stream.headersSent || this[kState].closed) return false; - stream.additionalHeaders({ - ...(headers || {}), - [HTTP2_HEADER_STATUS]: statusCode, - }); + const outHeaders = { __proto__: null }; + const keys = ObjectKeys(headers || {}); + for (let i = 0; i < keys.length; i++) { + const value = headers![keys[i]]; + if (isConnectionSpecificHeader(StringPrototypeToLowerCase.$call(keys[i]), value)) connectionHeaderMessageWarn(); + else outHeaders[keys[i]] = value; + } + outHeaders[HTTP2_HEADER_STATUS] = statusCode; + stream.additionalHeaders(outHeaders); return true; } @@ -1249,20 +1251,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 = StringPrototypeToLowerCase.$call(StringPrototypeTrim.$call(key)); + 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); } } @@ -1741,44 +1742,12 @@ const { HTTP2_HEADER_SCHEME, HTTP2_HEADER_PATH, HTTP2_HEADER_PROTOCOL, - HTTP2_HEADER_ACCESS_CONTROL_ALLOW_CREDENTIALS, - HTTP2_HEADER_ACCESS_CONTROL_REQUEST_METHOD, - HTTP2_HEADER_AGE, - HTTP2_HEADER_AUTHORIZATION, - HTTP2_HEADER_CONNECTION, - HTTP2_HEADER_CONTENT_ENCODING, HTTP2_HEADER_CONTENT_LENGTH, - HTTP2_HEADER_CONTENT_TYPE, HTTP2_HEADER_COOKIE, HTTP2_HEADER_DATE, - HTTP2_HEADER_ETAG, HTTP2_HEADER_HOST, - HTTP2_HEADER_IF_MODIFIED_SINCE, - HTTP2_HEADER_IF_NONE_MATCH, - HTTP2_HEADER_IF_RANGE, - HTTP2_HEADER_LAST_MODIFIED, - HTTP2_HEADER_LOCATION, - HTTP2_HEADER_RANGE, - HTTP2_HEADER_REFERER, HTTP2_HEADER_SET_COOKIE, - HTTP2_HEADER_UPGRADE_INSECURE_REQUESTS, - HTTP2_HEADER_USER_AGENT, - HTTP2_HEADER_X_CONTENT_TYPE_OPTIONS, HTTP2_HEADER_ORIGIN, - HTTP2_HEADER_ACCESS_CONTROL_MAX_AGE, - HTTP2_HEADER_CONTENT_LANGUAGE, - HTTP2_HEADER_CONTENT_LOCATION, - HTTP2_HEADER_CONTENT_MD5, - HTTP2_HEADER_CONTENT_RANGE, - HTTP2_HEADER_DNT, - HTTP2_HEADER_EXPIRES, - HTTP2_HEADER_FROM, - HTTP2_HEADER_IF_MATCH, - HTTP2_HEADER_IF_UNMODIFIED_SINCE, - HTTP2_HEADER_MAX_FORWARDS, - HTTP2_HEADER_PROXY_AUTHORIZATION, - HTTP2_HEADER_RETRY_AFTER, - HTTP2_HEADER_TK, HTTP2_METHOD_CONNECT, HTTP2_METHOD_DELETE, HTTP2_METHOD_GET, @@ -1799,63 +1768,7 @@ const { // This set is defined strictly by the HTTP/2 specification. Only // :-prefixed headers defined by that specification may be added to // this set. -const kValidPseudoHeaders = new SafeSet([ - HTTP2_HEADER_STATUS, - HTTP2_HEADER_METHOD, - HTTP2_HEADER_AUTHORITY, - HTTP2_HEADER_SCHEME, - HTTP2_HEADER_PATH, - HTTP2_HEADER_PROTOCOL, -]); -const kSingleValueHeaders = new SafeSet([ - HTTP2_HEADER_STATUS, - HTTP2_HEADER_METHOD, - HTTP2_HEADER_AUTHORITY, - HTTP2_HEADER_SCHEME, - HTTP2_HEADER_PATH, - HTTP2_HEADER_PROTOCOL, - HTTP2_HEADER_ACCESS_CONTROL_ALLOW_CREDENTIALS, - HTTP2_HEADER_ACCESS_CONTROL_MAX_AGE, - HTTP2_HEADER_ACCESS_CONTROL_REQUEST_METHOD, - HTTP2_HEADER_AGE, - HTTP2_HEADER_AUTHORIZATION, - HTTP2_HEADER_CONTENT_ENCODING, - HTTP2_HEADER_CONTENT_LANGUAGE, - HTTP2_HEADER_CONTENT_LENGTH, - HTTP2_HEADER_CONTENT_LOCATION, - HTTP2_HEADER_CONTENT_MD5, - HTTP2_HEADER_CONTENT_RANGE, - HTTP2_HEADER_CONTENT_TYPE, - HTTP2_HEADER_DATE, - HTTP2_HEADER_DNT, - HTTP2_HEADER_ETAG, - HTTP2_HEADER_EXPIRES, - HTTP2_HEADER_FROM, - HTTP2_HEADER_HOST, - HTTP2_HEADER_IF_MATCH, - HTTP2_HEADER_IF_MODIFIED_SINCE, - HTTP2_HEADER_IF_NONE_MATCH, - HTTP2_HEADER_IF_RANGE, - HTTP2_HEADER_IF_UNMODIFIED_SINCE, - HTTP2_HEADER_LAST_MODIFIED, - HTTP2_HEADER_LOCATION, - HTTP2_HEADER_MAX_FORWARDS, - HTTP2_HEADER_PROXY_AUTHORIZATION, - HTTP2_HEADER_RANGE, - HTTP2_HEADER_REFERER, - HTTP2_HEADER_RETRY_AFTER, - HTTP2_HEADER_TK, - HTTP2_HEADER_UPGRADE_INSECURE_REQUESTS, - HTTP2_HEADER_USER_AGENT, - HTTP2_HEADER_X_CONTENT_TYPE_OPTIONS, -]); - -function assertValidPseudoHeader(key: string) { - if (!kValidPseudoHeaders.has(key)) { - throw $ERR_HTTP2_INVALID_PSEUDOHEADER(key); - } -} -hideFromStack(assertValidPseudoHeader); +const { kSingleValueFields: kSingleValueHeaders, assertValidPseudoHeader } = require("internal/quic/http2util"); // node's internal/http2/util sessionName() — used for debug output and exposed to the ported // node test suite through the internals object below. @@ -1887,12 +1800,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 { @@ -1941,6 +1856,9 @@ interface FileResponseOptions { offset?: number; length?: number; waitForTrailers?: boolean; + // `respond()` gets these two as they came. + sendDate?: unknown; + paddingStrategy?: unknown; statCheck?: ( this: ServerHttp2Stream, stat: $Node.Stats, @@ -2002,6 +1920,10 @@ abstract class Http2Session extends EventEmitter { get goawayLastStreamID() { return this[kGoawayLastStreamID] || 0; } + goaway(code = NGHTTP2_NO_ERROR, lastStreamID = 0, opaqueData?) { + if (this.destroyed) throw $ERR_HTTP2_INVALID_SESSION(); + sendGoaway(this, code, lastStreamID, opaqueData); + } [EventEmitter.captureRejectionSymbol](err: Error, event: string | symbol, ...args: unknown[]) { switch (event) { case "stream": { @@ -2015,13 +1937,54 @@ abstract class Http2Session extends EventEmitter { } } -function streamErrorFromCode(code: number) { - if (code === 0xe) { - return $ERR_HTTP2_MAX_PENDING_SETTINGS_ACK(); +// goaway() without its check for a destroyed session, which the GOAWAY of destroy() wouldn't pass. +function sendGoaway(session: Http2Session, code: number, lastStreamID: number, opaqueData?: Buffer) { + if (opaqueData !== undefined) { + validateBuffer(opaqueData, "opaqueData"); } + validateInteger(code, "code", 0, kMaxUint32); + validateNumber(lastStreamID, "lastStreamID"); + session[bunHTTP2Native]?.goaway(code, lastStreamID, opaqueData ?? kNoOpaqueData); +} + +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). @@ -2029,6 +1992,7 @@ hideFromStack(streamErrorFromCode); // asserts init/before/after/destroy fire for type 'HTTP2PING'. const { AsyncResource } = require("node:async_hooks"); const kEmptyPingPayload = Buffer.alloc(8); +const kNoOpaqueData = Buffer.alloc(0); function makeHttp2Ping(callback: PingCallback): PingCallback { const resource = new AsyncResource("HTTP2PING"); return function done(err: Error | null, duration?: number, payload?: Buffer) { @@ -2067,16 +2031,23 @@ function createPendingStreamCancelError(cause?: any) { // The native settings object for a server session: session options + the user's settings, with // enablePush forced off only when the caller explicitly provided it (see the RFC 9113 §6.5.2 note // at the construction site). Only explicitly-present settings are serialized by the native layer. +// What the native session reads: the session's options and its initial settings. An option named like a setting counts as one. +function nativeSessionSettings(options) { + const settings = options?.settings; + if (settings !== undefined && (typeof settings !== "object" || settings === null || $isArray(settings))) { + throw $ERR_INVALID_ARG_TYPE("settings", "object", settings); + } + const merged = { ...options, ...settings }; + validateSettingValues(merged); + return merged; +} function serverNativeSettings(options) { - const merged = { ...options, ...options?.settings }; + const merged = nativeSessionSettings(options); if (merged.enablePush !== undefined) merged.enablePush = false; return merged; } function sessionErrorFromCode(code: number) { - if (code === 0xe) { - return $ERR_HTTP2_MAX_PENDING_SETTINGS_ACK(); - } return $ERR_HTTP2_SESSION_ERROR(code); } hideFromStack(sessionErrorFromCode); @@ -2105,7 +2076,8 @@ function validateWindowSize(windowSize: number) { hideFromStack(validateWindowSize); function pushToStream(stream: Http2Stream, data: Buffer | null) { - 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() @@ -2120,6 +2092,9 @@ function pushToStream(stream: Http2Stream, data: Buffer | null) { 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); } @@ -2131,31 +2106,55 @@ function endInboundHalf(stream: Http2Stream) { pushToStream(stream, null); } +function endReadableAtHalfClose(stream: Http2Stream) { + if (!stream.readable || stream.closed) return; + if (stream._readableState.ended) { + // streamHeaders already pushed this EOF. It waits for a late reader, unless an 'end' listener with no reader is already waiting for it. + if (stream.readableFlowing === null && stream.readableLength === 0 && stream.listenerCount("end") > 0) { + stream.resume(); + } + return; + } + endInboundHalf(stream); + stream.read(0); +} +function endReadableAtClose(stream: Http2Stream) { + endInboundHalf(stream); + if (stream instanceof ServerHttp2Stream && !stream.readableDidRead && stream.readableFlowing === null) { + // Like node's onStreamClose, dump an unread request 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); + } +} + +// What the `Http2Stream` object has done. The state of the stream on the wire is in the native stream table. enum StreamState { - EndedCalled = 1 << 0, // 00001 = 1 - WantTrailer = 1 << 1, // 00010 = 2 - FinalCalled = 1 << 2, // 00100 = 4 - Closed = 1 << 3, // 01000 = 8 - StreamResponded = 1 << 4, // 10000 = 16 - WritableClosed = 1 << 5, // 100000 = 32 - // The native side fully closed and freed the stream (state 7 delivered): there is - // nothing left to send on the wire for it. - NativeClosed = 1 << 6, // 1000000 = 64 + EndedCalled = 1 << 0, + WantTrailer = 1 << 1, + Closed = 1 << 2, + StreamResponded = 1 << 3, + WritableClosed = 1 << 4, // END_STREAM already rode the final DATA frame from _write/_writev; _final must not // emit the empty END_STREAM frame on top of it. - EndStreamSent = 1 << 7, // 10000000 = 128 + EndStreamSent = 1 << 5, // The server stream has been handed to user code (the 'stream' event or the pushStream // 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() has scheduled the RST_STREAM. Once the native side has forgotten the stream, a - // second rstStream() with an error code would write the frame again. - ResetScheduled = 1 << 9, // 1000000000 = 512 - EndedByClose = 1 << 10, // 10000000000 = 1024 + Delivered = 1 << 6, + // close() ends the stream with an RST_STREAM, so a write still in flight doesn't get END_STREAM behind it. + RstPending = 1 << 7, + // The request HEADERS that opened this server stream carried END_STREAM. + EndAfterHeaders = 1 << 8, + // User code didn't end the writable side. respond() did (HEAD, 204, 205, 304, endStream), or the peer's RST_STREAM with NO_ERROR or CANCEL. + EndedForUser = 1 << 9, + // The stream closed on the wire and waits for a reader before it emits 'end' and 'close'. Session teardown destroys it. + AwaitsReader = 1 << 10, // The server ended the stream before respond(), which then puts END_STREAM on the HEADERS frame. - EndedBeforeHeaders = 1 << 11, // 100000000000 = 2048 - // The native side closed the stream (the peer's RST_STREAM, a protocol error), not destroy(). - ClosedByNative = 1 << 12, // 1000000000000 = 4096 + EndedBeforeHeaders = 1 << 11, } // 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 @@ -2182,7 +2181,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 ); } @@ -2191,10 +2191,9 @@ function markWritableDone(stream: Http2Stream) { if (typeof _final === "function") { stream[bunHTTP2StreamFinal] = null; _final(); - stream[bunHTTP2StreamStatus] |= StreamState.WritableClosed | StreamState.FinalCalled; - return; } stream[bunHTTP2StreamStatus] |= StreamState.WritableClosed; + if (stream instanceof ServerHttp2Stream) maybeCloseUnreadServerStream(stream); } const kCloseChannelPublished = Symbol("closeChannelPublished"); function publishStreamCloseChannel(stream: Http2Stream) { @@ -2306,20 +2305,55 @@ function emitHttp2SessionPerf( ); } -function markStreamClosed(stream: Http2Stream) { +// Returns false if the stream was closed already. +function closeStreamOnce(stream: Http2Stream) { const status = stream[bunHTTP2StreamStatus]; - - if ((status & StreamState.Closed) === 0) { - stream[bunHTTP2StreamStatus] = status | StreamState.Closed; - publishStreamCloseChannel(stream); - - markWritableDone(stream); + if ((status & StreamState.Closed) !== 0) return false; + stream[bunHTTP2StreamStatus] = status | StreamState.Closed; + if (stream[kTimeout]) clearTimeout(stream[kTimeout]); + stream[kTimeout] = null; + stream.removeAllListeners("timeout"); + publishStreamCloseChannel(stream); + return true; +} +function markStreamClosed(stream: Http2Stream) { + if (closeStreamOnce(stream)) markWritableDone(stream); +} +// 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; } -function rstNextTick(this: Http2Session, id: number, rstCode: number) { - const session = this; +// Ends the writable side of a stream the peer reset, if it's still open. write() drops what user code writes afterwards. +function abortAndEndWritable(stream: Http2Stream) { + if (!abortIfWritableOpen(stream) || stream.destroyed) return; + stream[bunHTTP2StreamStatus] |= StreamState.EndedForUser; + stream.end(); +} +// Like node, the code of close(code) stays when the peer's RST_STREAM arrives before ours is written. +function keepsOwnRstCode(stream: Http2Stream) { + return (stream[bunHTTP2StreamStatus] & StreamState.Closed) !== 0 && !!stream.rstCode; +} +// 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.WritableClosed; + // Without an 'error' listener the stream's own code would be an uncaught error that only this timing raises. + if (!keepsOwnRstCode(stream) || stream.listenerCount("error") === 0) stream.rstCode = NGHTTP2_CANCEL; + abortAndEndWritable(stream); +} +// Does nothing for a stream that is closed by now. +function rstNextTick(session: Http2Session, id: number, rstCode: number) { session[bunHTTP2Native]?.rstStream(id, rstCode); } +// A session without a parser has closed all of its streams. +function isClosedOnWire(stream: Http2Stream) { + const native = stream[bunHTTP2Session]?.[bunHTTP2Native]; + return !native || native.isStreamClosed(stream.id); +} // node streamOnPause/streamOnResume (lib/internal/http2/core.js): the readable's flow state // drives the native receive window. While paused, the stream's window is not replenished; on // resume the deferred WINDOW_UPDATE is sent. A pending stream (no id yet) has nothing on the @@ -2340,11 +2374,15 @@ 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, session, id, code); } function uncorkNT(stream: Http2Stream) { stream.uncork(); } +function readNothing(this: Http2Stream) { + this.read(0); +} +function ignoreError() {} // 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) { @@ -2364,22 +2402,16 @@ function setupRequestEndAndSignal(req: Http2Stream, options: any, signal: AbortS // node destroys every open stream synchronously from inside session.destroy(): a write() issued // right after destroy() must already observe an ended + destroyed stream (write-after-end without // an 'error' event), and stream teardown must not wait a tick. -function destroyStreamForSessionDestroy(error: Error | undefined, rstCode: number, stream: Http2Stream) { +function destroyStreamForSessionDestroy( + error: Error | undefined, + rstCode: number, + stream: Http2Stream, + closedOnWire: boolean, +) { if (stream.destroyed) return; if (rstCode && !stream.rstCode) stream.rstCode = rstCode; - // A clean session teardown can reach here while a stream's already-received request data is - // still being drained by its consumer: the native side is done with the stream (state 7, - // which is what allowed the session to start destroying), but the JS side is not. Destroying - // now discards that data and emits 'close' without 'end' (a piped request body never - // finishes). Node's session waits for the JS stream, so finish delivering and destroy on - // 'end'. Only for natively-closed streams: a still-open one must keep node's contract that - // session.destroy() destroys it synchronously (and the engine error sweep follows for it). - if ( - error === undefined && - (stream[bunHTTP2StreamStatus] & StreamState.NativeClosed) !== 0 && - stream.readable && - stream.readableFlowing !== null - ) { + // The session is destroyed from inside the handler of this stream's close. Its reader still gets what has arrived, and 'end'. + if (error === undefined && closedOnWire && stream.readable && stream.readableFlowing !== null) { stream.push(null); stream.once("end", destroySelfOnEnd); return; @@ -2391,6 +2423,10 @@ 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, false); +} interface Http2Stream { /** A stream ignores a second argument that is no function. */ destroy(error?: Error | null, callback?: $Node.ErrorCallback | number): this; @@ -2409,8 +2445,11 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { // 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; @@ -2421,10 +2460,12 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { streamId: number | undefined, session: ClientHttp2Session | ServerHttp2Session, headers: HeadersObject | null | undefined, + writable = true, ) { super({ decodeStrings: false, autoDestroy: false, + writable, }); this.#id = streamId!; this[bunHTTP2Session] = session; @@ -2438,7 +2479,7 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { } get scheme() { - const headers = this[bunHTTP2Headers]; + const headers = this[kRequestHeaders] || this[bunHTTP2Headers]; if (headers) return headers[":scheme"] || "https"; return "https"; } @@ -2512,6 +2553,7 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { if ((this[bunHTTP2StreamStatus] & StreamState.WantTrailer) === 0) { throw $ERR_HTTP2_TRAILERS_NOT_READY(); } + if (this[kTimeout]) this[kTimeout].refresh(); if (headers == undefined) { headers = {}; @@ -2528,26 +2570,17 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { // 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; @@ -2556,10 +2589,8 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { } } - setTimeout(timeout: number, callback?: () => void) { - const session = this[bunHTTP2Session]; - if (!session) return; - session.setTimeout(timeout, callback); + setTimeout(msecs: number, callback?: () => void) { + return setIdleTimeout.$call(this, msecs, callback, this[bunHTTP2Session], streamTimerExpired); } get closed() { @@ -2587,11 +2618,24 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { get state() { const session = this[bunHTTP2Session]; - if (session && !session.destroyed && typeof this.#id === "number") { - return session[bunHTTP2Native]?.getStreamState(this.#id) ?? {}; + if (session && !this.destroyed && typeof this.#id === "number") { + const native = session[bunHTTP2Native]; + if (!native) return {}; + // Like Node, where nghttp2 has dropped the id of a stream that only lives on for its unread data, and Http2Stream::RefreshState then reports an idle stream. + const awaitsReader = (this[bunHTTP2StreamStatus] & StreamState.AwaitsReader) !== 0; + return ( + (awaitsReader ? undefined : 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. + // stream has been destroyed. return {}; } @@ -2613,11 +2657,7 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { } 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() { @@ -2625,9 +2665,9 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { } get session() { - // node detaches the session reference once the session has been destroyed. + // The listeners that the session's teardown runs still get it, as in Node, though `session.destroyed` is already true. const session = this[bunHTTP2Session]; - if (session == null || session.destroyed) return undefined; + if (session == null || session[bunHTTP2Socket] === null) return undefined; return session; } @@ -2637,7 +2677,12 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { // 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?: number, callback?: () => void) { @@ -2657,33 +2702,33 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { 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"); - } + const endWritable = abortIfWritableOpen(this); + if (endWritable) { // Set first: with nothing left to write, end() calls _final() before it returns. - this[bunHTTP2StreamStatus] |= StreamState.EndedByClose; + if (!this.pending) this[bunHTTP2StreamStatus] |= StreamState.RstPending; 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) { - this[bunHTTP2StreamStatus] |= StreamState.ResetScheduled; - setImmediate(rstNextTick.bind(session, this.#id, code)); + this[bunHTTP2StreamStatus] |= StreamState.RstPending; + setImmediate(rstNextTick, session, this.#id, code); } else { - this.once("finish", rstNextTick.bind(session, this.#id, code)); + this.once("finish", rstNextTick.bind(null, session, this.#id, code)); } // node destroys the stream once both halves have finished; without this a stream closed // while idle never emits 'close'. @@ -2699,30 +2744,22 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { // 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; + 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); + const id = this.#id; + const native = session[bunHTTP2Native]; + // The table forgets a closed stream once its Http2Stream is destroyed. A session without a parser has closed all of its streams. + const closedOnWire = typeof id === "number" && (!native || native.releaseStream(id)); let rstCode = this.rstCode; if (!rstCode) { - if (err != null) { + // Like Node, a stream that already closed cleanly keeps its code. + if (err != null && !closedOnWire) { if (err.code === "ABORT_ERR") { // Enables using AbortController to cancel requests with RST code 8. rstCode = NGHTTP2_CANCEL; @@ -2735,10 +2772,7 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { } this.rstCode = rstCode; emitHttp2StreamPerf(this); - // node closes the stream from inside _destroy, so the close-channel publish observes - // closed === true and destroyed === true with the final rstCode. The non-error close path - // (streamEnd state=7) calls markStreamClosed BEFORE destroy(), where the channel observes - // destroyed === false instead. + // Like Node, a local destroy() closes the stream here, so its close channel sees `destroyed` and the final rstCode. A close on the wire has published already. markStreamClosed(this); // RST code 8 not emitted as an error as its used by clients to signify // abort and is already covered by aborted event, also allows more @@ -2759,6 +2793,8 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { ) { err = null; } + // The client didn't ask for a pushed stream, so its 'error' is never uncaught. It stays errored, or a body the peer cut short would emit 'end'. + if (err != null && this[kPush] && this.listenerCount("error") === 0) this.on("error", ignoreError); this[bunHTTP2Session] = null; // This notifies the session that this stream has been destroyed and @@ -2766,16 +2802,8 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { // will destroy if it has been closed and there are no other open or // pending streams. Delay with setImmediate so we don't do it on the // nghttp2 stack. - if ( - session && - typeof this.#id === "number" && - !this[kNeverAnnounced] && - (this[bunHTTP2StreamStatus] & StreamState.ResetScheduled) === 0 && - // 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) - ) { - setImmediate(rstNextTick.bind(session, this.#id, rstCode)); + if (typeof id === "number" && !closedOnWire) { + setImmediate(rstNextTick, session, id, rstCode); } // Diagnostics channels: published after the stream is closed and destroyed, with the same error @@ -2802,7 +2830,6 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { onClientStreamBodySentChannel.publish({ stream: this }); } if ((status & StreamState.EndStreamSent) !== 0) { - this[bunHTTP2StreamStatus] = status | StreamState.FinalCalled; if ((status & (StreamState.WritableClosed | StreamState.Closed)) !== 0) { callback(); } else { @@ -2812,39 +2839,26 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { } return; } + // Like node, close() with nothing left to write still sends END_STREAM ahead of its RST_STREAM, unless trailers would carry it. + if ( + (status & StreamState.RstPending) !== 0 && + ((status & StreamState.Closed) !== 0 || this[bunHTTP2WaitForTrailers]) + ) { + this[bunHTTP2WaitForTrailers] = false; + this[bunHTTP2StreamStatus] |= StreamState.WritableClosed; + callback(); + return; + } const session = this[bunHTTP2Session]; if (session) { const native = session[bunHTTP2Native]; if (native) { - if (this instanceof ServerHttp2Stream && !this.headersSent) { - // An empty DATA frame would go out with no HEADERS frame before it, and the peer would - // answer with a connection error. Like node, send nothing and emit 'finish'. - this[bunHTTP2StreamStatus] = status | StreamState.EndedBeforeHeaders; - if (this._readableState.destroyed && (status & StreamState.ClosedByNative) === 0) { - // Called from _destroy() after the user's destroy(). Its markStreamClosed() completes this once - // the writable is marked destroyed again, so there's no 'finish'. Node's onStreamClose ends the - // writable before it destroys the stream, so a stream that the peer reset does emit it. - this[bunHTTP2StreamFinal] = callback; - return; - } - this[bunHTTP2StreamStatus] |= StreamState.FinalCalled; + if (this instanceof ServerHttp2Stream && !this.headersSent && !native.isStreamClosed(this.#id)) { + // An empty DATA frame ahead of the response HEADERS is a connection error for the peer. Like node, send nothing and emit 'finish'. + this[bunHTTP2StreamStatus] |= StreamState.EndedBeforeHeaders | StreamState.WritableClosed; callback(); return; } - if ((status & StreamState.EndedByClose) !== 0) { - // Like node, RST_STREAM ends these streams, not END_STREAM, which would tell the peer that the body is - // complete: one that waits for trailers (node's onStreamTrailers returns on a closed stream), and one with - // a write still in flight when close() returned, unless the code is CANCEL. - const closed = (status & StreamState.Closed) !== 0; - if (this[bunHTTP2WaitForTrailers] || (closed && this.rstCode !== NGHTTP2_CANCEL)) { - this[bunHTTP2WaitForTrailers] = false; - // Called from inside close(), whose markStreamClosed() completes it. - if (closed) callback(); - else this[bunHTTP2StreamFinal] = callback; - return; - } - } - this[bunHTTP2StreamStatus] |= StreamState.FinalCalled; // When waitForTrailers is active, writing an empty DATA frame with // close=true emits a bare empty DATA frame (flags=0) to the wire // before the trailer/noTrailers path runs, which then emits ANOTHER @@ -2862,7 +2876,12 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { // markWritableDone before control returns here: stash the callback there so the // writable finishes ('finish' before 'close') instead of being torn down mid-final. this[bunHTTP2StreamFinal] = callback; - if ((this[bunHTTP2StreamStatus] & StreamState.Closed) !== 0 || this.listenerCount("wantTrailers") === 0) { + if ( + (this[bunHTTP2StreamStatus] & StreamState.Closed) !== 0 || + // Nobody is left to send trailers to. + session[kTransportClosed] || + this.listenerCount("wantTrailers") === 0 + ) { // No 'wantTrailers' listener — or the stream was close()d while the last write // was still in flight, in which case node never asks for trailers (the compat // onStreamTrailersReady would throw ERR_HTTP2_INVALID_STREAM on a closed @@ -2906,7 +2925,6 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { } if ((status & StreamState.WritableClosed) !== 0 || (status & StreamState.Closed) !== 0) { callback(); - this[bunHTTP2StreamStatus] |= StreamState.FinalCalled; } else { this[bunHTTP2StreamFinal] = callback; } @@ -2930,6 +2948,17 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { } } + write(chunk, encoding?, callback?) { + // respond() (HEAD, 204, 304) and the peer's reset end the writable side themselves. A write() after that is dropped, unless the stream is open and has an 'error' listener. + const status = this[bunHTTP2StreamStatus]; + if ((status & StreamState.EndedForUser) !== 0 && (this.listenerCount("error") === 0 || isClosedOnWire(this))) { + if (typeof encoding === "function") callback = encoding; + if (typeof callback === "function") process.nextTick(callback, $ERR_STREAM_WRITE_AFTER_END()); + return false; + } + return super.write(chunk, encoding, callback); + } + end(chunk?, encoding?, callback?: () => void) { const status = this[bunHTTP2StreamStatus]; if (typeof callback === "undefined") { @@ -2943,9 +2972,8 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { } 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 @@ -3012,8 +3040,11 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { } 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) as number; + const status = native.writeStream(this.#id, chunk, undefined, endStream, callback, true); + // The stream is closed, and native has called the callback. + if (status === false) return; if (status & kWriteFlushedWithoutCallback) session[kDeferWriteCallback](callback); if (endStream) { this[bunHTTP2StreamStatus] |= StreamState.EndStreamSent; @@ -3059,8 +3090,11 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { 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) as number; + const status = native.writeStream(this.#id, wireChunk, wireEncoding, endStream, callback, true); + // The stream is closed, and native has called the callback. + if (status === false) return; if (status & kWriteFlushedWithoutCallback) session[kDeferWriteCallback](callback); if (endStream) { this[bunHTTP2StreamStatus] |= StreamState.EndStreamSent; @@ -3092,6 +3126,15 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { } 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 @@ -3133,6 +3176,12 @@ function doSendFileFD( ) { 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); @@ -3140,7 +3189,7 @@ function doSendFileFD( 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; @@ -3159,7 +3208,8 @@ function doSendFileFD( 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; @@ -3228,7 +3278,7 @@ function doSendFileFD( 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. @@ -3258,7 +3308,8 @@ function doSendFileFD( // 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, }); @@ -3269,6 +3320,7 @@ function doSendFileFD( cb(); return; } + if (stream[kTimeout]) stream[kTimeout].refresh(); const status = native.writeStream(stream.id, chunk as Uint8Array, undefined, false, cb, true) as number; if (status & kWriteFlushedWithoutCallback) process.nextTick(cb); }, @@ -3333,41 +3385,26 @@ function afterOpen( 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. 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; @@ -3383,7 +3420,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: $Node.ErrorCallback) { - 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 @@ -3413,7 +3450,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(); } } @@ -3434,10 +3471,9 @@ 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. - if (session[kStrictSingleValueFields] !== false) assertSingleValueHeaders(headers); const sensitives = headers[sensitiveHeaders]; // Note: the sensitiveHeaders symbol stays on the object — the native header walk skips // symbol keys, and deleting it here would flip the object into dictionary mode, @@ -3447,14 +3483,22 @@ 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); + } + // Like node, a repeated single-value field throws. The callback gets every other error. Neither reserves a stream id. + try { + parser.validateRequest(headers); + } catch (err: any) { + if (err.code === "ERR_HTTP2_HEADER_SINGLE_VALUE") throw err; + process.nextTick(callback, err); + return; } const pushId = parser.getNextStream(); if (pushId === -1) { @@ -3465,6 +3509,7 @@ class ServerHttp2Stream extends Http2Stream { const pushedStream = parser.getStreamContext(pushId) as ServerHttp2Stream | undefined; if (pushedStream && pushedStream[bunHTTP2Headers] == null) { pushedStream[bunHTTP2Headers] = headers; + pushedStream[kRequestHeaders] = headers; } if (pushedStream) endInboundHalf(pushedStream); if (onServerStreamCreatedChannel.hasSubscribers) { @@ -3484,7 +3529,6 @@ class ServerHttp2Stream extends Http2Stream { // The PUSH_PROMISE never reached the wire; sending RST_STREAM for the reserved id would // be a protocol violation (the peer sees an idle stream). The skipped reset dispatch is // also what releases the session's bookkeeping - do that explicitly. - pushedStream[kNeverAnnounced] = true; (session as ServerHttp2Session)[kReleaseUnannouncedStream](pushId); pushedStream.destroy(); } @@ -3497,7 +3541,6 @@ class ServerHttp2Stream extends Http2Stream { // the session error must reach its 'error' listener). PUSH_PROMISE never reached the wire, // so teardown must not RST the reserved id. if (pushedStream) { - pushedStream[kNeverAnnounced] = true; pushedStream[bunHTTP2StreamStatus] |= StreamState.Delivered; } process.nextTick(callback, null, pushedStream, headers); @@ -3521,7 +3564,7 @@ class ServerHttp2Stream extends Http2Stream { } respondWithFile(path: string | Buffer | URL, headers?: HeadersObject | null, options?: FileResponseOptions) { - if (this.destroyed) { + if (this.destroyed || this.closed) { throw $ERR_HTTP2_INVALID_STREAM(); } if (this.headersSent) throw $ERR_HTTP2_HEADERS_SENT(); @@ -3542,7 +3585,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 @@ -3582,7 +3628,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(); @@ -3603,7 +3649,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 ( @@ -3650,6 +3699,7 @@ class ServerHttp2Stream extends Http2Stream { if (this.headersSent) { throw $ERR_HTTP2_HEADERS_AFTER_RESPOND(); } + if (this[kTimeout]) this[kTimeout].refresh(); if (headers == undefined) { headers = {}; @@ -3673,16 +3723,9 @@ class ServerHttp2Stream extends Http2Stream { throw $ERR_INVALID_ARG_VALUE("headers[http2.neverIndex]", sensitives); } const sensitiveNames = buildSensitiveNames(headers, sensitives); - // 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); @@ -3696,10 +3739,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(); } @@ -3709,6 +3753,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 @@ -3747,15 +3793,7 @@ class ServerHttp2Stream extends Http2Stream { (headers as unknown[]).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: unknown = 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]; } @@ -3774,9 +3812,7 @@ class ServerHttp2Stream extends Http2Stream { throw $ERR_INVALID_ARG_VALUE("headers[http2.neverIndex]", sensitives); } const sensitiveNames = buildSensitiveNames(headers, sensitives); - // 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; @@ -3791,12 +3827,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; @@ -3808,18 +3840,11 @@ class ServerHttp2Stream extends Http2Stream { this.headRequest === true || (this[bunHTTP2StreamStatus] & StreamState.EndedBeforeHeaders) !== 0 ) { - // 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; } + // Read before the native call, which can close the stream and end the writable side. + const endedByRespond = endStream && !this._writableState.ending; + const waitForTrailers = !endStream && !!options?.waitForTrailers; const sendDate = options?.sendDate; if (rawHeadersList === null && (sendDate == null || sendDate)) { const current_date = headers["date"]; @@ -3829,21 +3854,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) { @@ -3851,6 +3872,7 @@ class ServerHttp2Stream extends Http2Stream { } this[bunHTTP2Headers] = headers; if (endStream) { + if (endedByRespond) this[bunHTTP2StreamStatus] |= StreamState.EndedForUser; this.end(); } @@ -3860,10 +3882,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, listener); + switch (protocol) { + case "http:": + return net.connect(options, listener); + case "https:": + return tls.connect(options, listener); + default: + throw $ERR_HTTP2_UNSUPPORTED_PROTOCOL(protocol); } - return tls.connect(options, listener); } function emitConnectNT(self: ClientHttp2Session, socket: SessionSocket) { @@ -3883,11 +3909,16 @@ function destroyWithInvalidSessionNT(stream: Http2Stream) { // teardown by the grace, never past it. function scheduleSettingsAckGraceNT(session: Http2Session) { if (session[kSettingsAckGraceTimer] !== undefined) return; - const timer: NodeJS.Timeout = setTimeout(destroyIfNotDestroyedNT, 250, session); + const timer: NodeJS.Timeout = setTimeout(destroySessionInternally, 250, session); // Never keeps the event loop alive: if nothing else does, the process is exiting anyway. timer.unref?.(); session[kSettingsAckGraceTimer] = timer; } +// Every destroy() with no error that the session decides on itself goes through here, so destroy() can tell it from the program's. +function destroySessionInternally(session: Http2Session) { + session[kInternalDestroy] = true; + session.destroy(); +} function destroyIfNotDestroyedNT(target: Http2Session | Http2Stream) { if (!target.destroyed) target.destroy(); } @@ -3903,14 +3934,26 @@ function rejectRequestNT(session: ClientHttp2Session, req: ClientHttp2Stream) { return; } req.rstCode = constants.NGHTTP2_PROTOCOL_ERROR; - emitStreamErrorNT(session, req, constants.NGHTTP2_PROTOCOL_ERROR, true, false); + emitStreamErrorNT(session, req, constants.NGHTTP2_PROTOCOL_ERROR, 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) { + if (!stream.closed) { + stream.rstCode = NGHTTP2_NO_ERROR; + abortAndEndWritable(stream); + markStreamClosed(stream); + } + // An 'aborted' listener or a close-channel subscriber can destroy the stream. + if (stream.destroyed) return; + endReadableAtClose(stream); + destroyClosedStream(session, stream); } function emitStreamErrorNT( - self: Http2Session | null | undefined, + self: Http2Session, stream: Http2Stream | null | undefined, error: Error | number | undefined, - destroy?: boolean, destroy_self?: boolean, ) { if (stream) { @@ -3921,7 +3964,7 @@ function emitStreamErrorNT( // A destroyed stream that IS listened keeps the delivery below - // node's errorEmitted de-dupes the re-emission, and grpc's status // codes ride exactly this deferred path. - if (destroy_self) self!.destroy(); + if (destroy_self) destroySessionInternally(self); return; } if (typeof error === "number" && self != null && self[kSessionDestroyError] != null) { @@ -3931,6 +3974,7 @@ function emitStreamErrorNT( if (error !== 0 && !stream.rstCode) stream.rstCode = error; error = self[kSessionDestroyError]; } + if (typeof error === "number" && keepsOwnRstCode(stream)) error = stream.rstCode; let error_instance: Error | number | undefined = undefined; if (stream.listenerCount("error") > 0) { if (typeof error === "number") { @@ -3942,33 +3986,21 @@ function emitStreamErrorNT( error_instance = error; } } - if (destroy && error === NGHTTP2_NO_ERROR && stream.readable) { - // Like node's onStreamClose: the readable ends first, and 'end' destroys the stream. - stream.once("end", destroySelfOnEnd); - endInboundHalf(stream); - if (stream instanceof ServerHttp2Stream && !stream.readableDidRead && stream.readableFlowing === null) - stream.resume(); - else stream.read(0); - if (destroy_self) self!.destroy(); - return; - } - if (stream.readable) { - stream.resume(); // we have a error we consume and close - pushToStream(stream, null); - } - 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[bunHTTP2StreamStatus] |= StreamState.ClosedByNative; - stream.destroy(error_instance, stream.rstCode); + // A stream that already emitted 'end' goes this way too while its writable side is open, so end() still emits 'finish'. + if (error === NGHTTP2_NO_ERROR && !self.destroyed && (stream.readable || !stream._writableState.ending)) { + closeStreamResetWithNoError(self, stream); } else { - markStreamClosed(stream); - if (error_instance) { - stream.emit("error", error_instance); + // Like Node's onStreamClose, a reset on the wire closes the stream and publishes its close channel before destroy(). Only a local destroy() publishes from _destroy. + let closedHere = false; + if (typeof error !== "object" && !stream.destroyed) { + abortIfWritableOpen(stream); + closedHere = closeStreamOnce(stream); } + stream.destroy(error_instance, stream.rstCode); + if (closedHere) markWritableDone(stream); } - if (destroy_self) self!.destroy(); + if (destroy_self) destroySessionInternally(self); } } // Outbound guard: header values carrying code points above 0xFF (or raw CR/LF/NUL) cannot be @@ -3982,13 +4014,21 @@ const kForbiddenConnectionHeaders = new SafeSet([ "proxy-connection", "transfer-encoding", ]); +// `name` is lowercase. Only a `te` value is stringified here, so a user toString() on any other field runs once, in the native walk. +function isConnectionSpecificHeader(name: string, value): boolean { + if (kForbiddenConnectionHeaders.has(name)) return true; + if (name !== "te") return false; + if ($isArray(value) && value.length === 1) value = value[0]; + return `${value}` !== "trailers"; +} function assertNoConnectionHeaders(headers: HeadersObject): 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++) { + const value = headers[keys[i]]; + if (value === undefined || ($isArray(value) && value.length === 0)) continue; + const lower = keys[i].toLowerCase(); + if (isConnectionSpecificHeader(lower, value)) { + throw $ERR_HTTP2_INVALID_CONNECTION_HEADERS(`HTTP/1 Connection specific headers are forbidden: "${lower}"`); } } } @@ -4035,25 +4075,6 @@ function stripInvalidWhitespaceFields(rawheaders: string[]): string[] { // the user's sensitive-headers list matches case-insensitively, and `authorization` (always) // plus short `cookie` values (< 20 chars) are never indexed even when not listed. -// node validates header constraints in JS before anything reaches the native encoder, so a -// throwing request leaves no partial state in the shared HPACK table. Mirror the single-value -// rule here: duplicated single-value fields (across case variants) and multi-element arrays for -// them throw before encoding starts. -function assertSingleValueHeaders(headers: HeadersObject) { - let seen: Set | null = null; - const keys = Object.keys(headers); - for (let i = 0; i < keys.length; i++) { - const lower = keys[i].toLowerCase(); - if (!kSingleValueHeaders.has(lower)) continue; - const value = headers[keys[i]]; - if (($isArray(value) && value.length > 1) || (seen !== null && seen.has(lower))) { - throw $ERR_HTTP2_HEADER_SINGLE_VALUE(`Header field "${lower}" must only have a single value`); - } - if (seen === null) seen = new SafeSet(); - seen.add(lower); - } -} - // Renders a received value the way node's determineSpecificType does for error messages. function receivedValueLabel(value: unknown) { if (value === null) return "null"; @@ -4091,6 +4112,20 @@ function buildSensitiveNames( 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) { @@ -4195,45 +4230,21 @@ class ServerHttp2Session extends Http2Session { #closeCalled: boolean = false; /// connected indicates that the connection/socket is connected #connected: boolean = false; - #connections: number = 0; #socket_proxy: import("node:net").Socket | import("node:tls").TLSSocket | undefined; #parser: $Node.H2FrameParser | null; #alpnProtocol: string | undefined = undefined; #localSettings: Settings | null = null; #encrypted: boolean = false; - #pendingSettingsAck: boolean = true; - // Count of SETTINGS frames sent that the peer has not yet ACKed (the initial connection - // SETTINGS counts as the first). node destroys the session with - // ERR_HTTP2_MAX_PENDING_SETTINGS_ACK when this exceeds maxOutstandingSettings. - #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<[SettingsCallback, number] | null> = [null]; + // node's Http2Settings queue: one entry per SETTINGS frame the peer hasn't ACKed yet (null for the initial one). + #pendingSettingsCallbacks: Array = [null]; #remoteSettings: Settings | null = null; #pingCallbacks: Array<[PingCallback, number]> | null = null; #strictFieldWhitespaceValidation: boolean = true; - // The SETTINGS_MAX_CONCURRENT_STREAMS value this session advertised (enforced from the moment it - // is submitted, like nghttp2's pending local settings — not only after the peer ACKs). - #advertisedMaxConcurrentStreams: number = Infinity; - // Client-initiated (odd-id) streams currently open: RFC 9113 5.1.2 - only these count against - // the limit this server advertised; its own pushed streams count against the client's setting. - #peerInitiatedStreams: number = 0; - static #Handlers = { - binaryType: "buffer", streamStart(self: ServerHttp2Session, stream_id: number) { if (!self) return; - // RFC 9113 §5.1.2: refuse peer-initiated streams that would exceed the advertised - // 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; - } - 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; @@ -4245,24 +4256,16 @@ 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"); - } - self.#connections--; - if (stream.id % 2 === 1) self.#peerInitiatedStreams--; - process.nextTick(emitStreamErrorNT, self, stream, error, true, self.#connections === 0 && self.#closed); + abortOnPeerCancel(stream); + process.nextTick(emitStreamErrorNT, self, stream, error, self.#isClosedAndIdle()); }, streamError(self: ServerHttp2Session, stream: ServerHttp2Stream, error: number) { if (!self || typeof stream !== "object") return; - self.#connections--; - if (stream.id % 2 === 1) self.#peerInitiatedStreams--; - process.nextTick(emitStreamErrorNT, self, stream, error, true, self.#connections === 0 && self.#closed); + process.nextTick(emitStreamErrorNT, self, stream, error, self.#isClosedAndIdle()); }, streamEnd(self: ServerHttp2Session, stream: ServerHttp2Stream, state: number) { if (!self || typeof stream !== "object") return; @@ -4273,49 +4276,25 @@ 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. - // After close(code) the stream is about to be destroyed, with no 'end' before that. - if ((state == 7 || (stream.readableLength === 0 && !stream.rstCode)) && 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], endReadableAtClose, undefined, stream); } // 7 = closed, in this case we already send everything and received everything if (state === 7) { - stream[bunHTTP2StreamStatus] |= StreamState.NativeClosed; 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(); - } - if (self.#connections === 0 && self.#closed) { - if (self.#pendingSettingsAckCount > 0 || (self.#pingCallbacks !== null && self.#pingCallbacks.length > 0)) { + destroyClosedStream(self, stream); + if (self.#isClosedAndIdle()) { + if ( + self.#pendingSettingsCallbacks.length > 0 || + (self.#pingCallbacks !== null && self.#pingCallbacks.length > 0) + ) { scheduleSettingsAckGraceNT(self); } else { captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); - setImmediate(destroyIfNotDestroyedNT, self); + setImmediate(destroySessionInternally, self); } } } else if (state === 5) { @@ -4326,9 +4305,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, @@ -4336,7 +4312,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; @@ -4352,15 +4334,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; } @@ -4370,14 +4347,9 @@ class ServerHttp2Session extends Http2Session { if (endOfStream) endInboundHalf(stream); stream.emit("trailers", headers, flags, rawheaders); } else { - // Set the StreamResponded bit BEFORE dispatching the 'stream' event - // synchronously to user code. The user handler may call - // stream.respond()/stream.end() which set other bits (WantTrailer, - // FinalCalled, EndedCalled, WritableClosed). If we captured `status` - // and wrote it back AFTER the emit, we'd clobber any bits set by the - // user handler — in particular, losing WantTrailer/FinalCalled breaks - // any later `sendTrailers()` with ERR_HTTP2_TRAILERS_NOT_READY. + // Set before the 'stream' event. A write-back afterwards would lose the bits the handler's respond() and end() set. stream[bunHTTP2StreamStatus] |= StreamState.StreamResponded | StreamState.Delivered; + if (endOfStream) stream[bunHTTP2StreamStatus] |= StreamState.EndAfterHeaders; if (onServerStreamCreatedChannel.hasSubscribers) { onServerStreamCreatedChannel.publish({ stream, headers }); } @@ -4393,38 +4365,29 @@ class ServerHttp2Session extends Http2Session { localSettings(self: ServerHttp2Session, settings: Settings) { if (!self) return; self.#localSettings = settings; - self.#pendingSettingsAck = false; - if (self.#pendingSettingsAckCount > 0) self.#pendingSettingsAckCount--; + const acked = self.#pendingSettingsCallbacks.shift(); // This ACK may be the only thing a gracefully closed, idle session was waiting for. const pingsDrained = self.#pingCallbacks === null || self.#pingCallbacks.length === 0; - if (self.#pendingSettingsAckCount === 0 && pingsDrained && self[kSettingsAckGraceTimer] !== undefined) { + if (self.#pendingSettingsCallbacks.length === 0 && pingsDrained && self[kSettingsAckGraceTimer] !== undefined) { clearTimeout(self[kSettingsAckGraceTimer]); self[kSettingsAckGraceTimer] = undefined; } - if ( - self.#closed && - self.#connections === 0 && - self.#pendingSettingsAckCount === 0 && - pingsDrained && - !self.destroyed - ) { + if (self.#isClosedAndIdle() && self.#pendingSettingsCallbacks.length === 0 && pingsDrained && !self.destroyed) { captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); - setImmediate(destroyIfNotDestroyedNT, self); + setImmediate(destroySessionInternally, 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, acked); }, 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); } @@ -4438,28 +4401,22 @@ class ServerHttp2Session extends Http2Session { const stats = self[kPerfStats]; if (stats !== undefined) stats.pingRTT = rtt; callback(null, rtt, payload); - if (callbacks.length === 0 && self.#pendingSettingsAckCount === 0) { + if (callbacks.length === 0 && self.#pendingSettingsCallbacks.length === 0) { if (self[kSettingsAckGraceTimer] !== undefined) { clearTimeout(self[kSettingsAckGraceTimer]); self[kSettingsAckGraceTimer] = undefined; } // A server session has no pending-request queue. - if (self.#closed && self.#connections === 0 && !self.destroyed) { + if (self.#isClosedAndIdle() && !self.destroyed) { captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); - setImmediate(destroyIfNotDestroyedNT, self); + setImmediate(destroySessionInternally, self); } } } } } }, - error( - self: ServerHttp2Session, - errorCode: number | string, - lastStreamId: number, - opaqueData: Buffer, - clientIsGone?: boolean, - ) { + error(self: ServerHttp2Session, errorCode: number | string, lastStreamId: number, opaqueData: Buffer) { if (!self) return; if (errorCode === "ERR_HTTP2_TOO_MANY_INVALID_FRAMES") { self.destroy($ERR_HTTP2_TOO_MANY_INVALID_FRAMES()); @@ -4469,8 +4426,8 @@ class ServerHttp2Session extends Http2Session { // detected; node surfaces those as NghttpError (code ERR_HTTP2_ERROR) via // onSessionInternalError. if (typeof errorCode === "number" && errorCode < 0) { - // Only the session reports the error. Its streams end as they do when the socket closes. - if (clientIsGone) self.#parser?.forEachStream(streamSocketClosed); + // The responses of a client that is gone are still queued. Only the session reports the error, and its streams end as they do when the socket closes. + if (self.#parser?.hasRejectedSend()) self.#parser.forEachStream(streamSocketClosed); self.destroy(new NghttpError(errorCode)); return; } @@ -4498,7 +4455,7 @@ class ServerHttp2Session extends Http2Session { if (errorCode === constants.NGHTTP2_NO_ERROR) { // Graceful shutdown: no new streams, existing ones may finish. self.close(); - } else if (self.closed && self.#connections === 0) { + } else if (self.closed && self.#isClosedAndIdle()) { // Mutual teardown: this side already sent its own graceful GOAWAY // (close()) and every stream is done, so the peer's error code cannot // affect anything user-visible - both ends agreed the connection is @@ -4507,9 +4464,8 @@ class ServerHttp2Session extends Http2Session { // peer's late GOAWAY could be read - grpc-js forceShutdown sends // GOAWAY(NGHTTP2_CANCEL) at a client that just close()d, and on the // Windows agents the frame deterministically arrived first). - self.destroy(); + destroySessionInternally(self); } 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); @@ -4517,7 +4473,7 @@ class ServerHttp2Session extends Http2Session { }, end(self: ServerHttp2Session, errorCode: number, lastStreamId: number, opaqueData: Buffer) { if (!self) return; - self.destroy(); + destroySessionInternally(self); }, write(self: ServerHttp2Session, buffer: Buffer, transportClosed: boolean) { if (!self) return -1; @@ -4536,15 +4492,21 @@ 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.#isClosedAndIdle(); + const unread = unreadClosedStreams(parser); 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(unread, 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 @@ -4552,7 +4514,7 @@ class ServerHttp2Session extends Http2Session { // never comes once the peer is gone — leaving the session (and the // server's open-connection count) alive forever. this.close(); - this.destroy(); + destroySessionInternally(this); } #onError(error: Error) { // Node's socketOnError reads `const session = this[kSession]` and does @@ -4564,7 +4526,7 @@ class ServerHttp2Session extends Http2Session { return; } if (isEconnresetAfterGoaway(this, error)) { - this.destroy(); + destroySessionInternally(this); return; } if (this.listenerCount("error") === 0 && (error as NodeJS.ErrnoException)?.code === "ECONNRESET") { @@ -4575,18 +4537,11 @@ class ServerHttp2Session extends Http2Session { // attaches sessionOnError at accept time, so this branch only matters // for standalone sessions.) Anything that is not teardown noise keeps // Node's EventEmitter contract and surfaces when unobserved. - this.destroy(); + destroySessionInternally(this); return; } this.destroy(error); } - #onTimeout() { - const parser = this.#parser; - if (parser) { - parser.forEachStream(emitTimeout); - } - this.emit("timeout"); - } #onDrain() { const parser = this.#parser; if (parser) { @@ -4652,10 +4607,13 @@ class ServerHttp2Session extends Http2Session { parser.origin(validOrigins); } + // close() or destroy() was called and no stream is open on the wire. + #isClosedAndIdle() { + return this.#closed && !this.#parser?.hasOpenStreams(); + } + [kReleaseUnannouncedStream](streamId: number) { - // A pushed stream torn down before its PUSH_PROMISE left: no reset dispatch will arrive, so - // release the connection slot and the native stream context here. - this.#connections--; + // A pushed stream that is torn down before its PUSH_PROMISE left was never on the wire, so it's closed without a reset. this.#parser?.setStreamContext(streamId, undefined); } @@ -4673,7 +4631,6 @@ class ServerHttp2Session extends Http2Session { if (options?.strictFieldWhitespaceValidation === false) { this.#strictFieldWhitespaceValidation = false; } - this[kStrictSingleValueFields] = options?.strictSingleValueFields !== false; if (server) { server[kSessions].add(this); } @@ -4689,16 +4646,8 @@ class ServerHttp2Session extends Http2Session { this[kDeferWriteCallback] = deferWriteCallbackForSocket(nativeSocket); this.#encrypted = socket instanceof TLSSocket; - if (typeof options?.maxOutstandingSettings === "number" && options.maxOutstandingSettings >= 1) { - this.#maxOutstandingSettings = options.maxOutstandingSettings; - } - const advertisedMaxConcurrentStreams = options?.settings?.maxConcurrentStreams ?? options?.maxConcurrentStreams; - if (typeof advertisedMaxConcurrentStreams === "number") { - this.#advertisedMaxConcurrentStreams = advertisedMaxConcurrentStreams; - } - - if (options?.settings !== undefined) { - validateSettings(options.settings); + if (typeof options?.maxOutstandingSettings === "number") { + this.#maxOutstandingSettings = Math.max(1, options.maxOutstandingSettings); } const nativeSettings = serverNativeSettings(options); this.#localSettings = initialLocalSettings(nativeSettings); @@ -4717,7 +4666,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)); @@ -4751,7 +4700,8 @@ class ServerHttp2Session extends Http2Session { return this[bunHTTP2Socket]?.connecting === false; } get destroyed() { - return this[bunHTTP2Socket] === null; + // Like Node's flag, true before a teardown reaches the first stream. The socket slot is cleared last. + return this.#destroying || this[bunHTTP2Socket] === null; } get encrypted() { return this.#encrypted; @@ -4761,17 +4711,17 @@ class ServerHttp2Session extends Http2Session { } get remoteSettings() { - if (this.destroyed || this.connecting) return {}; + if (this[bunHTTP2Socket] === null || this.connecting) return {}; return (this.#remoteSettings ??= getDefaultSettings()); } get localSettings() { - if (this.destroyed || this.connecting) return {}; + if (this[bunHTTP2Socket] === null || this.connecting) return {}; return (this.#localSettings ??= getDefaultSettings()); } get pendingSettingsAck() { - return this.#pendingSettingsAck; + return this.#pendingSettingsCallbacks.length > 0; } get type() { @@ -4788,7 +4738,7 @@ class ServerHttp2Session extends Http2Session { return this.#socket_proxy; } get state() { - return this.#parser?.getCurrentState(); + return this.#parser?.getCurrentState() ?? {}; } get [bunHTTP2Native]() { @@ -4807,7 +4757,7 @@ class ServerHttp2Session extends Http2Session { setTimeout(msecs: number, callback?: () => void) { // 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?: ArrayBufferView | PingCallback, callback?: PingCallback) { @@ -4839,16 +4789,6 @@ class ServerHttp2Session extends Http2Session { } return true; } - goaway(code = NGHTTP2_NO_ERROR, lastStreamID = 0, opaqueData?: ArrayBufferView) { - if (this.destroyed) throw $ERR_HTTP2_INVALID_SESSION(); - - if (opaqueData !== undefined) { - validateBuffer(opaqueData, "opaqueData"); - } - validateInteger(code, "code", 0, kMaxUint32); - validateNumber(lastStreamID, "lastStreamID"); - return this.#parser?.goaway(code, lastStreamID, opaqueData); - } setLocalWindowSize(windowSize: number) { if (this.destroyed) throw $ERR_HTTP2_INVALID_SESSION(); @@ -4859,42 +4799,26 @@ class ServerHttp2Session extends Http2Session { settings(settings: Settings, callback?: SettingsCallback) { 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 // in ServerHttp2Session's constructor). Clients still accept `enablePush` // via their own `settings()` method. settings = { ...settings, enablePush: false }; - if (typeof settings.maxConcurrentStreams === "number") { - this.#advertisedMaxConcurrentStreams = settings.maxConcurrentStreams; - } // node: enforce maxOutstandingSettings - the session is destroyed with // ERR_HTTP2_MAX_PENDING_SETTINGS_ACK when too many SETTINGS are un-ACKed. - this.#pendingSettingsAckCount++; - if (this.#pendingSettingsAckCount > this.#maxOutstandingSettings) { + if (this.#pendingSettingsCallbacks.length >= this.#maxOutstandingSettings) { this.destroy($ERR_HTTP2_MAX_PENDING_SETTINGS_ACK(), constants.NGHTTP2_INTERNAL_ERROR); 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. @@ -4910,30 +4834,26 @@ class ServerHttp2Session extends Http2Session { // node submits a graceful GOAWAY as soon as close() is called; the peer observes the shutdown // ('goaway' event) while in-flight streams are still allowed to finish. The session is only // destroyed once there is nothing in flight, and never before the GOAWAY had a chance to leave. - this.goaway(constants.NGHTTP2_NO_ERROR, 0, Buffer.alloc(0)); + this.goaway(); this[kGoawaySent] = true; this.#parser?.flush?.(); - if (this.#connections === 0) { + if (this.#isClosedAndIdle()) { // Same bounded grace the client applies: an unACKed SETTINGS or ping still // reaches its callback/'localSettings' before the session is destroyed. - if (this.#pendingSettingsAckCount > 0 || (this.#pingCallbacks !== null && this.#pingCallbacks.length > 0)) { + if ( + this.#pendingSettingsCallbacks.length > 0 || + (this.#pingCallbacks !== null && this.#pingCallbacks.length > 0) + ) { scheduleSettingsAckGraceNT(this); } else { captureHttp2PerfFrameSnapshot(this, this.#parser); - setImmediate(destroyIfNotDestroyedNT, this); + setImmediate(destroySessionInternally, this); } } } 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. `destroyed` reads this latch. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js (Http2Session#destroy) if (this.#destroying) { return; @@ -4966,35 +4886,13 @@ class ServerHttp2Session extends Http2Session { } if (!this.#connected) return; - // Like node's finishSessionClose, which only leaves the socket to the peer after close(). - destroysSocket = !!error || !this.#closed; + // Only a teardown the session starts itself ends a graceful close(). A destroy() that the program calls destroys every stream. + const endsGracefulClose = this[kInternalDestroy] === true && this.#isClosedAndIdle(); + const unread = unreadClosedStreams(this.#parser); 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 (destroysSocket) { - // 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. + sendGoaway(this, code || constants.NGHTTP2_NO_ERROR, 0); } const parser = this.#parser; if (parser) { @@ -5008,8 +4906,22 @@ 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(unread, error, endsGracefulClose, !socket || socket.destroyed); + this.#connected = false; + if (socket) { + destroysSocket = !!error || !this.#closeCalled; + if (destroysSocket) { + 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 @@ -5019,6 +4931,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. `pendingSettingsAck` stays as it is. + this.#pendingSettingsCallbacks.fill(null); // 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. @@ -5038,20 +4952,21 @@ class ServerHttp2Session extends Http2Session { } } } -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: number, callback?: () => void) { +function setIdleTimeout(this: any, msecs: number, callback: (() => void) | undefined, session, onExpired) { if (this.destroyed) return this; this.timeout = msecs; msecs = getTimerDuration(msecs, "msecs"); @@ -5067,9 +4982,8 @@ function setSessionTimeout(this: Http2Session, msecs: number, callback?: () => v // 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) as NodeJS.Timeout).unref(); + this[kTimeoutWrittenSnapshot] = sessionBytesWritten(session); + this[kTimeout] = (setTimeout(onExpired, msecs, this) as NodeJS.Timeout).unref(); if (callback !== undefined) { validateFunction(callback, "callback"); this.once("timeout", callback); @@ -5078,20 +4992,29 @@ function setSessionTimeout(this: Http2Session, msecs: number, callback?: () => v 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 // socket instead of observing an abortive close. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js#L1188 -function destroySessionSocketDelayedNT(socket: SessionSocket, error: Error | undefined) { +function destroySessionSocketDelayedNT(socket: SessionSocket, error: Error | null | undefined) { if (!socket.destroyed) { socket.destroy(error); } } -function endThenDestroySessionSocket(socket: SessionSocket, error: Error | undefined) { +function endThenDestroySessionSocket(socket: SessionSocket, error: Error | null | undefined) { socket.end(() => setImmediate(destroySessionSocketDelayedNT, socket, error)); } +function destroyTransportSoon(this: $Node.Duplex) { + 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 @@ -5108,8 +5031,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(); @@ -5128,13 +5050,75 @@ 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); + // A flowing stream with nothing buffered emits 'end' within a few ticks, whatever happens to its session. + if (!stream.readableFlowing || stream.readableLength > 0) { + stream[bunHTTP2StreamStatus] |= StreamState.AwaitsReader; + } + } else if (!stream.writableFinished && !stream.destroyed && stream.errored == null) { + // END_STREAM closed the stream from inside end(), or from respond() just before its end(). Destroying now would swallow 'finish'. An errored stream never emits it. + if (!stream[kEndingWithChunk]) stream.end(); + stream.once("finish", destroySelfOnEnd); + } else { + stream.destroy(); + } +} +// Read before the parser is detached, which forgets them. +function unreadClosedStreams(parser: $Bindgen.H2FrameParser | null) { + const streams: Http2Stream[] = []; + parser?.forEachStream(stream => { + if ((stream[bunHTTP2StreamStatus] & StreamState.AwaitsReader) !== 0) streams.push(stream); + }, true); + return streams; +} +// Session teardown destroys them here or they never emit 'close'. +function settleUnreadClosedStreams( + streams: Http2Stream[], + error: Error | null | undefined, + endsGracefulClose: boolean, + transportGone: boolean, +) { + // A graceful close finishes without waiting for unread streams, so they stay readable. + if (error == null && endsGracefulClose) 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); } @@ -5159,7 +5143,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); } } @@ -5178,29 +5163,24 @@ class ClientHttp2Session extends Http2Session { #closed: boolean = false; // One-shot destroy latch (Node: "if (this.destroyed) return;" opens destroy()). #destroying: boolean = false; + // An error GOAWAY has arrived. destroy() follows once the streams that the peer left out are rejected. + #failedByGoaway: boolean = false; /// closeCalled tracks whether close() specifically was called: `session.closed` only reports a /// graceful close() in node — destroy() leaves it false while `session.destroyed` flips to true. #closeCalled: boolean = false; /// connected indicates that the connection/socket is connected #connected: boolean = false; - #connections: number = 0; #socket_proxy: import("node:net").Socket | import("node:tls").TLSSocket | undefined; #parser: $Node.H2FrameParser | null; - #url: URL; + #defaultScheme: string; #authority: string; #alpnProtocol: string | undefined = undefined; #localSettings: Settings | null = null; #encrypted: boolean = false; - #pendingSettingsAck: boolean = true; - // Count of SETTINGS frames sent that the peer has not yet ACKed (the initial connection - // SETTINGS counts as the first). node destroys the session with - // ERR_HTTP2_MAX_PENDING_SETTINGS_ACK when this exceeds maxOutstandingSettings. - #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<[SettingsCallback, number] | null> = [null]; + // node's Http2Settings queue: one entry per SETTINGS frame the peer hasn't ACKed yet (null for the initial one). + #pendingSettingsCallbacks: Array = [null]; #remoteSettings: Settings | null = null; #pingCallbacks: Array<[PingCallback, number]> | null = null; // RFC 9113 reserved (pushed) streams the peer may have open at once (node session option). @@ -5208,30 +5188,10 @@ class ClientHttp2Session extends Http2Session { #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; + // Requests without an id yet, like node's: they wait, in order, for the socket to connect or for a slot under the peer's SETTINGS_MAX_CONCURRENT_STREAMS. + #pendingRequests: Set | null = null; static #Handlers = { - binaryType: "buffer", - streamStart(self: ClientHttp2Session, stream_id: number) { - 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, pushId: number, @@ -5239,7 +5199,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); @@ -5259,12 +5220,13 @@ 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--; }); - self.#connections++; self.#parser?.setStreamContext(pushId, pushedStream); if (onClientStreamCreatedChannel.hasSubscribers) { onClientStreamCreatedChannel.publish({ stream: pushedStream, headers }); @@ -5279,24 +5241,19 @@ 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"); - } - self.#connections--; - process.nextTick(emitStreamErrorNT, self, stream, error, true, self.#connections === 0 && self.#closed); + abortOnPeerCancel(stream); + self.#flushPendingRequests(); + process.nextTick(emitStreamErrorNT, self, stream, error, self.#isClosedAndIdle()); }), streamError: withStreamFrame((self: ClientHttp2Session, stream: ClientHttp2Stream, error: number) => { if (!self || typeof stream !== "object") return; - - self.#connections--; - process.nextTick(emitStreamErrorNT, self, stream, error, true, self.#connections === 0 && self.#closed); + self.#flushPendingRequests(); + process.nextTick(emitStreamErrorNT, self, stream, error, self.#isClosedAndIdle()); }), streamEnd: withStreamFrame((self: ClientHttp2Session, stream: ClientHttp2Stream, state: number) => { if (!self || typeof stream !== "object") return; @@ -5307,46 +5264,30 @@ 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 if (state === 7) { - 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(); - } - if (self.#connections === 0 && self.#closed) { + self.#flushPendingRequests(); + destroyClosedStream(self, stream); + if (self.#isClosedAndIdle()) { // 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 // ping gets bounded grace (scheduleSettingsAckGraceNT); its arrival completes destroy. - if (self.#pendingSettingsAckCount > 0 || (self.#pingCallbacks !== null && self.#pingCallbacks.length > 0)) { + if ( + self.#pendingSettingsCallbacks.length > 0 || + (self.#pingCallbacks !== null && self.#pingCallbacks.length > 0) + ) { scheduleSettingsAckGraceNT(self); } else { captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); - setImmediate(destroyIfNotDestroyedNT, self); + setImmediate(destroySessionInternally, self); } } } else if (state === 5) { @@ -5365,7 +5306,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) { @@ -5424,32 +5367,22 @@ class ClientHttp2Session extends Http2Session { localSettings(self: ClientHttp2Session, settings: Settings) { if (!self) return; self.#localSettings = settings; - self.#pendingSettingsAck = false; - if (self.#pendingSettingsAckCount > 0) self.#pendingSettingsAckCount--; + const acked = self.#pendingSettingsCallbacks.shift(); // This ACK may be the only thing a gracefully closed, idle session was waiting for. const pingsDrained = self.#pingCallbacks === null || self.#pingCallbacks.length === 0; - if (self.#pendingSettingsAckCount === 0 && pingsDrained && self[kSettingsAckGraceTimer] !== undefined) { + if (self.#pendingSettingsCallbacks.length === 0 && pingsDrained && self[kSettingsAckGraceTimer] !== undefined) { clearTimeout(self[kSettingsAckGraceTimer]); self[kSettingsAckGraceTimer] = undefined; } - if ( - self.#closed && - self.#connections === 0 && - self.#pendingSettingsAckCount === 0 && - pingsDrained && - !self.destroyed - ) { + if (self.#isClosedAndIdle() && self.#pendingSettingsCallbacks.length === 0 && pingsDrained && !self.destroyed) { captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); - setImmediate(destroyIfNotDestroyedNT, self); + setImmediate(destroySessionInternally, 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, acked); }, 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. @@ -5458,6 +5391,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); } @@ -5471,20 +5405,12 @@ class ClientHttp2Session extends Http2Session { const stats = self[kPerfStats]; if (stats !== undefined) stats.pingRTT = rtt; callback(null, rtt, payload); - if (callbacks.length === 0 && self.#pendingSettingsAckCount === 0) { + if (callbacks.length === 0 && self.#pendingSettingsCallbacks.length === 0) { if (self[kSettingsAckGraceTimer] !== undefined) { 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(); } } } @@ -5526,26 +5452,23 @@ class ClientHttp2Session extends Http2Session { // fail over to a fresh connection). self[kReceivedGoaway] = true; self.emit("goaway", errorCode, lastStreamId, opaqueData || Buffer.allocUnsafe(0)); + if (errorCode !== constants.NGHTTP2_NO_ERROR) self.#failedByGoaway = true; // node: streams the peer did not process (id above the GOAWAY's lastStreamId) are // destroyed with ERR_HTTP2_GOAWAY_SESSION - clients (grpc) rely on that error class to // retry on a fresh connection instead of reporting a cancellation. 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(); - } else if (self.closed && self.#connections === 0) { + // 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.#isClosedAndIdle()) { // Mutual teardown: this side already sent its own graceful GOAWAY // (close()) and every stream is done, so the peer's error code cannot // affect anything user-visible - both ends agreed the connection is @@ -5554,32 +5477,32 @@ class ClientHttp2Session extends Http2Session { // peer's late GOAWAY could be read - grpc-js forceShutdown sends // GOAWAY(NGHTTP2_CANCEL) at a client that just close()d, and on the // Windows agents the frame deterministically arrived first). - self.destroy(); + destroySessionInternally(self); } else { self.destroy(sessionErrorFromCode(errorCode), constants.NGHTTP2_NO_ERROR); } }, end(self: ClientHttp2Session, errorCode: number, lastStreamId: number, opaqueData: Buffer) { if (!self) return; - self.destroy(); + destroySessionInternally(self); }, altsvc(self: ClientHttp2Session, origin: string, value: string, streamId: number) { if (!self) return; // 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 && origin) { const originSet = initOriginSet(self, socket); const origins = $isArray(origin) ? origin : [origin]; - for (const item of origins) { + for (let i = 0; i < origins.length; i++) { if (originSet.size >= self.#maxOriginSetSize) { self.destroy($ERR_HTTP2_TOO_MANY_ORIGINS(self.#maxOriginSetSize)); return; } - originSet.add(item); + originSet.add(origins[i]); } self.emit("origin", origins); } @@ -5632,37 +5555,39 @@ class ClientHttp2Session extends Http2Session { this.#parser!.setNativeSocket(nativeSocket); this[kDeferWriteCallback] = deferWriteCallbackForSocket(nativeSocket); } - process.nextTick(emitConnectNT, this, socket); this.#parser!.flush(); - 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(); - return; - } - // Requests made while the socket was still connecting were queued; submit them now. - this.#flushPendingRequests(); + } + + // Requests made while connecting go out after the 'connect' listeners, which can still destroy() or close() the session. + #emitConnect(socket, wasConnecting: boolean) { + // On a transport that was already connected, Node sends a request from inside request(). So it goes out first, and close() waits for it. + if (!wasConnecting) this.#flushPendingRequests(); + this.emit("connect", this, socket); + // Like Node, close() rejects the requests that waited for the socket to connect. + if (this.#closed && wasConnecting) this.#rejectPendingRequests(); + else this.#flushPendingRequests(); + // A 'connect' listener can open a request and then close(). That request still completes. + if (this.#isClosedAndIdle()) destroySessionInternally(this); } #onClose() { 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.#isClosedAndIdle(); + const unread = unreadClosedStreams(parser); if (parser) { parser.forEachStream(streamCancel); parser.forEachStream(streamSocketClosed); parser.detach(); this.#parser = null; } + settleUnreadClosedStreams(unread, err, closedAndIdle, true); + // Node's socketOnClose calls close() on a session that's still attached to the socket. + if (!this.destroyed) this.#closeCalled = true; + // Like Node's socketOnClose, before the session counts as destroyed. + this.#cancelPendingRequests(err, NGHTTP2_NO_ERROR); this.destroy(err, NGHTTP2_NO_ERROR); this[bunHTTP2Socket] = null; } @@ -5673,13 +5598,12 @@ class ClientHttp2Session extends Http2Session { if (this.destroyed) { return; } - this[bunHTTP2Socket] = null; if (this.#closed) { - this.destroy(); + destroySessionInternally(this); return; } if (isEconnresetAfterGoaway(this, error)) { - this.destroy(); + destroySessionInternally(this); return; } if (this.listenerCount("error") === 0 && (error as NodeJS.ErrnoException)?.code === "ECONNRESET") { @@ -5688,18 +5612,11 @@ class ClientHttp2Session extends Http2Session { // still errors any remaining streams. Anything else (handshake // failure, ECONNREFUSED, ...) keeps Node's EventEmitter contract and // surfaces when unobserved. - this.destroy(); + destroySessionInternally(this); return; } this.destroy(error); } - #onTimeout() { - const parser = this.#parser; - if (parser) { - parser.forEachStream(emitTimeout); - } - this.emit("timeout"); - } #onDrain() { const parser = this.#parser; if (parser) { @@ -5717,7 +5634,8 @@ class ClientHttp2Session extends Http2Session { return this[bunHTTP2Socket]?.connecting === false; } get destroyed() { - return this[bunHTTP2Socket] === null; + // Like Node's flag, true before a teardown reaches the first stream. The socket slot is cleared last. + return this.#destroying || this.#failedByGoaway || this[bunHTTP2Socket] === null; } get encrypted() { return this.#encrypted; @@ -5727,17 +5645,17 @@ class ClientHttp2Session extends Http2Session { } get remoteSettings() { - if (this.destroyed || this.connecting) return {}; + if (this[bunHTTP2Socket] === null || this.connecting) return {}; return (this.#remoteSettings ??= getDefaultSettings()); } get localSettings() { - if (this.destroyed || this.connecting) return {}; + if (this[bunHTTP2Socket] === null || this.connecting) return {}; return (this.#localSettings ??= getDefaultSettings()); } get pendingSettingsAck() { - return this.#pendingSettingsAck; + return this.#pendingSettingsCallbacks.length > 0; } get type() { @@ -5762,7 +5680,7 @@ class ClientHttp2Session extends Http2Session { setTimeout(msecs: number, callback?: () => void) { // 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?: ArrayBufferView | PingCallback, callback?: PingCallback) { if (this.destroyed) throw $ERR_HTTP2_INVALID_SESSION(); @@ -5793,10 +5711,6 @@ class ClientHttp2Session extends Http2Session { } return true; } - goaway(errorCode = constants.NGHTTP2_NO_ERROR, lastStreamId = 0, opaqueData?: ArrayBufferView) { - if (this.destroyed) throw $ERR_HTTP2_INVALID_SESSION(); - return this.#parser?.goaway(errorCode, lastStreamId, opaqueData); - } setLocalWindowSize(windowSize: number) { if (this.destroyed) throw $ERR_HTTP2_INVALID_SESSION(); @@ -5814,35 +5728,22 @@ class ClientHttp2Session extends Http2Session { return this.#socket_proxy; } get state() { - return this.#parser?.getCurrentState(); + return this.#parser?.getCurrentState() ?? {}; } settings(settings: Settings, callback?: SettingsCallback) { 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++; - if (this.#pendingSettingsAckCount > this.#maxOutstandingSettings) { + if (this.#pendingSettingsCallbacks.length >= this.#maxOutstandingSettings) { this.destroy($ERR_HTTP2_MAX_PENDING_SETTINGS_ACK(), constants.NGHTTP2_INTERNAL_ERROR); 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?: ConnectListener) { @@ -5884,17 +5785,9 @@ class ClientHttp2Session extends Http2Session { if (options.strictFieldWhitespaceValidation === false) { 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"; @@ -5919,7 +5812,9 @@ class ClientHttp2Session extends Http2Session { this.#authority = needsBrackets ? `[${authorityHost}]:${port}` : `${authorityHost}:${port}`; } + let constructorThrew = false; function onConnect(this: ClientHttp2Session) { + 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) { @@ -5927,17 +5822,19 @@ class ClientHttp2Session extends Http2Session { return; } try { - this.#onConnect(arguments); - listener?.$call(this, this); + this.#onConnect(); } catch (e) { this.destroy(e as Error); } + // Outside the try, so a listener that throws doesn't destroy the session. + if (connectOnNextTick) this.#emitConnect(socket, false); + else process.nextTick(() => this.#emitConnect(socket, true)); } // h2 with ALPNProtocols let socket: TLSSocket | Socket; - if (typeof options?.maxOutstandingSettings === "number" && options.maxOutstandingSettings >= 1) { - this.#maxOutstandingSettings = options.maxOutstandingSettings; + if (typeof options?.maxOutstandingSettings === "number") { + this.#maxOutstandingSettings = Math.max(1, options.maxOutstandingSettings); } let connectOnNextTick = false; if (typeof options?.createConnection === "function") { @@ -5975,22 +5872,27 @@ class ClientHttp2Session extends Http2Session { const nativeSocket = socket._handle; this[kDeferWriteCallback] = deferWriteCallbackForSocket(nativeSocket); - if (options?.settings !== undefined) { - validateSettings(options.settings); + try { + const nativeSettings = nativeSessionSettings(options); + 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 @@ -5998,6 +5900,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. @@ -6013,7 +5916,7 @@ class ClientHttp2Session extends Http2Session { // node submits a graceful GOAWAY as soon as close() is called; the peer observes the shutdown // ('goaway' event) while in-flight streams are still allowed to finish. The session is only // destroyed once there is nothing in flight, and never before the GOAWAY had a chance to leave. - this.goaway(constants.NGHTTP2_NO_ERROR, 0, Buffer.alloc(0)); + this.goaway(); this[kGoawaySent] = true; this.#parser?.flush?.(); // Requests queued while the socket is still connecting count as in-flight too: they are @@ -6027,27 +5930,62 @@ 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.#isClosedAndIdle() || (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.#pendingSettingsCallbacks.length > 0 || (this.#pingCallbacks !== null && this.#pingCallbacks.length > 0)) { + scheduleSettingsAckGraceNT(this); + } else { + captureHttp2PerfFrameSnapshot(this, this.#parser); + setImmediate(destroySessionInternally, 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); + } + } + + // Requests that wait for the connect or for a stream slot never reached the wire. Like Node, cancel them with ERR_HTTP2_STREAM_CANCEL, whose cause is the session's error. + #cancelPendingRequests(error: Error | null | undefined, code: number | undefined) { + const pendingRequests = this.#pendingRequests; + this.#pendingRequests = null; + if (pendingRequests === null || pendingRequests.size === 0) return; + const cancelError = createPendingStreamCancelError(error); + const sessionCode = this[kGoawayCode] || code; + for (const req of pendingRequests) { + if (!req.destroyed) { + // 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); } } } 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. `destroyed` reads this latch. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js (Http2Session#destroy) if (this.#destroying) { return; @@ -6086,46 +6024,14 @@ class ClientHttp2Session extends Http2Session { // Streams torn down by this destroy surface the same session error (node semantics). this[kSessionDestroyError] = error; } - // Like node's finishSessionClose, which only leaves the socket to the peer after close(). - destroysSocket = !!error || !this.#closed; + // Only a teardown the session starts itself ends a graceful close(). A destroy() that the program calls destroys every stream. + const endsGracefulClose = this[kInternalDestroy] === true && this.#isClosedAndIdle(); + const unread = unreadClosedStreams(this.#parser); 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) { - const cancelError = createPendingStreamCancelError(error); - for (let i = 0; i < pendingRequests.length; i++) { - const req = pendingRequests[i].req; - if (!req.destroyed) { - req.rstCode = code !== undefined ? code : constants.NGHTTP2_CANCEL; - 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 (destroysSocket) { - // See the client session: end first, destroy a tick later (node's - // finishSessionClose Windows-ECONNRESET avoidance). - endThenDestroySessionSocket(socket, error ?? undefined); - } 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(); - } + this.#cancelPendingRequests(error, code); + if (socket && (!this[kGoawaySent] || code)) { + // close() already sent GOAWAY(NO_ERROR), so only an error code is worth a second frame. + sendGoaway(this, code || constants.NGHTTP2_NO_ERROR, 0); } const parser = this.#parser; if (parser) { @@ -6137,13 +6043,34 @@ 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(unread, error, endsGracefulClose, !socket || socket.destroyed); + this.#connected = false; + if (socket) { + destroysSocket = !!error || !this.#closeCalled; + if (destroysSocket) { + 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 @@ -6154,6 +6081,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. `pendingSettingsAck` stays as it is. + this.#pendingSettingsCallbacks.fill(null); // 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. @@ -6174,16 +6103,15 @@ class ClientHttp2Session extends Http2Session { } request(headers?: HeadersObject | any[] | null, options?: ClientRequestOptions) { - // Set once a stream id was allocated (streamStart incremented #connections); validation - // throws before that point must not decrement. - let connectionsCounted = false; + // Set once a stream id was allocated. + let allocated = false; 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"); } @@ -6247,8 +6175,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) { @@ -6260,15 +6187,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: unknown = 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]; } @@ -6288,7 +6207,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( @@ -6297,7 +6216,6 @@ class ClientHttp2Session extends Http2Session { "DEP0194", ); } - delete options.weight; } const sensitives = headers[sensitiveHeaders]; @@ -6308,33 +6226,10 @@ class ClientHttp2Session extends Http2Session { throw $ERR_INVALID_ARG_VALUE("headers[http2.neverIndex]", sensitives); } const sensitiveNames = buildSensitiveNames(headers, sensitives); - // Validate header names in JS like node's buildNgHeaderString does: request() must throw - // synchronously for an invalid name even while the session is still connecting (the native - // encoder only sees the headers once a queued request is actually submitted). Empty names - // keep going to the native validator, matching its acceptance. - { - const headerNames = ObjectKeys(headers); - for (let i = 0; i < headerNames.length; i++) { - const name = headerNames[i]; - if (name === "") 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. - assertValidPseudoHeader(name); - continue; - } - if (!checkIsHttpToken(name)) { - throw $ERR_INVALID_HTTP_TOKEN("Header name", name); - } - } - } - // 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. @@ -6367,20 +6262,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) { @@ -6398,14 +6281,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; + } } } } @@ -6472,30 +6356,31 @@ class ClientHttp2Session extends Http2Session { // a "pending" stream (no id) and sends its HEADERS frame once the session connects / a slot // frees, keeping stream id allocation in submission order. Deferring until connect is what // lets a stream destroyed before connect never reach the wire. - const maxConcurrentStreams = this.#remoteSettings?.maxConcurrentStreams; - if ( - !this.#connected || - (this.#pendingRequests !== null && this.#pendingRequests.length > 0) || - (typeof maxConcurrentStreams === "number" && this.#activeRequestCount >= maxConcurrentStreams) - ) { + // 0 if the limit leaves no slot. + const stream_id: number = + this.#connected && (this.#pendingRequests === null || this.#pendingRequests.size === 0) + ? this.#parser!.getNextStream() + : 0; + if (stream_id === 0) { + const wireHeaders = rawHeadersList !== null ? rawHeadersList : headers; + // The encoder only sees the headers when the request is submitted, and request() has to throw now. + this.#parser!.validateRequest(wireHeaders); 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, + wireHeaders, 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(); @@ -6504,8 +6389,6 @@ class ClientHttp2Session extends Http2Session { return req; } - connectionsCounted = true; - let stream_id: number = this.#parser!.getNextStream(); if (stream_id < 0) { const req = new ClientHttp2Stream(undefined, this, headers); process.nextTick(emitOutofStreamErrorNT, req); @@ -6518,92 +6401,86 @@ class ClientHttp2Session extends Http2Session { onClientStreamCreatedChannel.publish({ stream: req, headers }); } const wireHeaders = rawHeadersList !== null ? rawHeadersList : headers; + allocated = true; this.#parser!.request(stream_id, req, wireHeaders, sensitiveNames, options); + allocated = false; 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) { - if (connectionsCounted) { - this.#connections--; - process.nextTick(emitErrorNT, this, e, this.#connections === 0 && this.#closed); + // Nothing reached the wire, so the session is still usable. Node only throws here. + if (allocated) { + this.#flushPendingRequests(); + if (this.#isClosedAndIdle()) setImmediate(destroySessionInternally, 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) { - this.#activeRequestCount++; - req.once("close", () => { - this.#activeRequestCount--; - this.#flushPendingRequests(); - }); + // close() or destroy() was called and no stream is open on the wire. + #isClosedAndIdle() { + return this.#closed && !this.#parser?.hasOpenStreams(); } // 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(); - } - 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); + req.rstCode = constants.NGHTTP2_CANCEL; + req.destroy(); continue; } - if (onClientStreamStartChannel.hasSubscribers) { - onClientStreamStartChannel.publish({ stream: req, headers }); + const stream_id = parser.getNextStream(); + if (stream_id === 0) break; + 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, stream_id, queued); + } + // A closed session's last queued request can fail above without ever opening a stream. + this.#maybeDestroy(); + } + + #submitQueuedRequest( + parser: $Bindgen.H2FrameParser, + req: ClientHttp2Stream, + stream_id: number, + { headers, wireHeaders, sensitiveNames, options }: NonNullable, + ) { + if (stream_id < 0) { + process.nextTick(emitOutofStreamErrorNT, req); + return; + } + 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); } - this.#trackActiveRequest(req); - process.nextTick(emitEventNT, req, "ready"); + } catch (err) { + // Native rejected the headers and closed the stream, which the peer never heard of. + 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?: ConnectListener) { return new ClientHttp2Session(url, options, listener); @@ -6635,7 +6512,7 @@ function sessionOnTimeout(this: ServerHttp2Session) { if (this.destroyed || this.closed) return; const server = this[kServer]!; if (!server.emit("timeout", this)) { - this.destroy(); + destroySessionInternally(this); } } /** @@ -6651,18 +6528,12 @@ function closeAllSessions(server: Http2Server | Http2SecureServer) { } } -const { - connectionListenerHTTP1, - closeIdleHttp1Connections, - kHttp1Connections, -} = require("internal/http1_server_fallback"); - function connectionListener(this: Http2Server | Http2SecureServer, socket: SessionSocket) { const options = this[bunSocketServerOptions] || {}; if (socket.alpnProtocol === false || socket.alpnProtocol === "http/1.1") { if (options.allowHTTP1 === true) { // Fallback to HTTP/1.1 - return connectionListenerHTTP1(this, socket as $Node.HttpSocket, options); + return require("node:_http_server")._connectionListener.$call(this, socket); } // Let event handler deal with the socket @@ -6795,7 +6666,7 @@ class Http2Server extends (net.Server as unknown as Http2ServerBase) { return this; } updateSettings(settings: Settings) { - assertSettings(settings); + validateSettings(settings); const options = this[bunSocketServerOptions]; if (options) { options.settings = { ...options.settings, ...settings }; @@ -6813,9 +6684,7 @@ Http2Server.prototype[EventEmitter.captureRejectionSymbol] = function (err, even switch (event) { case "stream": { const { 0: stream } = args as [ServerHttp2Stream]; - // 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 as Error); } else { stream.respond({ [HTTP2_HEADER_STATUS]: 500 }); @@ -6842,11 +6711,6 @@ Http2Server.prototype[EventEmitter.captureRejectionSymbol] = function (err, even } }; -// Compiled ahead of time, a store that names the property makes it a field of the class, and every read of the name on another object first takes that object for one of this class. -function storeUnderName(server: object, name: string, value: unknown) { - (server as any)[name] = value; -} - function onErrorSecureServerSession(this: Http2SecureServer, err: Error, socket: TLSSocket) { if (!this.emit("clientError", err, socket)) { // The handshake-failed socket has no 'error' listener yet; destroying it with the error @@ -6857,18 +6721,33 @@ function onErrorSecureServerSession(this: Http2SecureServer, err: Error, socket: } function emitFrameErrorEventNT(stream: Http2Stream, frameType: number, errorCode: number) { - 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(); +} +const kNumericHTTP1Options = [ + "keepAliveTimeout", + "keepAliveTimeoutBuffer", + "headersTimeout", + "requestTimeout", + "connectionsCheckingInterval", + "maxHeaderSize", +]; 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: "insecure" | "relaxed" | "strict" | undefined; declare joinDuplicateHeaders: boolean | undefined; + declare requireHostHeader: boolean | undefined; timeout = 0; [kSessions]: Set = new SafeSet(); constructor(options?, onRequestHandler?: RequestHandler) { @@ -6883,23 +6762,19 @@ class Http2SecureServer extends (tls.Server as unknown as Http2SecureServerBase) this.setMaxListeners(0); this.on("newListener", setupCompat); if (options.allowHTTP1 === true) { - this[kHttp1Connections] = new SafeSet(); + const { storeHTTPOptions, setupConnectionsTracking } = require("node:_http_server"); const http1Options = { ...options, ...options.http1Options }; - this.keepAliveTimeout = http1Options.keepAliveTimeout ?? 5000; - this.headersTimeout = http1Options.headersTimeout ?? 60000; - this.requestTimeout = http1Options.requestTimeout ?? 300000; + // Bun 1.4 took a numeric string (an environment variable) for these, and any number for the flag. + for (const name of kNumericHTTP1Options) { + const value = http1Options[name]; + if (typeof value === "string" && value.trim() !== "" && Number.isFinite(+value)) http1Options[name] = +value; + } + const { insecureHTTPParser } = http1Options; + if (typeof insecureHTTPParser === "number") http1Options.insecureHTTPParser = insecureHTTPParser !== 0; + storeHTTPOptions.$call(this, http1Options); this.maxHeadersCount = http1Options.maxHeadersCount ?? null; this.maxRequestsPerSocket = http1Options.maxRequestsPerSocket ?? 0; - const joinDuplicateHeaders = http1Options.joinDuplicateHeaders; - if (joinDuplicateHeaders !== undefined) validateBoolean(joinDuplicateHeaders, "options.joinDuplicateHeaders"); - this.joinDuplicateHeaders = joinDuplicateHeaders; - // connectionListenerHTTP1 reads these off the server when initializing - // the per-connection parser, matching Node's storeHTTPOptions. - this.maxHeaderSize = http1Options.maxHeaderSize; - if (!IS_COMPILED_AHEAD_OF_TIME) this.insecureHTTPParser = http1Options.insecureHTTPParser; - else storeUnderName(this, "insecureHTTPParser", http1Options.insecureHTTPParser); - if (!IS_COMPILED_AHEAD_OF_TIME) this.httpValidation = http1Options.httpValidation; - else storeUnderName(this, "httpValidation", http1Options.httpValidation); + this.on("listening", setupConnectionsTracking); } if (typeof onRequestHandler === "function") { this.on("request", onRequestHandler); @@ -6917,7 +6792,7 @@ class Http2SecureServer extends (tls.Server as unknown as Http2SecureServerBase) return this; } updateSettings(settings: Settings) { - assertSettings(settings); + validateSettings(settings); const options = this[bunSocketServerOptions]; if (options) { options.settings = { ...options.settings, ...settings }; @@ -6927,11 +6802,15 @@ 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) { + require("node:_http_server").httpServerPreClose(this); + } closeAllSessions(this); } closeIdleConnections() { - if (this[bunSocketServerOptions]?.allowHTTP1 === true) closeIdleHttp1Connections(this); + if (this[bunSocketServerOptions]?.allowHTTP1 === true) { + require("node:_http_server").Server.prototype.closeIdleConnections.$call(this); + } } } function createServer(options?, onRequestHandler?: RequestHandler) { diff --git a/src/js/ahead-of-time/node/https.ts b/src/js/ahead-of-time/node/https.ts index 72ab756f7a0e..d59379f0470a 100644 --- a/src/js/ahead-of-time/node/https.ts +++ b/src/js/ahead-of-time/node/https.ts @@ -1,5 +1,5 @@ // What the compiler reads in place of src/js/node/https.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 95c4c778f38564720fd172eefcb2549f3b525352f189c741d6170e9e5591d30f. +// Written against that file with the SHA-256 56929ba4d63ac5226dccc2df0e4f66b325127aa0221dc4984e82aed49c62b36b. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. // Hardcoded module "node:https" @@ -15,8 +15,16 @@ const { checkShouldUseProxy, kWaitForProxyTunnel, kPerRequestCheckServerIdentity, + getMaxHTTPHeaderSize, isTlsSymbol, + httpServerParts, + serverSymbol, + tlsSymbol, } = require("internal/http"); +const { kSNIContexts } = require("internal/net/symbols"); +const buntls = Symbol.for("::buntls::"); +const setListenerSecureContext = $bindgen("Listener.bind.ts", "setSecureContext"); +const addServerName = $bindgen("Listener.bind.ts", "addServerName"); const { validateHeaderValue } = require("node:_http_common"); const { isAnyArrayBuffer, isDataView } = require("node:util/types"); @@ -24,6 +32,9 @@ const ArrayPrototypeShift = Array.prototype.shift; const ArrayPrototypeJoin = Array.prototype.join; const ArrayPrototypeMap = Array.prototype.map; const ObjectAssign = Object.assign; +const ObjectDefineProperties = Object.defineProperties; +const ObjectSetPrototypeOf = Object.setPrototypeOf; +const ReflectDefineProperty = Reflect.defineProperty; const ArrayPrototypeUnshift = Array.prototype.unshift; const JSONStringify = JSON.stringify; const WeakMapPrototypeGet = WeakMap.prototype.get; @@ -110,6 +121,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, @@ -124,8 +136,8 @@ function getTunnelConfigForProxiedHttps(agent, reqOptions) { return result; } -function establishTunnel(agent, socket, options, tunnelConfig, afterSocket) { - const { proxyTunnelPayload } = tunnelConfig; +function establishTunnel(agent, socket, tunnelConfig, afterSocket) { + 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; @@ -162,14 +174,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`); @@ -344,7 +362,7 @@ function createConnection(...args) { }); function onProxyConnection() { socket.removeListener("error", onError); - establishTunnel(agent, socket, options, tunnelConfig, cleanupAndPropagate); + establishTunnel(agent, socket, tunnelConfig, cleanupAndPropagate); } if (this[kProxyConfig].protocol === "http:") { socket = require("node:net").connect(connectOptions, onProxyConnection); @@ -587,6 +605,7 @@ const { shouldUseEnvProxy } = require("node:_http_agent"); // https://github.com/nodejs/node/blob/v26.3.0/lib/https.js#L82-L97 function Server(options, requestListener): void { if (!(this instanceof Server)) return new Server(options, requestListener) as any; + if (!serverClassLinked) linkServerClass(); if (typeof options === "function") { requestListener = options; options = {}; @@ -594,14 +613,15 @@ function Server(options, requestListener): void { options = {}; } else { validateObject(options, "options"); - options = { ...options }; + // tls.Server gets the caller's own enumerable options, as in Node. What Node reads by name is still found on the caller's object. + options = { __proto__: options, ...options }; } if (!options.ALPNProtocols && !options.ALPNCallback) { // http/1.0 is not defined as a Protocol ID in the IANA registry, so // ALPN requests are always answered with http/1.1. options.ALPNProtocols = ["http/1.1"]; } - http.Server.$call(this, options, requestListener); + httpServerParts.initHttpServer!.$call(this, options, requestListener); const optionsALPNProtocols = options.ALPNProtocols; if (optionsALPNProtocols) { require("node:tls").convertALPNProtocols(optionsALPNProtocols, this); @@ -616,10 +636,50 @@ function Server(options, requestListener): void { } return this; } -$toClass(Server, "Server", http.Server); +// https://github.com/nodejs/node/blob/v26.3.0/lib/https.js#L115-L122, with the tls.Server link deferred to first use. +$toClass(Server, "Server", require("node:events")); +ObjectDefineProperties(Server.prototype, httpServerParts.serverPrototypeDescriptors!); // A listener of this class speaks TLS whatever its options hold. Server.prototype[isTlsSymbol] = true; +let serverClassLinked = false; +function linkServerClass() { + const TLSServer = require("node:tls").Server; + ObjectSetPrototypeOf(Server.prototype, TLSServer.prototype); + ObjectSetPrototypeOf(Server, TLSServer); + serverClassLinked = true; +} + +// Replaces the accessor with a data property. On a frozen `exports` defineProperty returns false and the accessor stays. +function materializeServerExport(value) { + ReflectDefineProperty(https, "Server", { value, writable: true, configurable: true }); + return value; +} + +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]; + // Inherits, because tls.Server reads each option by name: one that is inherited, a getter or non-enumerable counts, as in Node. + tlsServer.setSecureContext({ __proto__: options, ...require("internal/tls").ignoredByHttpsServer }); + const handle = this[serverSymbol]; + if (handle) setListenerSecureContext(handle, tlsServer[buntls](null)[0].secureContext); +}; + +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); } @@ -632,7 +692,13 @@ var https = { timeout: 5000, proxyEnv: shouldUseEnvProxy() ? process.env : undefined, }), - Server, + get Server() { + if (!serverClassLinked) linkServerClass(); + return materializeServerExport(Server); + }, + set Server(value) { + materializeServerExport(value); + }, createServer, get, request, diff --git a/src/js/ahead-of-time/node/net.ts b/src/js/ahead-of-time/node/net.ts index e7a0ebd679bd..94fdc0dd2919 100644 --- a/src/js/ahead-of-time/node/net.ts +++ b/src/js/ahead-of-time/node/net.ts @@ -1,5 +1,5 @@ // What the compiler reads in place of src/js/node/net.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 d6861dbd59c52394231c89ff7bae0bcff81e31756f79b40db9ee7cee0562b4a5. +// Written against that file with the SHA-256 3e8cde01fb99e12e9b6cd1e8cc4f7cdc3618972cc0cd407bab8f1a61e096be99. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. // Hardcoded module "node:net" @@ -28,6 +28,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); const normalizedArgsSymbol = Symbol("normalizedArgs"); const { @@ -46,18 +48,27 @@ const getTimerDuration = timers.getTimerDuration; const { validateFunction, validateNumber, validatePort, validateBoolean, validateInt32, validateString } = require("internal/validators"); // prettier-ignore const { isIPv4, isIPv6, isIP } = require("internal/net/isIP"); const netSymbols = require("internal/net/symbols"); +const kAcceptedTLS: typeof netSymbols.kAcceptedTLS = netSymbols.kAcceptedTLS; const kArmHandshakeTimeout: typeof netSymbols.kArmHandshakeTimeout = netSymbols.kArmHandshakeTimeout; const kDestroyOnRead: typeof netSymbols.kDestroyOnRead = netSymbols.kDestroyOnRead; +const kForeignListener: typeof netSymbols.kForeignListener = netSymbols.kForeignListener; +const kHandleClosed: typeof netSymbols.kHandleClosed = netSymbols.kHandleClosed; const kPreHandshakeWrite: typeof netSymbols.kPreHandshakeWrite = netSymbols.kPreHandshakeWrite; +const kReadStop: typeof netSymbols.kReadStop = netSymbols.kReadStop; const kSecureConnectDone: typeof netSymbols.kSecureConnectDone = netSymbols.kSecureConnectDone; +const kSNIError: typeof netSymbols.kSNIError = netSymbols.kSNIError; const kStandaloneWrap: typeof netSymbols.kStandaloneWrap = netSymbols.kStandaloneWrap; const kUpgradeClientTLS: typeof netSymbols.kUpgradeClientTLS = netSymbols.kUpgradeClientTLS; const kVerifyError: typeof netSymbols.kVerifyError = netSymbols.kVerifyError; const { addServerAbortSignalOption } = require("internal/net/server_abort_signal"); +const AsyncContextFrame = require("internal/async_context_frame"); -type SocketHandle = $Node.SocketHandle<$Node.Socket | ConnectData>; +type SocketHandle = $Node.SocketHandle<$Node.Socket>; type ListenerHandle = $Node.ListenerHandle; -type NativeSocketError = Error & { errno?: number }; +// @mswjs/interceptors puts these on a handle. +type ReplacedConnect = (this: SocketHandle, req: ConnectReq, address: string, port?: number) => number; +type DialingHandle = SocketHandle & { connect?: ReplacedConnect; connect6?: ReplacedConnect }; +type NativeSocketError = Error & { errno?: number; syscall?: string }; type NativeConnectError = Error & { errno: number }; type ConnectException = InstanceType & { localAddress?: string; localPort?: number }; type SystemError = Error & { errno?: number; syscall?: string }; @@ -136,6 +147,7 @@ interface ConnectOptions { socket?: $Node.Socket; fd?: number; fdIsRawSocket?: boolean; + fdIsWriteOnly?: boolean; pauseOnConnect?: boolean; servername?: string; rejectUnauthorized?: boolean; @@ -164,26 +176,29 @@ interface NativeConnectOptions { unix?: string; fd?: number; fdIsRawSocket?: boolean; + fdIsWriteOnly?: boolean; localAddress?: string; localPort?: number; ipv6Only?: boolean; allowHalfOpen: boolean; tls?: TLSConnectOptions; pauseOnConnect?: boolean; - data: $Node.Socket | ConnectData; - socket: InternalSocketHandler<$Node.Socket> | InternalSocketHandler; + // kConnectDispatch() fills these two in. + data?: $Node.Socket; + socket?: InternalSocketHandler<$Node.Socket>; } declare global { namespace $Node { interface SocketOptions extends DuplexOptions { fd?: number; - handle?: SocketHandle; + handle?: SocketHandle; socket?: Socket; onread?: OnReadOptions | null; noDelay?: boolean; keepAlive?: boolean; keepAliveInitialDelay?: number; + typeOfService?: number; blockList?: BlockList; } @@ -195,13 +210,14 @@ declare global { interface Socket extends Duplex, HttpSocket { bytesRead: number; + _bytesDispatched: number; connecting: boolean; timeout: number; isServer: boolean; server: NetServer | null | undefined; _server: NetServer | undefined; // `new net.Socket({ handle })` keeps any object, which is why some members are tested for before they are called. - _handle: SocketHandle | null; + _handle: SocketHandle | null; _parent: Socket | null; _parentWrap: null; _host: string | undefined; @@ -260,7 +276,7 @@ declare global { _controlReleased?: boolean; _requestCert?: boolean; _rejectUnauthorized?: boolean; - [bunTlsSymbol]?: (port: unknown, host: string | null | undefined, isClient?: boolean) => TLSConnectOptions; + [bunTlsSymbol]?: (host: string | null | undefined) => TLSConnectOptions; [bunTLSConnectOptions]: TLSConnectOptions | null; [kConnectOptions]?: ConnectOptions; [kVerifyError]?: Error | null; @@ -275,7 +291,6 @@ declare global { [kerrorEmitted]?: boolean; [kTimeout]: Timer | null; - [kLastWriteQueueSize]: number; [kDestroyOnRead]: boolean; [kupgraded]: Socket | null; [kOnUpgradedClose]: (() => void) | undefined; @@ -284,14 +299,11 @@ declare global { [kSetKeepAlive]: boolean; [kSetKeepAliveInitialDelay]: number; [kSetTOS]?: number; - [khandlers]: InternalSocketHandler; + [khandlers]: InternalSocketHandler<$Node.Socket>; [kBytesWritten]: number | undefined; [kclosed]: boolean; [kended]: boolean; - [kwriteCallback]: WriteCallback | null | undefined; - [kRejectedWrite]?: Error; [ksocket]: Socket | undefined; - [kSyncWriteFd]?: number; [kUserUnrefed]?: boolean; [kPausedUnref]?: boolean; [kOnreadTail]?: Uint8Array; @@ -301,9 +313,9 @@ declare global { [kOnreadReadRequested]?: boolean; [kOnreadDeliver]?: (buffer: Uint8Array) => void; [kPerfHooksNetConnectContext]?: unknown; - [kAttach](port: number | undefined, socket: SocketHandle): void; + [kAttach](socket: SocketHandle): void; [kCloseRawConnection](): void; - [kReinitializeHandle](handle: SocketHandle): void; + [kReinitializeHandle](handle: SocketHandle): void; } // What this module reads and calls of a `tls.TLSSocket` (node/tls.ts). @@ -325,7 +337,7 @@ declare global { _emitTLSError(err: Error): void; setSession(session: string | Uint8Array): void; getPeerCertificate(detailed?: boolean): PeerCertificate | null; - [bunTlsSymbol]: (port: unknown, host: string | null | undefined, isClient?: boolean) => TLSConnectOptions; + [bunTlsSymbol]: (host: string | null | undefined) => TLSConnectOptions; [kNativeSecureContextCtor]: NativeSecureContextConstructor; [kUpgradeServerTLS](connection: Socket, tls: TLSConnectOptions): void; } @@ -382,11 +394,7 @@ declare global { _handshakeTimeout?: number; _SNICallback?: SNICallback; _ALPNCallback?: ALPNCallback; - [bunTlsSymbol]?: ( - port: unknown, - host: string | null | undefined, - isClient: boolean, - ) => [tls: ServerTLSOptions, TLSSocketClass: SocketConstructor]; + [bunTlsSymbol]?: (host: string | null | undefined) => [tls: ServerTLSOptions, TLSSocketClass: SocketConstructor]; [kNativeSecureContextCtor]?: NativeSecureContextConstructor; [kSNIError]?: unknown; @@ -408,7 +416,6 @@ declare global { writableAll: boolean, tls: ServerTLSOptions | undefined, contexts: Map | null, - onListen: unknown, fd: number | undefined, ): void; } @@ -440,23 +447,10 @@ interface ConnectReq { [kTraceConnectActive]?: boolean; } -interface ConnectData { - self: Self; - req: ConnectReq | undefined; -} - -interface PendingConnectReq extends ConnectReq { - oncomplete: NonNullable; -} - -interface PendingConnectData extends ConnectData { - get req(): PendingConnectReq; - set req(value: ConnectReq | undefined); -} - interface SNIState { server: $Node.NetServer | $Node.TLSSocket; socketHandle: $Node.SocketHandle<$Node.Socket> | undefined; + socket: $Node.Socket | undefined; selected: unknown; failed: Error | undefined; settled: boolean; @@ -479,6 +473,7 @@ interface TLSConnectOptions { // The handlers that the native socket calls (src/runtime/socket/Handlers.rs). interface InternalSocketHandler { + binaryType?: "buffer"; open?(socket: $Node.SocketHandle): void; close?(socket: $Node.SocketHandle, error?: NativeSocketError): void; error?(socket: $Node.SocketHandle, error: Error): void; @@ -488,12 +483,9 @@ interface InternalSocketHandler { end?(socket: $Node.SocketHandle): void; connectError?(socket: $Node.SocketHandle, error: NativeConnectError): void; timeout?(socket: $Node.SocketHandle): void; + writeComplete?(socket: $Node.SocketHandle, status: number): void; session?(socket: $Node.SocketHandle, session: Uint8Array): void; keylog?(socket: $Node.SocketHandle, line: Uint8Array): void; - binaryType?: "buffer"; -} - -interface InternalServerHandler extends InternalSocketHandler { alpnCallback?( socket: $Node.SocketHandle, servername: string | undefined, @@ -503,6 +495,7 @@ interface InternalServerHandler extends InternalSocketHandler { server: $Node.NetServer | $Node.TLSSocket, servername: string, socketHandle: $Node.SocketHandle | undefined, + socket?: Data, ): unknown; } @@ -514,9 +507,12 @@ const MathMin = Math.min; const reportError = globalThis.reportError; interface UVErrnos { + UV_EBADF: number; UV_ECANCELED: number; + UV_EINVAL: number; UV_EISCONN: number; UV_ENOBUFS: number; + UV_EPIPE: number; UV_ETIMEDOUT: number; } let uvBinding: UVErrnos | undefined; @@ -525,19 +521,31 @@ function uv(): UVErrnos { } const isWindows = process.platform === "win32"; -const getDefaultAutoSelectFamily: () => boolean = $bindgen("node_net_binding.bind.ts", "getDefaultAutoSelectFamily"); -const setDefaultAutoSelectFamily: (value: boolean) => boolean = $bindgen( - "node_net_binding.bind.ts", - "setDefaultAutoSelectFamily", -); -const getDefaultAutoSelectFamilyAttemptTimeout: () => number = $bindgen( - "node_net_binding.bind.ts", - "getDefaultAutoSelectFamilyAttemptTimeout", -); -const setDefaultAutoSelectFamilyAttemptTimeout: (value: number) => number = $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, @@ -605,7 +613,7 @@ const addServerName = $bindgen("Listener.bind.ts", "addServerName") as ( // The second part: what the duplex's 'data', 'end', 'drain' and 'close' events are to call. const upgradeDuplexToTLS: ( duplex: $Node.Duplex, - options: $Node.TLSUpgradeOptions<$Node.Socket | ConnectData>, + options: $Node.TLSUpgradeOptions<$Node.Socket>, ) => [handle: SocketHandle, events: [(chunk: Uint8Array) => void, () => void, () => void, () => void]] = $bindgen( "socket_body.bind.ts", "upgradeDuplexToTLS", @@ -613,7 +621,7 @@ const upgradeDuplexToTLS: ( // tls.connect({ socket }) upgrade: hostname policy stays with this JS layer. const upgradeTLSDeferred = $bindgen("socket_body.bind.ts", "upgradeTLSDeferred") as unknown as ( socket: SocketHandle, - options: $Node.TLSUpgradeOptions<$Node.Socket | ConnectData>, + options: $Node.TLSUpgradeOptions<$Node.Socket>, ) => [raw: SocketHandle, tls: SocketHandle] | undefined; const checkServerIdentityInHandshake: (socket: SocketHandle, inHandshake: boolean) => void = $bindgen( "socket_body.bind.ts", @@ -625,7 +633,7 @@ const isNamedPipeSocket: (socket: SocketHandle | null) => boolean = $bindgen( "socket_body.bind.ts", "isNamedPipeSocket", ); -const getBufferedAmount: (socket: SocketHandle) => number = $bindgen("socket_body.bind.ts", "getBufferedAmount"); +const getBytesRead = $bindgen("socket_body.bind.ts", "getBytesRead"); const bunTlsSymbol: typeof netSymbols.bunTlsSymbol = CAN_REPLACE_BUILT_IN_FUNCTIONS ? (Symbol.for("::buntls::") as typeof netSymbols.bunTlsSymbol) @@ -637,7 +645,7 @@ 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 bunTLSConnectOptions = Symbol.for("::buntlsconnectoptions::"); // tls.Server exposes its native SecureContext constructor through this key so @@ -654,7 +662,6 @@ const kRealListen = Symbol("kRealListen"); const kSetNoDelay = Symbol("kSetNoDelay"); const kSetTOS = Symbol("kSetTOS"); const kSetKeepAlive = Symbol("kSetKeepAlive"); -const kSyncWriteFd = Symbol("kSyncWriteFd"); const kSetKeepAliveInitialDelay = Symbol("kSetKeepAliveInitialDelay"); const kConnectOptions = Symbol("connect-options"); const kAttach = Symbol("kAttach"); @@ -671,10 +678,12 @@ const kReadError = Symbol("kReadError"); const kSessionAccepted = Symbol("kSessionAccepted"); const ksocket = Symbol("ksocket"); const khandlers = Symbol("khandlers"); +const kConnectReq = Symbol("kConnectReq"); const kclosed = Symbol("closed"); const kended = Symbol("ended"); +// The handle closed behind the peer's EOF: the peer hung up, and the loop closed the descriptor. +const kHungUp = Symbol("kHungUp"); const kpendingSession = Symbol("pendingSession"); -const kSNIError = Symbol("kSNIError"); const kALPNError = Symbol("kALPNError"); const kPerfHooksNetConnectContext = Symbol("kPerfHooksNetConnectContext"); const khandshakeTimer = Symbol("khandshakeTimer"); @@ -698,9 +707,6 @@ function onUpgradeAttachedWrite( } function onUpgradeWriteClose(this: $Node.Socket, callback: WriteCallback) { - // ServerHandlers.error may have already failed this write. - if (this[kwriteCallback] !== callback) return; - this[kwriteCallback] = null; callback($ERR_SOCKET_CLOSED()); } const kUpgradeAttached = Symbol("kUpgradeAttached"); @@ -710,95 +716,33 @@ const kOnreadBuffer = Symbol("kOnreadBuffer"); const kOnreadPendingEnd = Symbol("kOnreadPendingEnd"); const kOnreadReadRequested = Symbol("kOnreadReadRequested"); const kOnreadEmptyTail = Buffer.alloc(0); -const kwriteCallback = Symbol("writeCallback"); -const kLastWriteQueueSize = Symbol("lastWriteQueueSize"); -const kRejectedWrite = Symbol("kRejectedWrite"); +// The write the handle queued, like Node's WriteWrap. A member named `handle` here would be one more callee of every untyped `x.handle()` in a compiled program. +const kWriteReq = Symbol("kWriteReq"); +// How many bytes the kernel had taken when the timer last found a write queue that moved. +const kLastBytesSent = Symbol("kLastBytesSent"); const kSocketClass = Symbol("kSocketClass"); +const kServerSocket = Symbol("kServerSocket"); -// A completed write whose status is a negative errno: Node hands it to the write -// callback as errnoException(status, 'write') and destroys the stream when no -// callback is pending. https://github.com/nodejs/node/blob/v26.3.0/lib/internal/stream_base_commons.js#L81-L92 -function failWrite(self: $Node.Socket, negErrno: number, callback: WriteCallback) { - failWriteWith(self, writeErrnoException(negErrno), callback); -} -// The TLS engine ended the write side only. It reads what the peer sent, then closes, and the write fails with that close. -function holdRejectedWrite(self: $Node.Socket, handle: SocketHandle, negErrno: number, callback: WriteCallback) { - if (!self.encrypted || self[kclosed]) return false; - if (self[kupgraded] && !(self[kupgraded] instanceof Socket)) return false; - self._pendingData = null; - self[kwriteCallback] = callback; - self[kRejectedWrite] = writeErrnoException(negErrno); - // It holds the loop as any pending write does (_write). - if ((self[kended] || self[kPausedUnref]) && !self[kUserUnrefed]) handle.ref?.(); - if (self[kPausedUnref]) setImmediate(failRejectedWriteOfStoppedReader, self); - return true; -} -// A reader that stopped may be waiting for this very write, while the engine waits for it to read. It has one turn of the -// loop to go on, which is when Node reports a failed write. -function failRejectedWriteOfStoppedReader(self: $Node.Socket) { - if (self[kPausedUnref]) failRejectedWrite(self); -} -function failRejectedWrite(self: $Node.Socket) { - const er = self[kRejectedWrite]; - if (er === undefined) return false; - self[kRejectedWrite] = undefined; - failWriteWith(self, er, self[kwriteCallback]); - return true; -} -function failWriteWith(self: $Node.Socket, er: Error, callback: WriteCallback | null | undefined) { - self._pendingData = null; - self[kwriteCallback] = null; - if (callback) { - // Node delivers a failed write to BOTH the write callback and the socket: - // onWriteComplete calls errorOrDestroy(self, ...) in addition to the - // chained callback (lib/internal/stream_base_commons.js), so 'error' and - // 'close' still fire even when every write had a callback. Only handing - // the error to a callback that ignores it left the socket alive forever - // (test-net-stream's server never observed its peer vanish). - callback(er); - if (!self.destroyed && !self._hadError) { - // _hadError is the cross-path once-guard: the native error dispatch - // (SocketHandlers.error) can deliver the same failure, and node emits - // a socket error exactly once. - self._hadError = true; - self.destroy(er); - } +// https://github.com/nodejs/node/blob/v26.5.0/lib/internal/stream_base_commons.js#L81-L107 +function onWriteComplete(self: $Node.Socket, handle: SocketHandle, status) { + const req = self[kWriteReq]; + // connect() settled the write of the handle it replaced. + if (req?.writeHandle !== handle) return; + self[kWriteReq] = undefined; + let err: Error | null = null; + if (status < 0) { + // https://github.com/nodejs/node/blob/v26.5.0/src/crypto/crypto_tls.cc#L1318 + const reason = self.encrypted && status === uv().UV_ECANCELED ? "Canceled because of SSL destruction" : undefined; + err = new ErrnoException(status, "write", reason); } else if (!self.destroyed) { - if (self.listenerCount("error") > 0) { - // The consumer can detach its listener between now and destroy()'s - // deferred 'error' emission - the same last-resort guard - // SocketEmitEndNT uses for read errors. - self.once("error", () => {}); - self.destroy(er); - } else { - // No write callback and no 'error' listener: a failed flush on an - // orphaned socket (an h2 teardown racing the peer's reset - routine on - // Windows, where the reset completes the send first) is teardown noise. - // Same silent-close policy as SocketEmitEndNT's no-listener case. - self.destroy(); - } + self._unrefTimer(); + self[kLastBytesSent] = 0; + unrefAfterDrain(self, handle); } -} -// 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: $Node.Socket) { - const callback = self[kwriteCallback]; - if (!callback || !self[kclosed]) return; - self._pendingData = self[kwriteCallback] = null; - const reason = self.encrypted ? "Canceled because of SSL destruction" : undefined; - callback(new ErrnoException(uv().UV_ECANCELED, "write", reason)); -} -// Node's errnoException(status, 'write') for the negative errno $write returns. -function writeErrnoException(negErrno: number) { - let er = new ErrnoException(negErrno, "write") as Error & { code?: string; errno?: number; syscall?: string }; - if (typeof er.code !== "string" || !/^E[A-Z0-9]+$/.test(er.code)) { - // A raw WSA value the errno table cannot name (Windows delivers fatal - // send errors this way): shape it like SocketEmitEndNT shapes reads, - // keeping the original errno. - er = new ConnResetException("write ECONNRESET") as Error & { code: string; errno?: number; syscall?: string }; - er.errno = negErrno; - er.syscall = "write"; - } - return er; + // Node's WriteWrap is an AsyncResource that write() creates. + if (err && endsWithoutError(self)) + process.nextTick(AsyncContextFrame.run, req.writeFrame, req.writeCallback, undefined, err); + else AsyncContextFrame.run(req.writeFrame, req.writeCallback, undefined, err); } function endNT(self: $Node.Socket, socket: SocketHandle | null, callback: WriteCallback) { // Node's _final half-closes the writable side (sends FIN) and leaves the @@ -818,9 +762,19 @@ function emitCloseNT(self: $Node.Socket, hasError: boolean) { function closeAdoptedTLSRawNT(self: $Node.Socket, handle: SocketHandle, isException: boolean) { setImmediate(closeSocketHandle, self, handle, isException); } -function detachSocket(this: $Node.Socket | void, self?: $Node.Socket) { - if (!self) self = this as $Node.Socket; +// Bytes an onread callback declined don't count until it gets them. +function handleBytesRead(self, handle) { + return getBytesRead(handle) - (self[kOnreadTail]?.length ?? 0); +} +// `bytesRead` and `bytesWritten` stay readable after the socket let go of its handle. +function detachSocket(self: $Node.Socket) { + const handle = self._handle; + if (!handle) return; + self[kBytesRead] = handleBytesRead(self, handle); + self[kBytesWritten] = handle.bytesWritten; + handle.onread = noop; self._handle = null; + self._sockname = null; } function destroyNT(self: $Node.Socket, err?: Error) { self.destroy(err); @@ -850,9 +804,17 @@ function onUpgradedError(self: $Node.TLSSocket, err: Error) { // TLSWrap owns the reads of the handle it wraps: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L723-L727 function onUpgradedReadError(self: $Node.Socket, err: NativeSocketError) { self._closeAfterHandlingError = true; - SocketEmitEndNT(self, err); + onReadEnd(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: $Node.TLSSocket, connection: $Node.Socket) { const isSocket = connection instanceof Socket; @@ -860,11 +822,11 @@ function linkUpgraded(self: $Node.TLSSocket, connection: $Node.Socket) { 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: $Node.Socket, connection: $Node.Socket) { - if (self.destroyed || self[kupgraded] !== connection) return; + if (self.destroyed || !self.connecting || self[kupgraded] !== connection) return; self.connecting = false; self.emit("connect"); } @@ -872,16 +834,11 @@ function onUpgradedConnect(self: $Node.Socket, connection: $Node.Socket) { function onUpgradedEnd(connection: $Node.Socket, eof: () => void) { if (!connection.destroyed) eof(); } -function attachTLSEngine( - self: $Node.Socket, - connection: $Node.Socket, - options: $Node.TLSUpgradeOptions<$Node.Socket | ConnectData>, -) { +function attachTLSEngine(self: $Node.Socket, connection: $Node.Socket, options: $Node.TLSUpgradeOptions<$Node.Socket>) { 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(); @@ -907,7 +864,7 @@ function attachClientTLS( ) { const handle = connection._handle; if (self.destroyed || (!upgradeDuplex && !handle)) return; - const options = { data: { self, req: {} }, tls, socket: self[khandlers], isServer: false }; + const options = { data: self, tls, socket: self[khandlers], isServer: false }; try { if (upgradeDuplex || isNamedPipeSocket(handle) || hasUnflushedWrites(connection)) { attachTLSEngine(self, connection, options); @@ -918,11 +875,7 @@ function attachClientTLS( self.destroy(error as Error); } } -function adoptServerTLS( - self: $Node.Socket, - connection: $Node.Socket, - options: $Node.TLSUpgradeOptions<$Node.Socket | ConnectData>, -) { +function adoptServerTLS(self: $Node.Socket, connection: $Node.Socket, options: $Node.TLSUpgradeOptions<$Node.Socket>) { if (self.destroyed) return; const handle = connection._handle; if (connection.destroyed || connection[kclosed] || !handle) { @@ -1075,6 +1028,8 @@ function onClientHandshake( 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 @@ -1125,7 +1080,7 @@ function onClientHandshake( // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1697-L1698 self.secureConnecting = false; // pauseOnConnect stopped the handle as the handshake completed, whatever resume() did before that. - if (!self.isPaused()) self.read(0); + if (!self.isPaused()) readStart(self, socket); self.emit(kSecureConnectDone); self.emit("secureConnect", verifyError); const pendingSession = self[kpendingSession]; @@ -1184,176 +1139,12 @@ function establishedTLSError(self: $Node.Socket, error: SystemError | undefined) } } -// Node reports a throwing 'data' listener as uncaughtException and keeps reading. -function pushDataToSocket(self: $Node.Socket, socket: SocketHandle, buffer: Uint8Array) { - // TLS took over the fd; the wrapped socket reads nothing: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L723-L727 - if (socket[kAdoptedTLSRaw]) return; - if (self[kDestroyOnRead]) { - $debug("DATA on a socket that must receive nothing - destroying it"); - self.destroy(); - return; - } - let full; - try { - full = self.push(buffer) === false; - } catch (e) { - reportError(e); - return; - } - if (full) readStop(self, socket); -} - -const SocketHandlers = { - close(socket, err) { - const self = socket.data; - if (!self || self[kclosed]) return; - self[kclosed] = true; - //socket cannot be used after close - detachSocket(self); - if (!closeWithTLSSocket(self, socket)) SocketEmitEndNT(self, err); - self.data = null; - }, - data(socket, buffer) { - const { data: self } = socket; - 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. - drain(socket, rejected?: number) { - const self = socket.data; - if (!self) return; - const callback = self[kwriteCallback]; - self.connecting = false; - if (callback && self[kRejectedWrite] === undefined) { - const writeChunk = self._pendingData; - 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. - if (!holdRejectedWrite(self, socket, res, callback)) failWrite(self, res, callback); - } else if (res) { - self._pendingData = self[kwriteCallback] = null; - self._unrefTimer(); - self[kLastWriteQueueSize] = 0; - unrefAfterDrain(self, socket); - callback(null); - } else { - self._pendingData = null; - } - - self[kBytesWritten] = socket.bytesWritten; - } - }, - end(socket) { - const self = socket.data; - if (!self) return; - - // we just reuse the same code but we can push null or enqueue right away - SocketEmitEndNT(self); - }, - // A new resumable TLS session arrived (the peer's NewSessionTicket was just - // processed). Mirrors Node's onnewsessionclient: emit once the handshake has - // been verified, otherwise park it and emit from the handshake handler. - session(socket: $Node.SocketHandle<$Node.TLSSocket>, session) { - const self = socket.data; - if (!self) return; - if (self._secureEstablished) { - self.emit("session", session); - } else { - self[kpendingSession] = session; - } - }, - keylog(socket, line) { - const self = socket.data; - if (!self) return; - self.emit("keylog", line); - if (tlsKeylogPath !== undefined) appendTlsKeylog(tlsKeylogPath, line); - self.server?.emit?.("keylog", line, self); - }, - error(socket, error) { - const self = socket.data; - if (!self) return; - if (self._hadError) return; - self._hadError = true; - - const callback = self[kwriteCallback]; - if (callback) { - self[kwriteCallback] = null; - callback(error); - } - - self.emit("error", error); - }, - open(socket) { - const self = socket.data; - if (!self) return; - // make sure to disable timeout on usocket and handle on TS side - socket.timeout(0); - const selfTimeout = self.timeout; - if (selfTimeout) { - self.setTimeout(selfTimeout); - } - self._handle = socket; - self.connecting = false; - const options = self[bunTLSConnectOptions]; - - if (options) { - const { session } = options; - if (session) { - (self as $Node.TLSSocket).setSession(session); - } - } - startNameCheckInHandshake(self, socket); - - if (self[kSetNoDelay]) { - socket.setNoDelay!(true); - } - - if (self[kSetKeepAlive]) { - socket.setKeepAlive!(true, self[kSetKeepAliveInitialDelay]); - } - - // A TOS value set before the connection existed (setTypeOfService before - // connect) is applied to the live handle now. - let handle: SocketHandle | null; - if (self[kSetTOS] !== undefined && (handle = self._handle)?.setTypeOfService) { - handle.setTypeOfService(self[kSetTOS]); - } - - 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"); - } - - SocketHandlers.drain(socket); - }, - handshake(socket: $Node.SocketHandle<$Node.TLSSocket>, success, verifyError) { - const { data: self } = socket; - if (!self) return; - onClientHandshake(self, socket, success, verifyError); - }, - timeout(socket) { - const self = socket.data; - if (!self) return; - - self.emit("timeout", self); - }, - binaryType: "buffer", -} as const satisfies InternalSocketHandler<$Node.Socket>; - -// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/stream_base_commons.js#L191-L198; a stopped handle does not hold the loop, a pending write still does. function readStop(self: $Node.Socket, handle: SocketHandle | null) { handle?.pause?.(); // A socket over a generic duplex has no fd and never held the loop. if (self[kupgraded] && !(self[kupgraded] instanceof Socket)) return; self[kPausedUnref] = true; - if (self[kRejectedWrite] !== undefined) setImmediate(failRejectedWriteOfStoppedReader, self); - if (!self[kwriteCallback]) handle?.unref?.(); + if (!self[kWriteReq]) handle?.unref?.(); } // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L494-L498 @@ -1362,9 +1153,9 @@ function pauseOnCreate(self: $Node.Socket, handle: SocketHandle | null) { self.readableFlowing = false; } -// Reads are flowing again: give back the hold readStop dropped. Cleared even when -// the user unref'd, so a later ref() is not undone by unrefAfterDrain. -function restorePausedHold(self: $Node.Socket, handle: SocketHandle | null) { +// Gives back the hold readStop dropped. Cleared even when the user unref'd, so a later ref() is not undone by unrefAfterDrain. +function readStart(self: $Node.Socket, handle: SocketHandle | null) { + handle?.resume?.(); if (!self[kPausedUnref]) return; self[kPausedUnref] = false; if (!self[kUserUnrefed]) handle?.ref?.(); @@ -1377,7 +1168,8 @@ function unrefAfterDrain(self: $Node.Socket, handle: SocketHandle) { } function finishSocketEnd(self: $Node.Socket) { - 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); @@ -1389,103 +1181,64 @@ function finishSocketEnd(self: $Node.Socket) { // forget any readStop-time unref so a later read()/resume() does not pin the loop again. // A subsequent buffered write re-refs (see _write) so its callback can still fire. const socket = self._handle; - if (socket && !self[kwriteCallback]) { + // No _read() follows the end, and a TLS handle that backpressure stopped still has the peer's FIN to read. + socket?.resume?.(); + if (socket && !self[kWriteReq]) { socket.unref?.(); self[kPausedUnref] = false; } } +// A socket that node:http handed to 'upgrade' or 'connect' ends and closes when its connection fails, and emits no 'error' for it. The failed write's callback follows 'end'. +function endsWithoutError(self) { + if (self[kHandleClosed] === undefined) return false; + finishSocketEnd(self); + self.destroy(); + return true; +} + function deferEndForOnreadTail(self: $Node.Socket) { if (self[kOnreadTail] === undefined || self.destroyed) return false; self[kOnreadPendingEnd] = true; return true; } -function SocketEmitEndNT(self: $Node.Socket, _err?: NativeSocketError) { - if (self[kclosed] && failRejectedWrite(self)) return; - // A read error delivered with the close (e.g. a received RST surfacing as - // ECONNRESET) is not a clean EOF — Node destroys the socket with the error - // ("read ECONNRESET") instead of emitting a graceful 'end'. Guard on - // !destroyed so an already-torn-down socket isn't re-destroyed, and on an - // 'error' listener so callers that opted into error handling get Node's - // behavior while those that did not keep the previous silent EOF (a server - // hard-closing after a clean response would otherwise surface here as an - // unhandled error across the proxy/http2/fetch suites under ASAN/baseline - // timing). - // A reset that lands after the exchange already finished in BOTH - // directions (clean EOF delivered and nothing left being written) is - // teardown noise - a peer hard-closing once the exchange completed - not - // data loss; Node would have destroyed the socket on 'end' for these - // non-keepalive flows before the RST could ever be observed. Surfacing it - // produced unhandled errors between tests across the fetch/http2 suites on - // Windows, where loopback RSTs at teardown are routine. A reset while the - // socket is still writing (the peer aborted mid-transfer) is real and is - // surfaced (test-net-error-twice). - // writableFinished (everything actually flushed) - NOT writableEnded (end() - // merely called): a peer reset while queued data is still unflushed is the - // peer aborting mid-transfer and must surface (test-net-error-twice). +// EOF, or the read error the handle closed with: https://github.com/nodejs/node/blob/v26.5.0/lib/internal/stream_base_commons.js#L207-L234 +function onReadEnd(self: $Node.Socket, err?: NativeSocketError) { + if (self.destroyed || (err && endsWithoutError(self))) return; + // A reset after both directions finished. Node stops reading at EOF, so it never sees one. 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; + // Without a listener a peer's reset would be an uncaught exception, so the socket closes quietly. A write in flight fails it anyway. + const reportsReadError = hasErrorListener || (self[kWriteReq] !== undefined && !self[kended]); + if (err && !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 - // callback and destroy()'s deferred 'error' emission (a request that - // 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", () => {}); - let errErrno; - if (_err.code === undefined && typeof (errErrno = _err.errno) === "number" && errErrno !== 0) { - // A codeless close error that still carries the errno (Windows IOCP - // delivers some this way): derive the proper code from it, like Node's - // errnoException(nread, 'read'). Raw WSA values (-10054, ...) that the - // errno table cannot name fall through to the reset shape below instead - // of surfacing "Unknown system error N". - const er = new ErrnoException(errErrno, "read") as Error & { code?: string }; - if (typeof er.code === "string" && /^E[A-Z0-9]+$/.test(er.code)) { - self.destroy(er); - return; - } - } - if (_err.code === undefined || _err.code === "ECONNRESET") { - // Shape a reset (or a fully bare close error) like Node's - // errnoException(UV_ECONNRESET, 'read'). - const er = new ConnResetException("read ECONNRESET") as Error & { - code: string; - errno?: number; - syscall?: string; - }; - er.errno = _err.errno ?? (process.platform === "win32" ? -4077 : process.platform === "linux" ? -104 : -54); - er.syscall = "read"; - self.destroy(er); - } else { - // Any other coded error (ETIMEDOUT, EPIPE, ...) keeps its identity. - self.destroy(_err); - } - return; + if (self.emit(kReadError, err)) return void self.destroy(); + // The listener can be gone by the tick that emits 'error' (a request that finished as the reset arrived). + if (hasErrorListener) self.once("error", noop); + return void self.destroy(new ErrnoException(err.errno!, err.syscall!)); } if (!self[kended]) { - finishSocketEnd(self); - } else if (_err && !self.destroyed && !(teardownNoise && !self.readableEnded)) { - // An error excluded from the synthesis above (teardown noise, or no - // listener attached): nothing more is coming, but the socket still has to - // finish its lifecycle - close it quietly instead of leaving it open with - // no further events. What a finished exchange left unread is still the - // reader's: its 'end' closes the socket. + if (!deferEndForOnreadTail(self)) finishSocketEnd(self); + } else if (err && !(teardownNoise && !self.readableEnded)) { + // What a finished exchange left unread is still the reader's: its 'end' closes the socket. self.destroy(); } - // 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()); - } +} + +function onHandleClose(self, handle, err) { + if (self[kclosed]) return; + self[kclosed] = true; + self[kHungUp] = self[kended]; + if (closeWithTLSSocket(self, handle)) return; + // A connection attempt that lost the race and a handle that a TLS upgrade replaced end quietly. + if (handle !== self._handle) err = undefined; + onReadEnd(self, err); + self[kHandleClosed]?.(); + // Node destroys the socket with this error. Where it isn't reported, a half-open socket ends its writable side at 'end' like any other. + if (!err || !self.allowHalfOpen || self.destroyed) return; + if (self.readableEnded) self.end(); + else self.once("end", self.end); } // --- SNICallback dispatch helpers (hoisted: no per-handshake closures) --- @@ -1499,7 +1252,7 @@ function toSNIError(err: unknown): Error { // 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: SNIState, err: unknown, context?: unknown) { @@ -1507,23 +1260,22 @@ function consumeSNIResult(state: SNIState, err: unknown, context?: unknown) { state.failed = toSNIError(err); return; } - if (context == null) return; - const innerContext = typeof context === "object" ? (context as { context?: unknown }).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 as { context?: unknown }).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"); } } -// Stash per-connection (socketHandle.data is this connection's TLSSocket): +// Stash per-connection (state.socket is this connection's TLSSocket): // with concurrent handshakes a per-server stash could hand one connection's // error to another's failure handler. The server is the legacy fallback when // no handle was available at dispatch time. function stashSNIError(state: SNIState) { - const target = state.socketHandle?.data ?? state.server; + const target = state.socket ?? state.server; if (target) target[kSNIError] = state.failed; } @@ -1543,18 +1295,48 @@ function onSNIResolution(state: SNIState, err: unknown, context?: unknown) { } } -const ServerHandlers = { +// `socket.data` is the net.Socket that owns the handle. Until 'open' hands an accepted handle to one, it's the net.Server. +const SocketHandlers = { data(socket, buffer) { - const { data: self } = socket; - if (!self) return; - + const self = socket.data; self._unrefTimer(); - self.bytesRead += buffer.length; - pushDataToSocket(self, socket, buffer); + // TLS took over the fd; the wrapped socket reads nothing: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L723-L727 + if (socket[kAdoptedTLSRaw]) return; + if (self[kDestroyOnRead]) { + $debug("DATA on a socket that must receive nothing - destroying it"); + self.destroy(); + return; + } + let full; + try { + full = self.push(buffer) === false; + } catch (e) { + // Node reports a throwing 'data' listener as uncaughtException and keeps reading. + reportError(e); + return; + } + if (full) readStop(self, socket); + }, + writeComplete(socket, status: number) { + onWriteComplete(socket.data, socket, status); + }, + end(socket) { + onReadEnd(socket.data); + }, + close(socket, err?: NativeSocketError) { + onHandleClose(socket.data, socket, err); + }, + // Node's onnewsessionclient: emit once the handshake is verified. Until then, the handshake handler emits it. + session(socket: $Node.SocketHandle<$Node.TLSSocket>, session) { + const self = socket.data; + if (self._secureEstablished) { + self.emit("session", session); + } else { + self[kpendingSession] = session; + } }, keylog(socket, line) { - const { data: self } = socket; - if (!self) return; + const self = socket.data; self.emit("keylog", line); if (tlsKeylogPath !== undefined) appendTlsKeylog(tlsKeylogPath, line); self.server?.emit?.("keylog", line, self); @@ -1595,7 +1377,7 @@ const ServerHandlers = { } return result; }, - serverName(server, servername, socketHandle) { + serverName(server, servername, socketHandle, socket = socketHandle?.data) { // Returns what the SNICallback selects for this handshake: // - the native SecureContext (synchronous selection) // - undefined to fall through to the default context @@ -1606,19 +1388,21 @@ 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 = { server, socketHandle, + socket, selected: undefined, failed: undefined, settled: false, 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(socket, servername, onSNIResolution.bind(null, state)); } catch (err) { state.settled = true; state.failed = toSNIError(err); @@ -1642,54 +1426,54 @@ const ServerHandlers = { } return state.selected; }, - close(this: $Node.SocketHandle<$Node.Socket>, socket, err) { - $debug("Bun.Server close"); - const data = this.data; - if (!data) return; - - { - if (!data[kclosed]) { - data[kclosed] = true; - //socket cannot be used after close - detachSocket(data); - if (!closeWithTLSSocket(data, socket)) SocketEmitEndNT(data, err); - data.data = null; - socket[owner_symbol] = null; - } - } - }, - end(socket) { - SocketHandlers.end(socket); - }, - open(socket) { - $debug("Bun.Server open"); - const self = socket.data as unknown as $Node.NetServer; - if (!self) return; + open(socket: SocketHandle) { + const self = socket.data; // Dispatch through the listener handle's onconnection hook so user code // (and node:cluster RoundRobinHandle) can intercept accepted sockets the // same way Node.js exposes TCP/Pipe wrap onconnection. - // For a standalone server-side wrap (new TLSSocket(duplex, { isServer })), - // `self` is the wrapping socket - not a Server - and its handle has no - // onconnection; throwing here would tear the brand-new TLS engine down - // before the ClientHello ever arrives. - const handle = self._handle || socket.listener; - if (handle && typeof handle.onconnection === "function") { - handle.onconnection(0, socket); + const listener = (self as any as $Node.NetServer)._handle || socket.listener; + if (listener && typeof listener.onconnection === "function") { + listener.onconnection(0, socket); + return; + } + socket[owner_symbol] = self; + // Ahead of `oncomplete`: a 'connect' listener that writes sends the ClientHello, which has to offer the session. + const session = self[bunTLSConnectOptions]?.session; + // On the native socket: an fd upgrade assigns `_handle` after `open`. + if (session) socket.setSession(typeof session === "string" ? Buffer.from(session, "latin1") : session); + startNameCheckInHandshake(self, socket); + const req = socket[kConnectReq]; + socket[kConnectReq] = undefined; + // A TLS session over a transport the caller handed in has no request. An attempt that timed out has no `oncomplete`. + req?.oncomplete?.(0, self._handle, req, true, true); + }, + connectError(socket: SocketHandle, error: NativeConnectError) { + const self = socket.data; + const req = socket[kConnectReq]; + socket[owner_symbol] = self; + // connect() or bind() failed inside doConnect(): kConnectDispatch returns the errno, as Node's handle.connect() does. + if (req?.dispatching) { + req.errno = error.errno || uv().UV_ECANCELED; + return; + } + socket[kConnectReq] = undefined; + // A TLS engine that failed before it opened has no connect pending: afterConnect would drop the failure. + if (self[kupgraded]) { + if (!self.destroyed) self.destroy(new ErrnoException(error.errno, "connect")); + return; } + // Not deferred like libuv's ECANCELED of a closed handle: it would fail a connect() made right after destroy(). + req?.oncomplete?.(error.errno, self._handle, req, true, true); }, handshake(socket: $Node.SocketHandle<$Node.TLSSocket>, success, verifyError) { const self = socket.data; + if (!self.isServer) return onClientHandshake(self, socket, success, verifyError); // `server` is null for a standalone `new tls.TLSSocket(socket, { isServer: true })` // (no listening server owns it) — guard every server.emit / server option read. const server = self.server; if (self[khandshakeTimer]) { clearTimeout(self[khandshakeTimer]); self[khandshakeTimer] = undefined; - // node's _finishInit retires the handler with setTimeout(0, - // _handleTimeout); leaving it attached would turn a later idle timeout - // on an established connection into ERR_TLS_HANDSHAKE_TIMEOUT. - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1105-L1106 - self.removeListener("timeout", onHandshakeTimeout); } // On the server side the second argument is the raw handshake result // (client-certificate verification is reported separately through @@ -1775,6 +1559,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); @@ -1808,74 +1600,38 @@ const ServerHandlers = { reportError(err); } }, - error(this: $Node.SocketHandle<$Node.TLSSocket>, socket, error) { - const data = this.data; - if (!data) return; - - if (data._hadError) return; - data._hadError = true; - const bunTLS = data[bunTlsSymbol]; - - if (typeof bunTLS === "function") { - const tlsError = establishedTLSError(data, error); - if (tlsError) error = tlsError; - const callback = data[kwriteCallback]; - if (callback) { - data[kwriteCallback] = null; - callback(error); - } - // Mirrors node's onerror: - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L467-L498 - if (!data._secureEstablished) { - // Destroy the socket if the error happened before the handshake - // finished, keeping it reachable while 'tlsClientError' is emitted - // through the '_tlsError' hop. - data._closeAfterHandlingError = true; - data.destroy(error); - } else if ( - data.isServer && - data._rejectUnauthorized && - /peer did not return a certificate/.test(error?.message) - ) { - // Ignore server's authorization errors - data.destroy(); - } else if (tlsError && !callback) { - data._emitTLSError(error); - } else { - // Node emits through _emitTLSError and leaves the socket alive. Bun - // still destroys here: its tls.Server completes the handshake for a - // peer that already reset, and leaving that connection alive turns the - // error it then raises into an uncaught exception. - data.emit("_tlsError", error); - data.destroy(data.listenerCount("error") > 0 ? error : undefined); - } + error(this: $Node.SocketHandle<$Node.TLSSocket>, socket: $Node.SocketHandle<$Node.TLSSocket>, error) { + const self = socket.data; + if (!self || self._hadError) return; + self._hadError = true; + if (typeof self[bunTlsSymbol] !== "function") { + self.destroy(error); return; } - // Plain TCP. On kqueue a fatal-flush from on_writable is the only place - // the errno is visible (the close it issues short-circuits the read - // dispatch at loop.c's us_socket_is_closed check), so swallowing it hung - // the server behind an un-failed pending write (test-net-stream on - // darwin). Shape it like Node's onWriteComplete: fail the pending write - // callback, then destroy with the error. destroy() owns the single - // 'error' emission via the stream's errorEmitted guard; callback(error) - // may have already destroyed, in which case this is a no-op. - const callback = data[kwriteCallback]; - if (callback) { - data[kwriteCallback] = null; - callback(error); - } - if (!data.destroyed) { - data.destroy(error); + const tlsError = establishedTLSError(self, error); + // What a listener threw comes here too, and has neither. + if ((tlsError || (error as NodeJS.ErrnoException)?.syscall !== undefined) && endsWithoutError(self)) return; + if (tlsError) error = tlsError; + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L467-L498 + if (!self._secureEstablished) { + // The socket stays reachable while 'tlsClientError' is emitted through the '_tlsError' hop. + self._closeAfterHandlingError = true; + self.destroy(error); + } else if (self.isServer && self._rejectUnauthorized && /peer did not return a certificate/.test(error?.message)) { + // Ignore server's authorization errors + self.destroy(); + } else if (tlsError && !self[kWriteReq]) { + self._emitTLSError(error); + } else if (self.isServer) { + // Node leaves the socket alive. Destroy it, and pass the error on only if something listens for 'error'. + self.emit("_tlsError", error); + self.destroy(self.listenerCount("error") > 0 ? error : undefined); + } else { + self.destroy(error); } }, - timeout(socket) { - SocketHandlers.timeout(socket); - }, - drain(socket, rejected?: number) { - SocketHandlers.drain(socket, rejected); - }, binaryType: "buffer", -} as const satisfies InternalServerHandler<$Node.Socket>; +} as const satisfies InternalSocketHandler<$Node.Socket>; function applyRejectUnauthorized(self: $Node.Socket, tls: TLSConnectOptions, rejectUnauthorized: boolean | undefined) { if (typeof rejectUnauthorized !== "undefined") { @@ -1906,13 +1662,12 @@ function onSocketTLSClose(this: $Node.TLSSocket, hadError: boolean) { } } -function onHandshakeTimeout(this: $Node.TLSSocket) { - this._emitTLSError($ERR_TLS_HANDSHAKE_TIMEOUT()); -} - +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1056-L1058 function onHandshakeDeadline(socket: $Node.TLSSocket) { socket[khandshakeTimer] = undefined; - if (!socket.destroyed) socket.emit("timeout"); + if (socket.destroyed) return; + socket._emitTLSError($ERR_TLS_HANDSHAKE_TIMEOUT()); + socket.emit("timeout"); } // 'close' listener: `this` is the socket. @@ -1920,7 +1675,6 @@ function clearHandshakeTimeout(this: $Node.TLSSocket) { if (this[khandshakeTimer]) { clearTimeout(this[khandshakeTimer]); this[khandshakeTimer] = undefined; - this.removeListener("timeout", onHandshakeTimeout); } } @@ -1931,16 +1685,7 @@ function initAcceptedTLSSocket(server: $Node.NetServer, socket: $Node.TLSSocket) socket.on("close", onSocketTLSClose); const handshakeTimeout = server._handshakeTimeout; if (!(handshakeTimeout! > 0)) return; - // The deadline surfaces as the socket's own 'timeout' event with - // _handleTimeout as its first listener, which routes it through - // _emitTLSError: 'tlsClientError' fires while control is still the server's, - // and the connection is neither errored nor destroyed. Node arms it with - // socket.setTimeout(); bun keeps a standalone timer because encrypted bytes - // arriving mid-handshake must not extend the deadline (verified against the - // v26.3.0 binary: a client dribbling bytes is still cut off on time). - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L961-L962 - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1056-L1058 - socket.once("timeout", onHandshakeTimeout); + // Node arms socket.setTimeout() on its TLSSocket. Here the program's own setTimeout() is on the same socket, and bytes that arrive mid-handshake must not put the deadline off. const timer: Timer = setTimeout(onHandshakeDeadline, handshakeTimeout, socket); // Node's handshake timer is unref'd: a fully-unref'd server (the // graceful-shutdown pattern) must not be held open by a client that @@ -1950,7 +1695,7 @@ function initAcceptedTLSSocket(server: $Node.NetServer, socket: $Node.TLSSocket) socket.once("close", clearHandshakeTimeout); } // Node.js-compatible onconnection: assigned to server._handle.onconnection in -// kRealListen and invoked from ServerHandlers.open with `this` bound to the +// kRealListen and invoked from SocketHandlers.open with `this` bound to the // listener handle. Kept as a standalone function so tests/cluster can wrap it. function onconnection(this: ListenerHandle, err: number, clientHandle: $Node.SocketHandle<$Node.Socket>) { const handle = this; @@ -1959,6 +1704,7 @@ function onconnection(this: ListenerHandle, err: number, clientHandle: $Node.Soc self.emit("error", err); return; } + // Nothing reads this: it keeps the listener reachable for the garbage collector while a connection of its is open. clientHandle[kServerSocket] = handle; const options = self[bunSocketServerOptions]!; const { [kSocketClass]: SClass }: { [kSocketClass]?: $Node.SocketConstructor } = options; @@ -1978,25 +1724,19 @@ function onconnection(this: ListenerHandle, err: number, clientHandle: $Node.Soc // 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); + _socket[kAttach](clientHandle); const blockList = self.blockList; if (blockList) { const addressType = isIP(clientHandle.remoteAddress); + // No 'drop': https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L2512-L2520 if (addressType && blockList.check(clientHandle.remoteAddress!, `ipv${addressType}`)) { - const data = { - localAddress: _socket.localAddress, - localPort: _socket.localPort || clientHandle.localPort, - localFamily: _socket.localFamily, - remoteAddress: _socket.remoteAddress, - remotePort: _socket.remotePort, - remoteFamily: _socket.remoteFamily || "IPv4", - }; clientHandle.end(); - self.emit("drop", data); return; } } @@ -2024,19 +1764,20 @@ function onconnection(this: ListenerHandle, err: number, clientHandle: $Node.Soc } if (self.keepAlive && clientHandle.setKeepAlive) { _socket[kSetKeepAlive] = true; - _socket[kSetKeepAliveInitialDelay] = self.keepAliveInitialDelay; - clientHandle.setKeepAlive(true, self.keepAliveInitialDelay); + _socket[kSetKeepAliveInitialDelay] = keepAliveDelayOf(self); + clientHandle.setKeepAlive(true, _socket[kSetKeepAliveInitialDelay]); } self._connections++; _socket.server = self; _socket._server = self; - if (pauseOnConnect && !isTLS) { - pauseOnCreate(_socket, clientHandle); - } - - if (isTLS) initAcceptedTLSSocket(self, _socket as $Node.TLSSocket); + // 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 as $Node.TLSSocket); + _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 { @@ -2044,217 +1785,40 @@ function onconnection(this: ListenerHandle, err: number, clientHandle: $Node.Soc } 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 -const SocketHandlers2 = { - open(socket: $Node.SocketHandle) { - $debug("Bun.Socket open"); - let { self, req } = socket.data; - socket[owner_symbol] = self; - $debug("self[kupgraded]", String(self[kupgraded])); - // Offer a previously-negotiated session for resumption before oncomplete - // (afterConnect) runs: a user 'connect' listener that writes immediately - // would otherwise send the ClientHello before SSL_set_session runs and - // silently skip resumption. - { - const options = self[bunTLSConnectOptions]; - if (options) { - const { session } = options; - if (session) { - // On the native socket: an fd upgrade assigns `_handle` after `open`. - socket.setSession(typeof session === "string" ? Buffer.from(session, "latin1") : session); - } - } - } - startNameCheckInHandshake(self, socket); - if (!self[kupgraded]) req.oncomplete(0, self._handle, req, true, true); - socket.data.req = undefined; - }, +// Native code asks JS from inside every handshake for each of these members a table has, so a listener gets only what its server uses. +const { + session: _session, + connectError: _connectError, + serverName, + alpnCallback, + ...AcceptedHandlers +} = SocketHandlers; +const AcceptedHandlersSNI = { ...AcceptedHandlers, serverName }; +const AcceptedHandlersALPN = { ...AcceptedHandlers, alpnCallback }; +const AcceptedHandlersSNIAndALPN = { ...AcceptedHandlers, serverName, alpnCallback }; + +function serverHandlersFor(server) { + if (server._SNICallback) return server._ALPNCallback ? AcceptedHandlersSNIAndALPN : AcceptedHandlersSNI; + return server._ALPNCallback ? AcceptedHandlersALPN : AcceptedHandlers; +} + +// The `onread` option: https://github.com/nodejs/node/blob/v26.5.0/lib/internal/stream_base_commons.js#L177-L185 +const OnreadHandlers = { + ...SocketHandlers, data(socket, buffer) { - $debug("Bun.Socket data"); - const { self } = socket.data; + const self = socket.data; self._unrefTimer(); - self.bytesRead += buffer.length; - pushDataToSocket(self, socket, buffer); - }, - // See SocketHandlers.drain. - drain(socket, rejected?: number) { - $debug("Bun.Socket drain"); - const { self } = socket.data; - const callback = self[kwriteCallback]; - self.connecting = false; - if (callback && self[kRejectedWrite] === undefined) { - const writeChunk = self._pendingData; - 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; - self._unrefTimer(); - self[kLastWriteQueueSize] = 0; - unrefAfterDrain(self, socket); - callback(null); - } else { - self[kBytesWritten] = socket.bytesWritten; - self._pendingData = null; - } - } - }, - end(socket) { - $debug("Bun.Socket end"); - const { self } = socket.data; - if (deferEndForOnreadTail(self)) return; - finishSocketEnd(self); - }, - // See SocketHandlers.session. - session(socket: $Node.SocketHandle>, session) { - const { self } = socket.data; - if (!self) return; - if (self._secureEstablished) { - self.emit("session", session); - } else { - self[kpendingSession] = session; - } - }, - keylog(socket, line) { - const { self } = socket.data; - if (!self) return; - self.emit("keylog", line); - if (tlsKeylogPath !== undefined) appendTlsKeylog(tlsKeylogPath, line); - }, - close(socket, err?: NativeSocketError) { - $debug("Bun.Socket close"); - let { self } = socket.data; - if (err) $debug(err); - if (self[kclosed]) return; - self[kclosed] = true; - const leftToTLSSocket = closeWithTLSSocket(self, socket); - if (failRejectedWrite(self)) return; - // A received RST surfacing as ECONNRESET with the close is not a clean - // EOF - Node destroys the socket with "read ECONNRESET" instead of a - // graceful 'end'. Only surface it when the closing handle is still the - // socket's current handle: connection attempts that lost the - // 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) { - // 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 (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 }; - er.errno = err.errno; - er.syscall = "read"; - self.destroy(er); - } else { - // Any other recv errno (ETIMEDOUT, EHOSTUNREACH, ENETUNREACH, ...) - // keeps its identity — Node's onStreamRead does - // `stream.destroy(errnoException(nread, 'read'))` for any nread that - // is not UV_EOF. The native on_close only passes a non-undefined err - // when the close was driven by a recv() failure (libus close-code - // enum values are filtered out in NewSocket::on_close). - self.destroy(err); - } - return; - } - if (!leftToTLSSocket && !deferEndForOnreadTail(self)) finishSocketEnd(self); - // 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()); - } - }, - handshake(socket: $Node.SocketHandle>, success, verifyError) { - $debug("Bun.Socket handshake"); - const { self } = socket.data; - onClientHandshake(self, socket, success, verifyError); - }, - error(socket, error) { - $debug("Bun.Socket error"); - if (socket.data === undefined) return; - const { self } = socket.data; - if (self._hadError) return; - self._hadError = true; - const tlsError = establishedTLSError(self, error); - if (tlsError) error = tlsError; - - const callback = self[kwriteCallback]; - if (callback) { - self[kwriteCallback] = null; - callback(error); - } else if (tlsError) { - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L467-L498 - (self as $Node.TLSSocket)._emitTLSError(error); - return; - } - - if (!self.destroyed) process.nextTick(destroyNT, self, error); - }, - timeout(socket) { - $debug("Bun.Socket timeout"); - const { self } = socket.data; - self.emit("timeout", self); - }, - connectError(socket: $Node.SocketHandle, error: NativeConnectError) { - $debug("Bun.Socket connectError"); - let { self, req } = socket.data; - socket[owner_symbol] = self; - socket.data.req = undefined; - // doConnect dispatches this synchronously when connect()/bind() fails at - // the syscall; surface it as kConnectTcp/Pipe's return value (callers' - // Node-derived `if (err)` expects that) instead of re-entering oncomplete. - if (req.dispatching) { - req.errno = error.errno || uv().UV_ECANCELED; - return; - } - // A TLS engine that failed before it opened has no connect pending: afterConnect would drop the failure. - if (self[kupgraded]) { - if (!self.destroyed) self.destroy(new ErrnoException(error.errno, "connect")); + if (socket[kAdoptedTLSRaw]) return; + const tail = self[kOnreadTail]; + if (tail !== undefined) { + self[kOnreadTail] = Buffer.concat([tail, buffer]); return; } - // Closing the handle cancels the request (ECANCELED). libuv completes it on a later loop turn, - // after destroy(err)'s 'error'. Not deferred here: it would land on a connect() made right - // after destroy() and fail that one. - // An attempt that timed out was closed with its `oncomplete` cleared. - req.oncomplete?.(error.errno, self._handle, req, true, true); + self[kOnreadDeliver](buffer); }, -} satisfies InternalSocketHandler; - -// The same table minus the per-connection callback members: a listener whose -// config has neither handler never registers the native SNI/ALPN dispatches, -// so a server without an SNICallback or ALPNCallback does not pay a JS -// round-trip from inside the handshake for them. -const { serverName: _serverNameHandler, alpnCallback: _alpnCallbackHandler, ...ServerHandlersNoSNI } = ServerHandlers; -// Partial tables so a server with exactly one of the callbacks only registers -// that dispatch (the other would be a per-handshake JS round-trip that always -// falls through). -const { serverName: _snOnly, ...ServerHandlersALPNOnly } = ServerHandlers; -const { alpnCallback: _acOnly, ...ServerHandlersSNIOnly } = ServerHandlers; - -/** The handler table for a listen config: the full table only when a - * per-connection callback is configured, so other servers never pay a JS - * round-trip from inside the handshake. */ -function serverHandlersFor(server: $Node.NetServer | $Node.TLSSocket) { - const sni = !!server._SNICallback; - const alpn = !!server._ALPNCallback; - if (sni && alpn) return ServerHandlers; - if (sni) return ServerHandlersSNIOnly; - if (alpn) return ServerHandlersALPNOnly; - return ServerHandlersNoSNI; -} +}; // node.net.native trace events: one 'b'/'e' pair per connect *attempt*, including // failed ones. 'b' fires where the attempt is issued; the per-req flag dedupes the @@ -2288,6 +1852,11 @@ function traceConnectEnd(req: ConnectReq | undefined) { function kConnectTcp(self: $Node.Socket, addressType: 4 | 6, req: ConnectReq, address: string, port: number) { $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 as DialingHandle; + 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, @@ -2302,32 +1871,50 @@ function kConnectTcp(self: $Node.Socket, addressType: 4 | 6, req: ConnectReq, ad allowHalfOpen: true, tls: req.tls, pauseOnConnect: req.pauseOnConnect, - data: { self, req }, - socket: self[khandlers], }); } function kConnectPipe(self: $Node.Socket, req: ConnectReq, address: string) { $debug("SocketHandle.kConnectPipe"); - return kConnectDispatch(self, req, { + const handle = self._handle as DialingHandle; + const replaced = handle.connect; + if (typeof replaced === "function") return replaced.$call(handle, req, address); + traceConnectStart(req, address); + 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 }, - 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 || errno === uv().UV_EISCONN) return errno; + setImmediate(afterPipeConnectFailed, self, handle, req, errno); + return 0; +} + +function afterPipeConnectFailed(self, handle, req, errno) { + // destroy() or another connect() already ended this request. + if (self._handle === handle && handle[kConnectReq] === req) req.oncomplete(errno, handle, req, true, true); } function kConnectDispatch(self: $Node.Socket, req: ConnectReq, opts: NativeConnectOptions) { // Node's TCPWrap returns errno for sync uv_*_connect failure and defers // oncomplete; doConnect instead fires connectError inside this call. Bracket // it so connectError hands the errno back here instead of re-entering. + const handle = self._handle!; + handle[kConnectReq] = req; + opts.data = self; + opts.socket = self[khandlers]; req.dispatching = true; - const promise = doConnect(self._handle, opts); + const promise = doConnect(handle, opts); req.dispatching = false; + // The handle has a connection that isn't its own to drop, like that of an http server. + if (promise === undefined) { + traceConnectEnd(req); + return uv().UV_EISCONN; + } promise.catch(_reason => { // eat this so there's no unhandledRejection // we already catch this in connectError and error @@ -2395,22 +1982,23 @@ function Socket(this: $Node.Socket, options?: $Node.SocketOptions): void { // Bun.Socket), so store ms here. Node stores seconds because libuv does. this[kSetKeepAliveInitialDelay] = MathMax(0, ~~keepAliveInitialDelay!); - this[khandlers] = SocketHandlers2; + this[khandlers] = SocketHandlers; this[kDestroyOnRead] = false; - this.bytesRead = 0; + this[kBytesRead] = 0; this[kBytesWritten] = undefined; + this[kLastBytesSent] = 0; this[kclosed] = false; this[kended] = false; + this[kHungUp] = false; this.connecting = false; this._host = undefined; this._port = undefined; this[bunTLSConnectOptions] = null; this.timeout = 0; - this[kLastWriteQueueSize] = 0; // node initializes the timer slot to null so it is observable before setTimeout() is called. // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L401 this[kTimeout] = null; - this[kwriteCallback] = undefined; + this[kWriteReq] = undefined; this._pendingData = undefined; this._pendingEncoding = undefined; // for compatibility this._hadError = false; @@ -2426,19 +2014,15 @@ function Socket(this: $Node.Socket, options?: $Node.SocketOptions): void { // Shut down the socket when we're finished with it. this.on("end", onSocketEnd); + let stats; + let fdIsWriteOnly = false; 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: $Node.Stats; try { stats = require("node:fs").fstatSync(fd); } catch { @@ -2450,19 +2034,25 @@ function Socket(this: $Node.Socket, options?: $Node.SocketOptions): 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)) { + fdIsWriteOnly = true; + // 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); } } } @@ -2510,7 +2100,7 @@ function Socket(this: $Node.Socket, options?: $Node.SocketOptions): 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; @@ -2541,7 +2131,7 @@ function Socket(this: $Node.Socket, options?: $Node.SocketOptions): 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; @@ -2560,22 +2150,7 @@ function Socket(this: $Node.Socket, options?: $Node.SocketOptions): void { } } }; - // when the onread option is specified we use a different handlers object - this[khandlers] = { - ...SocketHandlers2, - data(socket, buffer) { - const { self } = socket.data; - if (!self) return; - self._unrefTimer(); - if (socket[kAdoptedTLSRaw]) return; - const tail = self[kOnreadTail]; - if (tail !== undefined) { - self[kOnreadTail] = Buffer.concat([tail, buffer]); - return; - } - self[kOnreadDeliver]!(buffer); - }, - }; + this[khandlers] = OnreadHandlers; } if (signal) { if (signal.aborted) { @@ -2595,6 +2170,25 @@ function Socket(this: $Node.Socket, options?: $Node.SocketOptions): 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 (fdIsWriteOnly) { + Socket.prototype.connect.$call(this, { fd, fdIsWriteOnly }); + // Nothing is read from this handle. Like a socket whose peer sent FIN, it holds the loop only while a write is pending. + this[kended] = true; + this._handle!.unref(); + } else if ( + process.platform !== "win32" && + fd !== undefined && + fd > 2 && + 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); netSymbols.constructors.Socket = Socket as unknown as $Node.SocketConstructor; @@ -2610,11 +2204,11 @@ Socket.prototype.address = function address(this: $Node.Socket) { Socket.prototype._onTimeout = function (this: $Node.Socket) { const handle = this._handle; if (handle) { - // As in Node, a write that moved since the last look puts the timeout off. Its first look is at the size of the write, this one at 0. - // https://github.com/nodejs/node/blob/v26.12.1/lib/net.js#L872-L887 - const writeQueueSize = getBufferedAmount(handle); - if (writeQueueSize !== this[kLastWriteQueueSize]) { - this[kLastWriteQueueSize] = writeQueueSize; + // A write queue that the kernel took bytes of since the last look is still draining, so wait another period. Its size doesn't tell: a node:http response can add to it. + const { writeQueueSize } = handle; + const sent = handle.bytesWritten - writeQueueSize; + if (writeQueueSize !== 0 && sent > this[kLastBytesSent]) { + this[kLastBytesSent] = sent; this._unrefTimer(); return; } @@ -2631,15 +2225,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 (this: $Node.Socket) { - return this[kBytesWritten] || 0; + const handle = this._handle; + return (handle ? handle.bytesWritten : this[kBytesWritten]) || 0; }, }); Object.defineProperty(Socket.prototype, "bytesWritten", { get: function (this: $Node.Socket) { - let bytes = this[kBytesWritten] || 0; + let bytes = this._bytesDispatched; const data = this._pendingData; const writableBuffer = this.writableBuffer; if (!writableBuffer) return undefined; @@ -2655,38 +2261,22 @@ Object.defineProperty(Socket.prototype, "bytesWritten", { }, }); -Socket.prototype[kAttach] = function ( - this: $Node.Socket, - port: number | undefined, - socket: $Node.SocketHandle<$Node.Socket>, -) { +Socket.prototype[kAttach] = function (socket: $Node.SocketHandle<$Node.Socket>) { socket.data = this; socket[owner_symbol] = this; const timeout = this.timeout; if (timeout) { this.setTimeout(timeout); } - // make sure to disable timeout on usocket and handle on TS side - socket.timeout(0); this._handle = socket; this.connecting = false; - if (this[kSetNoDelay]) { - socket.setNoDelay!(true); - } - - if (this[kSetKeepAlive]) { - socket.setKeepAlive!(true, this[kSetKeepAliveInitialDelay]); - } - 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); this.emit("ready"); } - SocketHandlers.drain(socket); }; Socket.prototype[kCloseRawConnection] = function (this: $Node.Socket) { @@ -2694,96 +2284,90 @@ Socket.prototype[kCloseRawConnection] = function (this: $Node.Socket) { // 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(); }; +// A handle that is open already (fd, wrapped stream) starts reading unless the user paused. afterConnect does this for a dial. +function startReadingNT(self) { + if (!self.connecting && !self.isPaused()) self.read(0); +} + Socket.prototype.connect = function connect(this: $Node.Socket, ...args) { $debug("Socket.prototype.connect"); + // The closed handle cancels its queued write from the event loop. By then that would fail this connection. + if (this.destroyed && this[kWriteReq]) onWriteComplete(this, this[kWriteReq].writeHandle, uv().UV_ECANCELED); + const [options, cb] = $isArray(args[0]) && args[0][normalizedArgsSymbol] ? args[0] : normalizeArgs(args); + const isTLS = typeof this[bunTlsSymbol] === "function"; { - const [options, connectListener] = - $isArray(args[0]) && args[0][normalizedArgsSymbol] ? args[0] : normalizeArgs(args); let connection = this[ksocket]; let upgradeDuplex = false; - let { port, host, path, socket, rejectUnauthorized, checkServerIdentity, session, fd, pauseOnConnect } = options; + const { port, host, path, socket, rejectUnauthorized, checkServerIdentity, session, fd, pauseOnConnect } = options; // A session over a transport the caller handed in has no connection of its own to dial again. if (!socket && this[kupgraded] && !this.destroyed) { const ex = new ExceptionWithHostPort(uv().UV_EISCONN, "connect", path || host || "localhost", port); process.nextTick(destroyNT, this, ex); return this; } - this.servername = options.servername; + if (isTLS) this.servername = options.servername; if (socket) { connection = socket; } if (fd != null) { - doConnect(this._handle, { - data: this, - fd: fd, - ...(options.fdIsRawSocket === true ? { fdIsRawSocket: true } : {}), - socket: SocketHandlers, - // Always half-open natively; see kConnect. - allowHalfOpen: true, - pauseOnConnect, - }).catch(error => { - if (!this.destroyed) { - this.emit("error", error); - this.emit("close", true); - } - }); + // createConnection({ fd }) gets here after the constructor already adopted the fd. + if (this._handle?.fd !== fd) { + this._handle ??= newDetachedSocket(false); + const errno = kConnectDispatch( + this, + { oncomplete: afterOpenFd }, + { + fd, + fdIsRawSocket: options.fdIsRawSocket === true, + fdIsWriteOnly: options.fdIsWriteOnly === true, + // Always half-open natively; see kConnect. + allowHalfOpen: true, + pauseOnConnect, + }, + ); + // https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L568-L569 + if (errno !== 0) throw new ErrnoException(errno, "open"); + } + if (pauseOnConnect) pauseOnCreate(this, this._handle); + else process.nextTick(startReadingNT, this); + return this; } if (pauseOnConnect) { - // An fd is open already; a dial is paused when it opens, and afterConnect releases its hold. - if (fd != null) pauseOnCreate(this, this._handle); - else this.readableFlowing = false; + // A dial is paused when it opens, and afterConnect releases its hold. + this.readableFlowing = false; } else { - process.nextTick(() => { - // An already-open handle (fd, wrapped duplex) starts reading here unless - // the user paused; read(0) does that without switching to flowing mode. - // A pending connect gets this from afterConnect instead, so a pause() - // that lands before then is still honored: - // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/net.js#L1649 - if (!this.connecting && !this.isPaused()) this.read(0); - }); - if (fd == null) this.connecting = true; - } - if (fd != null) { - return this; + this.connecting = true; + process.nextTick(startReadingNT, this); } 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) && (isTLS ? 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); + if (isTLS) { + if (checkServerIdentity !== undefined) validateFunction(checkServerIdentity, "options.checkServerIdentity"); // Client always request Cert this._requestCert = true; - if (tls) { - applyRejectUnauthorized(this, tls, rejectUnauthorized); - tls.requestCert = true; - tls.session = session || tls.session; - this.servername = tls.servername; - if (checkServerIdentity !== undefined) { - validateFunction(checkServerIdentity, "options.checkServerIdentity"); - } - tls.checkServerIdentity = checkServerIdentity || tls.checkServerIdentity; - this[bunTLSConnectOptions] = tls; - } + tls = clientTLSOptions(this, { rejectUnauthorized, checkServerIdentity, session }, host); 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"); @@ -2803,7 +2387,7 @@ Socket.prototype.connect = function connect(this: $Node.Socket, ...args) { } // start using existing connection if (connection) { - if (connectListener != null) this.once("secureConnect", connectListener); + if (cb != null) this.once("secureConnect", cb); // reset the underlying writable object when establishing a new connection // this is a function on `Duplex`, originally defined on `Writable` // https://github.com/nodejs/node/blob/c5cfdd48497fe9bd8dbd55fd1fca84b321f48ec1/lib/net.js#L311 @@ -2818,13 +2402,7 @@ Socket.prototype.connect = function connect(this: $Node.Socket, ...args) { } } - const [options, cb] = $isArray(args[0]) && args[0][normalizedArgsSymbol] ? args[0] : normalizeArgs(args); - - if (typeof this[bunTlsSymbol] === "function" && cb !== null) { - this.once("secureConnect", cb); - } else if (cb !== null) { - this.once("connect", cb); - } + if (cb !== null) this.once(isTLS ? "secureConnect" : "connect", cb); if (this._parent?.connecting) { return this; } @@ -2845,7 +2423,7 @@ Socket.prototype.connect = function connect(this: $Node.Socket, ...args) { $debug("pipe", pipe, path); if (!this._handle) { - this._handle = newDetachedSocket(typeof this[bunTlsSymbol] === "function"); + this._handle = newDetachedSocket(isTLS); initSocketHandle(this); } @@ -2873,45 +2451,28 @@ Socket.prototype.end = function end(this: $Node.Socket, data?, encoding?, callba return Duplex.prototype.end.$call(this, data, encoding, callback); }; -Socket.prototype._destroy = function _destroy(this: $Node.Socket, err: Error | null, callback: WriteCallback) { +// `scheduleClose` is node:http's, for a connection of its server. +Socket.prototype._destroy = function _destroy( + this: $Node.Socket, + err: Error | null, + callback: WriteCallback, + scheduleClose: CloseScheduler = setImmediate, +) { $debug("Socket.prototype._destroy"); this.connecting = false; const upgraded = this[kupgraded]; - // Close an fd adopted for synchronous writes (node closes the wrapping - // libuv handle here). Leave stdio fds 0-2 open: process.stdout/stderr and - // other wrappers share them, matching SyncWriteStream's autoClose gate. - const syncFd = this[kSyncWriteFd]; - if (syncFd !== undefined) { - this[kSyncWriteFd] = undefined; - // Drop the instance overrides so a later connect() on this (reusable) - // socket goes through the fresh handle's normal write path. - delete (this as { _write?: unknown })._write; - delete (this as { _writev?: unknown })._writev; - if (syncFd > 2) { - try { - require("node:fs").closeSync(syncFd); - } catch { - // Already closed by the peer/user; nothing to release. - } - } - } - // What closes with this socket clears its own. clearTimeout(this[kTimeout]!); const released = unadoptedTransport(this); if (released !== undefined && !this.resetAndClosing && this[kSessionAccepted] === true) releaseTransport(released); $debug("close"); - if (this._handle) { + const currentHandle = this._handle; + if (currentHandle) { $debug("close handle"); const isException = err ? true : false; - // `bytesRead` and `kBytesWritten` should be accessible after `.destroy()` - // this[kBytesRead] = this._handle.bytesRead; - this[kBytesWritten] = this._handle.bytesWritten; - - const currentHandle = this._handle; // Ahead of every branch: two of them close the handle a loop turn later, when the flight has left. if (typeof this[bunTlsSymbol] === "function" || currentHandle[kAdoptedTLSRaw]) { releaseHeldFlight(currentHandle); @@ -2924,7 +2485,7 @@ Socket.prototype._destroy = function _destroy(this: $Node.Socket, err: Error | n // SO_LINGER{1,0} for a real reset on all platforms. const transport = unadoptedTransport(this); if (transport !== undefined) abortTransport(transport); - const err = this._handle.terminate(); + const err = this._handle!.terminate(); setImmediate(() => { $debug("emit close"); this.emit("close", isException); @@ -2941,22 +2502,15 @@ Socket.prototype._destroy = function _destroy(this: $Node.Socket, err: Error | n currentHandle.pause?.(); setImmediate(closeAdoptedTLSRawNT, this, currentHandle, isException); } else { - closeSocketHandle(this, currentHandle, isException); + closeSocketHandle(this, currentHandle, isException, false, scheduleClose); } - if (!this._closeAfterHandlingError) { - const handle = this._handle; - if (handle) handle.onread = noop; - this._handle = null; - this._sockname = null; - } + if (!this._closeAfterHandlingError) detachSocket(this); callback(err); closeOwedRaw(this, upgraded); - if (this[kwriteCallback]) process.nextTick(cancelPendingWriteNT, this); } else { callback(err); closeOwedRaw(this, upgraded); - if (this[kwriteCallback]) process.nextTick(cancelPendingWriteNT, this); const transport = transportToDestroy(this); if (transport !== undefined) abortTransport(transport); if (transport instanceof Socket) transport.once("close", emitCloseNT.bind(null, this, err ? true : false)); @@ -3021,7 +2575,7 @@ Object.defineProperty(Socket.prototype, "pending", { // if the fd were adopted; such sockets use the stream-level TLS engine, whose // ciphertext queues behind the pending writes (order + callbacks preserved). function hasUnflushedWrites(connection: $Node.Socket) { - return connection.writableLength > 0 || connection[kwriteCallback] != null; + return connection.writableLength > 0 || connection[kWriteReq] !== undefined; } function drainOnreadTail(self: $Node.Socket, fromRead?: boolean) { @@ -3048,24 +2602,20 @@ function drainOnreadTailNT(socket: $Node.Socket) { socket[kOnreadPendingEnd] = false; finishSocketEnd(socket); } else if (fromRead || !socket.isPaused()) { - socket._handle?.resume?.(); - restorePausedHold(socket, socket._handle); + readStart(socket, socket._handle); } } const resumeOfSocket = function resume(this: $Node.Socket) { + // Node's Readable.resume() ignores a destroyed stream. Ours also waits for 'end', which destroy() doesn't emit. + if (this.destroyed) return this; + // Schedule the Readable flow tick first so its read() runs while // kOnreadDraining is still set and does not queue a second drain: Node's // override sets handle.reading synchronously for the same reason. const ret = Duplex.prototype.resume.$call(this); - // An ended readable side (EOF emitted, or `readable: false`) never restarts the handle: node reaches readStart only from _read. - // An onread socket is the exception: https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L830-L845 - if (this.readableEnded && this[kOnreadBuffer] === undefined) return ret; - if (!this.connecting && !drainOnreadTail(this)) { - this._handle?.resume?.(); - } - // Even while still connecting, so pause-then-resume stays symmetric. - restorePausedHold(this, this._handle); + // Reads start from _read(), which a full buffer doesn't reach. Only an onread socket starts them here: https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L976-L982 + if (this[kOnreadBuffer] !== undefined && !this.connecting && !drainOnreadTail(this)) readStart(this, this._handle); return ret; }; Socket.prototype.resume = resumeOfSocket; @@ -3080,11 +2630,16 @@ Socket.prototype.pause = function pause(this: $Node.Socket) { 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 +// serverHandlersFor() — the shared accepted-socket handler table, with per-socket // state carried via `data` (mirrors tls.createServer's one-handler-for-all model). Socket.prototype[ CAN_REPLACE_BUILT_IN_FUNCTIONS @@ -3094,8 +2649,15 @@ Socket.prototype[ 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)) { - // No adoptable fd (generic Duplex, named pipe, never dialed), TLS over TLS (the fd belongs to the + if ( + !socket || + (connection[kclosed] && connection.allowHalfOpen) || + !(connection instanceof Socket) || + connection.encrypted || + isNamedPipeSocket(socket) || + hasUnflushedWrites(connection) + ) { + // No adoptable fd (generic Duplex, named pipe, never dialed, closed under a half-open socket), TLS over TLS (the fd belongs to the // outer SSL layer), or pending plain writes that must flush first. attachTLSEngine(this, connection, options); } else if (connection.connecting) { @@ -3120,12 +2682,10 @@ Socket.prototype[kUpgradeClientTLS] = function ( }); }; +// https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L985-L991 Socket.prototype.read = function read(this: $Node.Socket, size?: number) { - // See resume(): an ended readable side never restarts the handle. - if ((!this.readableEnded || this[kOnreadBuffer] !== undefined) && !this.connecting && !drainOnreadTail(this, true)) { - this._handle?.resume?.(); - restorePausedHold(this, this._handle); - } + if (this[kOnreadBuffer] !== undefined && !this.connecting && !drainOnreadTail(this, true)) + readStart(this, this._handle); return Duplex.prototype.read.$call(this, size); }; @@ -3134,8 +2694,7 @@ Socket.prototype._read = function _read(this: $Node.Socket, size: number) { if (this.connecting || !socket) { this.once("connect", () => this._read(size)); } else if (!drainOnreadTail(this, true)) { - socket?.resume?.(); - restorePausedHold(this, socket); + readStart(this, socket); } }; @@ -3218,47 +2777,6 @@ Object.defineProperty(Socket.prototype, "remoteFamily", { }, }); -function fdSyncWrite(this: $Node.Socket, chunk: Chunk, encoding: BufferEncoding, callback: WriteCallback) { - const fs = require("node:fs"); - // node's _writeGeneric restarts the idle timer on every write. - this._unrefTimer(); - 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 as Error); - } -} - -function fdSyncWritev(this: $Node.Socket, data: { chunk: Chunk; encoding: BufferEncoding }[], callback: WriteCallback) { - 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; - while (offset < buf.length) { - offset += fs.writeSync(this[kSyncWriteFd], buf, offset); - } - total += offset; - } - // See fdSyncWrite: no native handle to account these on. - this[kBytesWritten] = (this[kBytesWritten] || 0) + total; - callback(); - } catch (err) { - callback(err as Error); - } -} - Socket.prototype.resetAndDestroy = function resetAndDestroy(this: $Node.Socket) { if (this._handle) { if (this.connecting) { @@ -3272,6 +2790,12 @@ Socket.prototype.resetAndDestroy = function resetAndDestroy(this: $Node.Socket) return this; }; +// The handle takes milliseconds that fit an int32. Past that the system's own delay applies. +function keepAliveDelayOf(server) { + const delay = server.keepAliveInitialDelay * 1000; + return delay > 0 && delay <= 2147483647 ? delay : 0; +} + Socket.prototype.setKeepAlive = function setKeepAlive(this: $Node.Socket, enable = false, initialDelayMsecs = 0) { enable = Boolean(enable); // Bun's native _handle.setKeepAlive takes milliseconds; the ms→seconds @@ -3310,6 +2834,11 @@ Socket.prototype.setNoDelay = function setNoDelay(this: $Node.Socket, enable = t 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. @@ -3330,7 +2859,7 @@ Socket.prototype.setTypeOfService = function setTypeOfService(this: $Node.Socket 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"); } } @@ -3345,7 +2874,7 @@ Socket.prototype.getTypeOfService = function getTypeOfService(this: $Node.Socket 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"); @@ -3353,6 +2882,11 @@ Socket.prototype.getTypeOfService = function getTypeOfService(this: $Node.Socket return res; }; +// https://github.com/nodejs/node/blob/v26.12.1/lib/internal/stream_base_commons.js +function onStreamTimeout(stream) { + stream._onTimeout(); +} + Socket.prototype.setTimeout = { setTimeout(this: $Node.Socket, msecs: number, callback?: () => void) { if (this.destroyed) return this; @@ -3369,7 +2903,7 @@ Socket.prototype.setTimeout = { this.removeListener("timeout", callback); } } else { - this[kTimeout] = (setTimeout(this._onTimeout.bind(this), msecs) as Timer).unref(); + this[kTimeout] = (setTimeout(onStreamTimeout, msecs, this) as Timer).unref(); if (callback !== undefined) { validateFunction(callback, "callback"); @@ -3405,33 +2939,23 @@ Socket.prototype.destroySoon = function destroySoon(this: $Node.Socket) { }; //TODO: migrate to native -// `data`: a list of `{ chunk, encoding }`, whose entries are replaced by their chunks below. +// Writable calls this with two chunks or more. Socket.prototype._writev = function _writev(this: $Node.Socket, data, callback: WriteCallback) { - const allBuffers = data.allBuffers; - const chunks = data; - if (allBuffers) { - if (data.length === 1) { - return this._write(data[0], "buffer", callback); - } - for (let i = 0; i < data.length; i++) { - data[i] = data[i].chunk; - } - } else { - if (data.length === 1) { - const { chunk, encoding } = data[0]; - return this._write(chunk, encoding, callback); - } - for (let i = 0; i < data.length; i++) { - const { chunk, encoding } = data[i]; - if (typeof chunk === "string") { - data[i] = Buffer.from(chunk, encoding); - } else { - data[i] = chunk; - } - } + // An HTTP message ends with an empty write, whose callback is its 'finish'. A lone string needs no copy, and nobody can change it while part of it waits. + let only; + let count = 0; + for (let i = 0; i < data.length; i++) { + if (data[i].chunk.length !== 0 && ++count === 1) only = data[i]; + } + if (count === 1) { + const { chunk, encoding } = only; + if (typeof chunk === "string") return this._write(chunk, encoding, callback); + } + for (let i = 0; i < data.length; i++) { + const { chunk, encoding } = data[i]; + data[i] = typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk; } - const chunk = Buffer.concat(chunks || []); - return this._write(chunk, "buffer", callback); + return this._write(Buffer.concat(data), "buffer", callback); }; Socket.prototype._write = function _write( @@ -3445,7 +2969,6 @@ Socket.prototype._write = function _write( // The Writable logic will buffer up any more writes while // waiting for this one to be done. if (this.connecting) { - this[kwriteCallback] = callback; this._pendingData = chunk; this._pendingEncoding = encoding; function onClose() { @@ -3466,7 +2989,6 @@ Socket.prototype._write = function _write( // adopts the fd a tick later, so hold the write until it lands rather // than failing it as a closed socket. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L590-L608 - this[kwriteCallback] = callback; this._pendingData = chunk; this._pendingEncoding = encoding; const onClose = onUpgradeWriteClose.bind(this, callback); @@ -3476,60 +2998,30 @@ Socket.prototype._write = function _write( } this._pendingData = null; this._pendingEncoding = ""; - this[kwriteCallback] = null; if (!socket) { callback($ERR_SOCKET_CLOSED()); return false; } this._unrefTimer(); - this[kLastWriteQueueSize] = 0; if (this.secureConnecting) this[kPreHandshakeWrite] = true; - 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 - // in Node instead of waiting forever for a drain that never comes. - // Node reports this as errnoException(UV_EBADF/UV_EPIPE, 'write'), with - // message, code, errno and syscall all populated. - const er = new ErrnoException(process.platform === "win32" ? -4047 /* UV_EPIPE */ : -9 /* UV_EBADF */, "write"); - // Node fails a write that the handle rejects at once inside the same call, - // so write() returns false. The stream runs the write callback and destroys - // the socket on the next tick. - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/stream_base_commons.js#L158-L159 - callback(er); - return false; - } - const res = socket.$write(chunk, encoding); - this[kBytesWritten] = socket.bytesWritten; - // `true` is the usual answer. A cast to `number` would be a test that fails on every write in compiled code. - if ((res as any) < 0) { - // The kernel rejected the send outright (peer reset): $write returned the - // negative errno; deliver it like the EBADF/EPIPE branch above. - if (!holdRejectedWrite(this, socket, res as number, callback)) callback(writeErrnoException(res as number)); - return false; - } - if (res) { - if (this.encrypted) { - // TLS batches writes through the SSL engine, so the bytes stay buffered - // after $write returns. Defer the callback so writableLength/bufferSize - // reflects the queued bytes until they are flushed (test-tls-buffersize.js). - // Node's bufferSize getter is just writableLength: - // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/net.js#L752 - process.nextTick(callback); - } else { - // A plain TCP write completes synchronously once $write reports success. - // Calling the callback synchronously lets writableLength drain so a tight - // write() loop backpressures at the kernel rather than the JS - // highWaterMark, matching Node's _write (test-net-throttle.js): - // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/net.js#L1036 - callback(); - } - } else if (this[kwriteCallback]) { - callback(new Error("overlapping _write()")); - } else { - this[kwriteCallback] = callback; + const status = socket.$write(chunk, encoding); + if (status === false) { + this[kWriteReq] = { writeHandle: socket, writeCallback: callback, writeFrame: AsyncContextFrame.current() }; // A pending write holds the loop even on a handle whose FIN/pause dropped // its hold (libuv: the uv_write_t is active); unrefAfterDrain lets go again. if ((this[kended] || this[kPausedUnref]) && !this[kUserUnrefed]) socket.ref?.(); + } else if (status !== true) { + // https://github.com/nodejs/node/blob/v26.5.0/lib/internal/stream_base_commons.js#L158-L159 + // Node still holds the descriptor of a peer that hung up, so its write fails with EPIPE. + const err = new ErrnoException(this[kHungUp] && status === uv().UV_EBADF ? uv().UV_EPIPE : status, "write"); + if (endsWithoutError(this)) process.nextTick(callback, err); + else callback(err); + } else if (this.encrypted) { + // A TLS write is never done in the same call in Node, so writableLength counts it until the next tick (test-tls-buffersize.js). + process.nextTick(callback); + } else { + // Done in the same call, so a tight write() loop stops at the kernel's backpressure, not at highWaterMark (test-net-throttle.js). + callback(); } }; @@ -3755,15 +3247,15 @@ function lookupAndConnectMultiple( }); } -// Per attempt, not per connect(): setServername()/setSession() calls made after tls.connect() must reach the dial. -function clientTLSOptionsForAttempt( +// connect() asks for a transport it was handed. A dial asks per attempt: a setSession() or setServername() made since connect() counts. +function clientTLSOptions( self: $Node.Socket, options: ConnectOptions, - port: number | undefined, + host: string | undefined, ): TLSConnectOptions | undefined { const bunTLS = self[bunTlsSymbol]; if (typeof bunTLS !== "function") return undefined; - const tls = bunTLS.$call(self, port, self._host, true); + const tls = bunTLS.$call(self, host); if (tls) { const { rejectUnauthorized, session, checkServerIdentity } = options; applyRejectUnauthorized(self, tls, rejectUnauthorized); @@ -3785,42 +3277,20 @@ function internalConnect( addressType?: number, localAddress?: string, localPort?: number, - _flags?: undefined, ) { $assert(self.connecting); let err; - if (localAddress || localPort) { - if (addressType === 4) { - localAddress ||= "0.0.0.0"; - // TODO: - // err = self._handle.bind(localAddress, localPort); - } else { - // addressType === 6 - localAddress ||= "::"; - // TODO: - // err = self._handle.bind6(localAddress, localPort, flags); - } - $debug( - "connect: binding to localAddress: %s and localPort: %d (addressType: %d)", - localAddress, - localPort, - addressType, - ); - - err = checkBindError(err, localPort, self._handle); - if (err) { - const ex = new ExceptionWithHostPort(err, "bind", localAddress, localPort); - self.destroy(ex); - return; - } - } + // The native connect binds, and reports a failed bind() as its own error. + if (localPort) localAddress ||= addressType === 4 ? "0.0.0.0" : "::"; - const tls = clientTLSOptionsForAttempt(self, options, port); + const tls = clientTLSOptions(self, options, self._host); $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}`)) { @@ -3837,7 +3307,6 @@ function internalConnect( 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 @@ -3858,7 +3327,6 @@ function internalConnect( req.tls = tls; req.pauseOnConnect = options.pauseOnConnect; - traceConnectStart(req, address); err = kConnectPipe(self, req, address); } @@ -3896,37 +3364,9 @@ function internalConnectMultiple(context: ConnectMultipleContext, canceled?: boo self[kReinitializeHandle](newDetachedSocket(typeof self[bunTlsSymbol] === "function")); } - const { localPort, port, _flags } = context; + const { localPort, port } = context; const { address, family: addressType } = context.addresses[current]; - let localAddress; - let err; - - if (localPort) { - if (addressType === 4) { - localAddress = "0.0.0.0"; - // TODO: - // err = self._handle.bind(localAddress, localPort); - } else { - // addressType === 6 - localAddress = "::"; - // TODO: - // err = self._handle.bind6(localAddress, localPort, flags); - } - - $debug( - "connect/multiple: binding to localAddress: %s and localPort: %d (addressType: %d)", - localAddress, - localPort, - addressType, - ); - - err = checkBindError(err, localPort, self._handle); - if (err) { - ArrayPrototypePush.$call(context.errors, new ExceptionWithHostPort(err, "bind", localAddress, localPort)); - internalConnectMultiple(context); - return; - } - } + const localAddress = localPort ? (addressType === 4 ? "0.0.0.0" : "::") : undefined; if (self.blockList?.check(address, `ipv${addressType}`)) { const ex = $ERR_IP_BLOCKED(address); @@ -3936,10 +3376,11 @@ function internalConnectMultiple(context: ConnectMultipleContext, canceled?: boo return; } - const tls = clientTLSOptionsForAttempt(self, context.options, port); + const tls = clientTLSOptions(self, context.options, self._host); $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 = {}; @@ -3954,8 +3395,7 @@ function internalConnectMultiple(context: ConnectMultipleContext, canceled?: boo ArrayPrototypePush.$call(self.autoSelectFamilyAttemptedAddresses, `${address}:${port}`); - traceConnectStart(req); - err = kConnectTcp(self, addressType, req, address, port); + const err = kConnectTcp(self, addressType, req, address, port); if (err) { const ex = new ExceptionWithHostPort(err, "connect", address, port); @@ -3967,9 +3407,7 @@ function internalConnectMultiple(context: ConnectMultipleContext, canceled?: boo 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; } @@ -4015,6 +3453,32 @@ function internalConnectMultipleTimeout(context: ConnectMultipleContext, req: Co } } +// Options set before the socket had a connection. +function applySocketOptions(self, handle) { + if (self[kSetTOS] !== undefined && handle.setTypeOfService) { + handle.setTypeOfService(self[kSetTOS]); + } + if (self[kSetNoDelay] && handle.setNoDelay) { + handle.setNoDelay(true); + } + if (self[kSetKeepAlive] && handle.setKeepAlive) { + handle.setKeepAlive(true, self[kSetKeepAliveInitialDelay]); + } +} + +// An adopted descriptor is open already, so this runs inside connect({ fd }). +function afterOpenFd(_status, handle) { + const self = handle[owner_symbol]; + self._unrefTimer(); + applySocketOptions(self, handle); + try { + self.emit("connect", self); + self.emit("ready"); + } catch (e) { + reportError(e); + } +} + function afterConnect( status: number, handle: SocketHandle | null, @@ -4052,25 +3516,20 @@ function afterConnect( self.end(); } self._unrefTimer(); - - if (self[kSetTOS] !== undefined && self._handle!.setTypeOfService) { - self._handle!.setTypeOfService(self[kSetTOS]); - } - if (self[kSetNoDelay] && self._handle!.setNoDelay) { - self._handle!.setNoDelay(true); - } - - if (self[kSetKeepAlive] && self._handle!.setKeepAlive) { - self._handle!.setKeepAlive(true, self[kSetKeepAliveInitialDelay]); - } + applySocketOptions(self, self._handle); // Ours already reads, Node's starts at read(): stop a paused plain socket now, and after the listeners unless one asked for a read. // A socket built with `readable: false` never reads: read() cannot reach _read once the readable side has ended. 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); @@ -4218,7 +3677,8 @@ function Server( // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/net.js#L1880 this.allowHalfOpen = allowHalfOpen; this.keepAlive = Boolean(keepAlive); - this.keepAliveInitialDelay = MathMax(0, ~~keepAliveInitialDelay); + // In seconds: https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L2041 + this.keepAliveInitialDelay = ~~(keepAliveInitialDelay / 1000); this.highWaterMark = highWaterMark; this.pauseOnConnect = Boolean(pauseOnConnect); this.noDelay = Boolean(noDelay); @@ -4248,6 +3708,12 @@ Object.defineProperty(Server.prototype, "listening", { }, }); +// node:http's listener is uWS's. Returns the handler table for the handles it accepts and their first `data`, where open() looks for the listener. +Server.prototype[kForeignListener] = function (SocketClass) { + this[bunSocketServerOptions] = { [kSocketClass]: SocketClass }; + return [serverHandlersFor(this), { _handle: { [owner_symbol]: this, onconnection } }]; +}; + Server.prototype.ref = function ref(this: $Node.NetServer) { this._unref = false; this._handle?.ref(); @@ -4338,21 +3804,19 @@ Server.prototype.getConnections = function getConnections( callback: (err: null, count: number) => void, ) { 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(this: $Node.NetServer, port, hostname, onListen) { +Server.prototype.listen = function listen(this: $Node.NetServer, 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) + // `options.backlog` is whatever the caller gave. + let backlog: any = toNumber(argsLength > 1 && hostname) || toNumber(argsLength > 2 && backlogArg); let path; let exclusive = false; let reusePort = false; @@ -4360,35 +3824,14 @@ Server.prototype.listen = function listen(this: $Node.NetServer, port, hostname, 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; @@ -4403,7 +3846,7 @@ Server.prototype.listen = function listen(this: $Node.NetServer, port, hostname, // 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; @@ -4472,7 +3915,8 @@ Server.prototype.listen = function listen(this: $Node.NetServer, port, hostname, // ipv6Only For TCP servers, setting ipv6Only to true will disable dual-stack support, i.e., binding to host :: won't make 0.0.0.0 be bound. Default: false. // signal An AbortSignal that may be used to close a listening server. - if (typeof options.callback === "function") onListen = options?.callback; + const { callback } = options; + if (typeof callback === "function") onListen = callback; } else if (port === undefined || port === null) { port = 0; } else if (typeof port === "number" || typeof port === "string") { @@ -4496,10 +3940,6 @@ Server.prototype.listen = function listen(this: $Node.NetServer, port, hostname, hostname = hostname || "::"; } - if (typeof port === "number" && (port < 0 || port >= 65536)) { - throw $ERR_SOCKET_BAD_PORT(`options.port should be >= 0 and < 65536. Received type number: (${port})`); - } - if (this._handle) { throw $ERR_SERVER_ALREADY_LISTEN(); } @@ -4515,7 +3955,7 @@ Server.prototype.listen = function listen(this: $Node.NetServer, port, hostname, const options = this[bunSocketServerOptions]!; let contexts: Map | null = null; if (typeof bunTLS === "function") { - [tls, TLSSocketClass] = bunTLS.$call(this, port, hostname, false); + [tls, TLSSocketClass] = bunTLS.$call(this, hostname); options.servername = tls!.serverName; options[kSocketClass] = TLSSocketClass; contexts = tls!.contexts; @@ -4531,7 +3971,7 @@ Server.prototype.listen = function listen(this: $Node.NetServer, port, hostname, queryAddress = path; queryPort = -1; queryAddressType = -1; - } else if (typeof fd === "number" && fd >= 0) { + } else if (fd !== undefined) { queryPort = null; queryAddressType = null; } else if (typeof clusterHost === "string") { @@ -4539,33 +3979,36 @@ Server.prototype.listen = function listen(this: $Node.NetServer, port, hostname, queryAddressType = isIP(clusterHost) || 4; } - listenInCluster( - this, - queryAddress, - queryPort, - queryAddressType, - backlog, - fd, - exclusive, - ipv6Only, - reusePort, - readableAll, - writableAll, - flags, - undefined, - path, - hostname, - tls, - contexts, - onListen, - ); + const listenOn = host => + listenInCluster( + this, + queryAddress, + queryPort, + queryAddressType, + backlog, + fd, + exclusive, + ipv6Only, + reusePort, + readableAll, + writableAll, + flags, + path, + host, + tls, + contexts, + ); + try { + listenOn(hostname); + } catch (err: any) { + // No host and no IPv6 in the kernel: https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L2127-L2139 + if (queryAddress !== null || err?.code !== "EAFNOSUPPORT") throw err; + listenOn((hostname = "0.0.0.0")); + } } catch (err) { - const isUnix = path != null; - process.nextTick( - emitErrorNextTick, - this, - formatListenError(err, isUnix ? path : hostname, isUnix ? undefined : port), - ); + const address = path ?? (fd === undefined ? hostname : null); + const { listenException } = require("internal/net/errors"); + process.nextTick(emitErrorNextTick, this, listenException(err, address, path != null ? -1 : port)); } return this; }; @@ -4582,7 +4025,6 @@ Server.prototype[kRealListen] = function ( writableAll: boolean, tls: ServerTLSOptions | undefined, contexts: Map | null, - _onListen: unknown, fd: number | undefined, ) { // NOTE: accepted sockets are always allowHalfOpen:true at the native layer @@ -4591,20 +4033,21 @@ Server.prototype[kRealListen] = function ( if (reusePort) { exclusive = false; } + const serverOptions = this[bunSocketServerOptions]; + const listenOptions: any = { + tls, + allowHalfOpen: true, + reusePort: reusePort || serverOptions?.reusePort || false, + ipv6Only: ipv6Only || serverOptions?.ipv6Only || false, + exclusive: exclusive || serverOptions?.exclusive || false, + socket: serverHandlersFor(this), + data: this, + pauseOnConnect: this.pauseOnConnect, + ...(path ? { unix: path } : fd != null ? { fd, hostname } : { port, hostname }), + }; + $putByIdDirectPrivate(listenOptions, "defaultTimeout", false); + this._handle = Bun.listen(listenOptions) as unknown as ListenerHandle; if (path) { - this._handle = Bun.listen({ - unix: path, - tls, - // Accepted sockets are always half-open natively; the stream layer - // implements allowHalfOpen=false (see kConnect / onSocketEnd). - allowHalfOpen: true, - reusePort: reusePort || this[bunSocketServerOptions]?.reusePort || false, - ipv6Only: ipv6Only || this[bunSocketServerOptions]?.ipv6Only || false, - exclusive: exclusive || this[bunSocketServerOptions]?.exclusive || false, - socket: serverHandlersFor(this), - data: this, - pauseOnConnect: this.pauseOnConnect, - }); // Mirror libuv uv_pipe_chmod: readableAll/writableAll relax the unix socket // file's group/other permission bits. Skipped on Windows and abstract // sockets (no filesystem entry). uSockets binds synchronously, so the file @@ -4625,32 +4068,6 @@ Server.prototype[kRealListen] = function ( throw e; } } - } else if (fd != null) { - this._handle = Bun.listen({ - fd, - hostname, - tls, - allowHalfOpen: true, - reusePort: reusePort || this[bunSocketServerOptions]?.reusePort || false, - ipv6Only: ipv6Only || this[bunSocketServerOptions]?.ipv6Only || false, - exclusive: exclusive || this[bunSocketServerOptions]?.exclusive || false, - socket: serverHandlersFor(this), - data: this, - pauseOnConnect: this.pauseOnConnect, - }); - } else { - this._handle = Bun.listen({ - port, - hostname, - tls, - allowHalfOpen: true, - reusePort: reusePort || this[bunSocketServerOptions]?.reusePort || false, - ipv6Only: ipv6Only || this[bunSocketServerOptions]?.ipv6Only || false, - exclusive: exclusive || this[bunSocketServerOptions]?.exclusive || false, - socket: serverHandlersFor(this), - data: this, - pauseOnConnect: this.pauseOnConnect, - }); } this._handle![owner_symbol] = this; @@ -4723,12 +4140,10 @@ function listenInCluster( readableAll: boolean, writableAll: boolean, flags: number, - options: object | undefined, path: string | undefined, hostname: string | undefined, tls: ServerTLSOptions | undefined, contexts: Map | null, - onListen: unknown, ) { exclusive = !!exclusive; @@ -4769,12 +4184,10 @@ function listenInCluster( readableAll, writableAll, flags, - options, path, hostname, tls, contexts, - onListen, ); }); return; @@ -4792,7 +4205,6 @@ function listenInCluster( writableAll, tls, contexts, - onListen, fd, ); return; @@ -4807,7 +4219,6 @@ function listenInCluster( backlog, readableAll, writableAll, - ...options, sharedOnly: tls ? true : undefined, }; const listeningId = (server[kClusterListeningId] = (server[kClusterListeningId] || 0) + 1); @@ -4840,7 +4251,7 @@ function listenInCluster( try { // The reply is asynchronous: a setSecureContext() or addContext() made since listen() counts. if (tls) { - tls = server[bunTlsSymbol]!(port, hostname, false)[0]; + tls = server[bunTlsSymbol]!(hostname)[0]; contexts = tls.contexts; } server[kRealListen]( @@ -4854,7 +4265,6 @@ function listenInCluster( writableAll, tls, contexts, - onListen, sharedFd, ); handle.adopted = true; @@ -4919,7 +4329,7 @@ function onClusterConnection(this: ClusterFauxHandle, err: number, clientHandle: if (self.noDelay) socket[kSetNoDelay] = true; if (self.keepAlive) { socket[kSetKeepAlive] = true; - socket[kSetKeepAliveInitialDelay] = self.keepAliveInitialDelay; + socket[kSetKeepAliveInitialDelay] = keepAliveDelayOf(self); } socket.connect({ fd: clientHandle.fd, fdIsRawSocket: true, pauseOnConnect: self.pauseOnConnect }); const blockList = self.blockList; @@ -4982,6 +4392,12 @@ function initSocketHandle(self: $Node.Socket) { self._sockname = null; self[kclosed] = false; self[kended] = false; + self[kHungUp] = 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; @@ -4996,7 +4412,15 @@ function initSocketHandle(self: $Node.Socket) { // intercepts close on `socket._handle` and invokes it, so always pass one. function onSocketHandleClosed() {} -function closeSocketHandle(self: $Node.Socket, handle: SocketHandle, isException: boolean, isCleanupPending = false) { +type CloseScheduler = (emit: typeof emitClose, self: $Node.Socket, isException: boolean) => unknown; + +function closeSocketHandle( + self: $Node.Socket, + handle: SocketHandle, + isException: boolean, + isCleanupPending = false, + scheduleClose: CloseScheduler = setImmediate, +) { $debug("closeSocketHandle", isException, isCleanupPending); // Ahead of the setImmediate below: the transport's 'close' precedes this socket's. const transport = transportToDestroy(self); @@ -5006,7 +4430,7 @@ function closeSocketHandle(self: $Node.Socket, handle: SocketHandle, isException handle.close(onSocketHandleClosed); // Arguments instead of a closure, so no scope keeps the closed handle alive. if (isCleanupPending) setImmediate(emitCloseAndDetachHandle, self, isException, handle); - else setImmediate(emitClose, self, isException); + else scheduleClose(emitClose, self, isException); } function emitClose(self: $Node.Socket, isException: boolean) { @@ -5016,44 +4440,8 @@ function emitClose(self: $Node.Socket, isException: boolean) { function emitCloseAndDetachHandle(self: $Node.Socket, isException: boolean, handle: SocketHandle) { emitClose(self, isException); - // self._handle may have been detached or replaced since. - handle.onread = noop; - if (self._handle === handle) { - self._handle = null; - self._sockname = null; - } -} - -// Reformat a native listen error to Node's "listen : " -// (Node uses exceptionWithHostPort). Only rewrites known uv codes; the code is -// already set natively. -// https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/net.js#L1899 -function uvListenErrorDescription(code: string) { - switch (code) { - case "EADDRINUSE": - return "address already in use"; - case "EACCES": - return "permission denied"; - case "EADDRNOTAVAIL": - return "address not available"; - case "EINVAL": - return "invalid argument"; - default: - return undefined; - } -} -function formatListenError(err, address: string | undefined, port: number | null | undefined) { - const desc = err && typeof err.code === "string" ? uvListenErrorDescription(err.code) : undefined; - if (desc) { - err.syscall = "listen"; - // Node's exceptionWithHostPort also exposes the failing address/port as - // own properties; user code commonly reads them off listen errors. - err.address = address; - if (port) err.port = port; - const where = port ? `${address}:${port}` : address; - err.message = `listen ${err.code}: ${desc}${where ? ` ${where}` : ""}`; - } - return err; + // connect() may have replaced it since. + if (self._handle === handle) detachSocket(self); } function checkBindError( diff --git a/src/js/ahead-of-time/node/tls.ts b/src/js/ahead-of-time/node/tls.ts index 4969a93949b0..e6948b261099 100644 --- a/src/js/ahead-of-time/node/tls.ts +++ b/src/js/ahead-of-time/node/tls.ts @@ -1,9 +1,9 @@ // What the compiler reads in place of src/js/node/tls.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 ee5ac146f5e92a0a2950d437cd7548198b4a2f79feaf5905835737dbf1d982eb. +// Written against that file with the SHA-256 a72df9a54c4349c86d8a90d4ddb99802cd308fef32e4f334535dba07bb6f1832. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. // Hardcoded module "node:tls" -const { isArrayBufferView } = require("node:util/types"); +const { isAnyArrayBuffer, isArrayBufferView } = require("node:util/types"); const net = require("node:net"); const Duplex = require("internal/streams/duplex"); const EventEmitter = require("node:events"); @@ -13,25 +13,26 @@ const addServerName = $bindgen("Listener.bind.ts", "addServerName") as ( tls: unknown, ) => void; // Does nothing for the handle of a cluster worker, which is no native listener. -const setListenerSecureContext: (handle: object, context: unknown) => void = $bindgen( - "Listener.bind.ts", - "setSecureContext", -); +const setListenerSecureContext = $bindgen("Listener.bind.ts", "setSecureContext") as ( + handle: object, + context: $Node.NativeSecureContext, +) => void; const firstFlightBeforeFin: (socket: $Node.SocketHandle | null) => void = $bindgen( "socket_body.bind.ts", "firstFlightBeforeFin", ); -const { throwNotImplemented } = require("internal/shared"); +const { throwNotImplemented, kInternalAssertionSuffix } = require("internal/shared"); const { idnaToASCII } = require("internal/url"); const { throwOnInvalidTLSArray, - tlsStringToProtocolVersion, - secureProtocolToVersionRange, + nativeCiphersAndVersions, normalizePemKeyOption, processPfxOptions, + readSecureContextOptions, stripTls13CipherNames, validateSecureContextOptions, SSL_OP_CIPHER_SERVER_PREFERENCE, + hasStreamMethods, } = require("internal/tls"); const { validateString, @@ -44,9 +45,11 @@ const { const { Server: NetServer, Socket: NetSocket } = net; const netSymbols = require("internal/net/symbols"); +const kAcceptedTLS: typeof netSymbols.kAcceptedTLS = netSymbols.kAcceptedTLS; const kArmHandshakeTimeout: typeof netSymbols.kArmHandshakeTimeout = netSymbols.kArmHandshakeTimeout; const kPreHandshakeWrite: typeof netSymbols.kPreHandshakeWrite = netSymbols.kPreHandshakeWrite; const kSecureConnectDone: typeof netSymbols.kSecureConnectDone = netSymbols.kSecureConnectDone; +const kSNIContexts: typeof netSymbols.kSNIContexts = netSymbols.kSNIContexts; const kStandaloneWrap: typeof netSymbols.kStandaloneWrap = netSymbols.kStandaloneWrap; const kUpgradeClientTLS: typeof netSymbols.kUpgradeClientTLS = netSymbols.kUpgradeClientTLS; const kVerifyError: typeof netSymbols.kVerifyError = netSymbols.kVerifyError; @@ -120,6 +123,7 @@ declare global { crl: TLSMaterial; allowPartialTrustChain: boolean | undefined; sessionTimeout: number | undefined; + ticketKeys: Uint8Array | undefined; sigalgs: string | null | undefined; ecdhCurve: string | undefined; passphrase: string | undefined; @@ -134,7 +138,7 @@ declare global { _rejectUnauthorized: boolean; _handshakeTimeout: number; _sharedCreds: SecureContext | undefined; - [kcontexts]: Map; + [kSNIContexts]: Map; addContext(hostname: string, context: SecureContext | TLSOptions): void; setSecureContext(options: SecureContext | TLSOptions | null | undefined): void; getTicketKeys(): never; @@ -170,6 +174,9 @@ const parseCACertificates: (certs: CACertInput[]) => string[] = $newCppFunction( const getTLSDefaultCiphers: () => string = $bindgen("rare_data.bind.ts", "getTLSDefaultCiphers"); const setTLSDefaultCiphers: (ciphers: string) => void = $bindgen("rare_data.bind.ts", "setTLSDefaultCiphers"); +const getTLSDefaultCA = $bindgen("node_tls_binding.bind.ts", "getDefaultCA"); +const setTLSDefaultCA = $bindgen("node_tls_binding.bind.ts", "setDefaultCA"); +const getDefaultCACertificateTypes = $bindgen("node_tls_binding.bind.ts", "getDefaultCACertificateTypes"); const selectServerName: ( contexts: Map, servername: string, @@ -271,60 +278,47 @@ function getCipherListSelectors() { return _CIPHER_LIST_SELECTORS; } -function validateCiphers(ciphers: string, name: string = "options") { - // Set the cipher list and cipher suite before anything else because - // @SECLEVEL= changes the security level and that affects subsequent - // operations. - if (ciphers !== undefined && ciphers !== null) { - validateString(ciphers, `${name}.ciphers`); - - // TODO: right now we need this because we dont create the CTX before listening/connecting - // we need to change that in the future and let BoringSSL do the validation - const ciphersSet = getValidCiphersSet(); - const requested = StringPrototypeSplit.$call(ciphers, ":"); - let sawLegacyEntry = false; - let sawUsableEntry = false; - for (const r of requested) { - if (!r) continue; - // BoringSSL has no security levels: its cipher parser rejects - // @SECLEVEL with INVALID_COMMAND. Report that the way the native - // parser would, with Node's decomposed error shape. - if (StringPrototypeIncludes.$call(r, "@SECLEVEL")) { - const err = new Error("error:0f000076:SSL routines:OPENSSL_internal:INVALID_COMMAND") as Error & { - code: string; - library: string; - function: string; - reason: string; - }; - err.code = "ERR_SSL_INVALID_COMMAND"; - err.library = "SSL routines"; - err.function = "OPENSSL_internal"; - err.reason = "INVALID_COMMAND"; - throw err; - } - if (StringPrototypeStartsWith.$call(r, "TLS_")) continue; - sawLegacyEntry = true; - // OpenSSL cipher-list grammar: `!X`/`-X`/`+X` operators, `A+B` - // intersections, `@STRENGTH` directives and selector keywords - // (HIGH, PSK, aNULL, ...) are not literal cipher names — leave their - // evaluation to BoringSSL and assume they can contribute matches. - const first = StringPrototypeCharCodeAt.$call(r, 0); - if ( - first === 0x21 /* ! */ || - first === 0x2d /* - */ || - first === 0x2b /* + */ || - first === 0x40 /* @ */ || - StringPrototypeIncludes.$call(r, "+") || - getCipherListSelectors().has(r) || - ciphersSet.has(r) - ) { - sawUsableEntry = true; - } +// tls.connect() and tls.createServer() report a list that matches nothing in OpenSSL's words. A bare context reports BoringSSL's. +function checkCipherMatch(ciphers: string) { + const ciphersSet = getValidCiphersSet(); + const requested = StringPrototypeSplit.$call(ciphers, ":"); + let sawLegacyEntry = false; + let sawUsableEntry = false; + for (const r of requested) { + if (!r) continue; + // BoringSSL has no security levels, and its parser rejects @SECLEVEL with INVALID_COMMAND. This is that error in Node's shape. + if (StringPrototypeIncludes.$call(r, "@SECLEVEL")) { + const err = new Error("error:0f000076:SSL routines:OPENSSL_internal:INVALID_COMMAND") as Error & { + code: string; + library: string; + function: string; + reason: string; + }; + err.code = "ERR_SSL_INVALID_COMMAND"; + err.library = "SSL routines"; + err.function = "OPENSSL_internal"; + err.reason = "INVALID_COMMAND"; + throw err; } - if (sawLegacyEntry && !sawUsableEntry) { - throw $ERR_SSL_NO_CIPHER_MATCH(); + if (StringPrototypeStartsWith.$call(r, "TLS_")) continue; + sawLegacyEntry = true; + // Operators (`!X`, `-X`, `+X`, `A+B`), `@STRENGTH` and selector keywords (HIGH, PSK, aNULL) aren't cipher names: BoringSSL evaluates them. + const first = StringPrototypeCharCodeAt.$call(r, 0); + if ( + first === 0x21 /* ! */ || + first === 0x2d /* - */ || + first === 0x2b /* + */ || + first === 0x40 /* @ */ || + StringPrototypeIncludes.$call(r, "+") || + getCipherListSelectors().has(r) || + ciphersSet.has(r) + ) { + sawUsableEntry = true; } } + if (sawLegacyEntry && !sawUsableEntry) { + throw $ERR_SSL_NO_CIPHER_MATCH(); + } } const SymbolReplace = Symbol.replace; @@ -368,12 +362,6 @@ function parseCertString() { throwNotImplemented("Not implemented"); } -// Node.js reads NODE_TLS_REJECT_UNAUTHORIZED lazily (per connection), so a -// script can set it after loading the module and still have it apply. -function rejectUnauthorizedDefault() { - return process.env.NODE_TLS_REJECT_UNAUTHORIZED !== "0"; -} - // Mirrors Node's getAllowUnauthorized(): warn (once) when certificate // verification is disabled by NODE_TLS_REJECT_UNAUTHORIZED=0. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/options.js#L204-L215 @@ -571,12 +559,6 @@ function checkServerIdentity(hostname: string, cert: $Node.PeerCertificate) { // that used to live in this file. const NativeSecureContext = $bindgen("SecureContext.bind.ts", "SecureContext") as $Node.NativeSecureContextConstructor; -// Node treats any falsy key/cert/ca as "not provided" (test-tls-options- -// boolean-check.js exercises false/0/""). The bindgen SSLConfigFile union only -// accepts null|string|ArrayBuffer|Blob|array, so coerce falsy → null before -// crossing into native so `{ key: false }` etc. doesn't throw -// ERR_INVALID_ARG_TYPE from the bindgen layer. - // SNI is a C string, so as in Node.js it ends at a NUL. checkServerIdentity() still gets the whole servername. function sniName(servername: string | undefined) { if (typeof servername !== "string") return servername; @@ -584,83 +566,31 @@ function sniName(servername: string | undefined) { return nul === -1 ? servername : StringPrototypeSlice.$call(servername, 0, nul); } -function newNativeSecureContext(options?: TLSOptions | null, cached = false) { +// `options` is a record of ours, never a caller's object, so it's normalized in place. +function newNativeSecureContext(options: TLSOptions, cached = false) { maybeWarnAboutExtraCACerts(); - // tls.createSecureContext() with no options still goes through the version - // translation below so the module-level DEFAULT_MIN/MAX_VERSION apply. - options = options == null ? {} : processPfxOptions(options); - const servername = options.servername; - if (sniName(servername) !== servername) options = { ...options, servername: sniName(servername) }; - // PKCS#12-embedded CAs extend the trust set after the context is built; a - // mutated context must not be the shared cached one. + options = processPfxOptions(options); + options.servername = sniName(options.servername); + // A bundle's CAs and the ticket keys change the context once it's built, so it can't be the shared cached one. const pfxExtraCAs = options._pfxExtraCACerts; - if (pfxExtraCAs) 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. - const ALPNProtocols = options.ALPNProtocols; - if (Array.isArray(ALPNProtocols)) { - const normalized: { ALPNProtocols?: Buffer } = {}; - convertALPNProtocols(ALPNProtocols, normalized); - options = { ...options, ALPNProtocols: normalized.ALPNProtocols }; - } - if (options) { - let { key, cert, ca } = options; - const normalizedKey = normalizePemKeyOption(key, options.passphrase); - if (normalizedKey !== key) { - key = normalizedKey; - options = { ...options, key }; - } - if (!key || !cert || !ca) { - options = { - ...options, - key: key || null, - cert: cert || null, - ca: ca || null, - }; - } - if (options.sessionTimeout == null) { - options = { ...options, sessionTimeout: 0 }; - } - if (options.ecdhCurve === undefined) { - options = { ...options, ecdhCurve: DEFAULT_ECDH_CURVE }; - } - const rejectUnauthorized = options.rejectUnauthorized; - if (rejectUnauthorized !== undefined && typeof rejectUnauthorized !== "boolean") { - options = { ...options, rejectUnauthorized: true }; - } - const allowPartialTrustChain = options.allowPartialTrustChain; - if (allowPartialTrustChain !== undefined && typeof allowPartialTrustChain !== "boolean") { - options = { ...options, allowPartialTrustChain: !!allowPartialTrustChain }; - } - if (options.honorCipherOrder) { - options = { ...options, secureOptions: options.secureOptions | 0 | SSL_OP_CIPHER_SERVER_PREFERENCE }; - } - } - if (options) { - // Read each option once. Translate minVersion/maxVersion/secureProtocol to - // the integer protocol range the native layer applies, so the bindings - // receive numbers, not the user-facing strings. When none are given the - // module-level tls.DEFAULT_MIN_VERSION / DEFAULT_MAX_VERSION apply, the - // way Node's createSecureContext does. - const { minVersion: optMinVersion, maxVersion: optMaxVersion, secureProtocol: optSecureProtocol } = options; - { - let minVersion, maxVersion; - const range = secureProtocolToVersionRange(optSecureProtocol); - if (range) { - minVersion = range[0]; - maxVersion = range[1]; - } else { - minVersion = tlsStringToProtocolVersion(optMinVersion ?? DEFAULT_MIN_VERSION); - maxVersion = tlsStringToProtocolVersion(optMaxVersion ?? DEFAULT_MAX_VERSION); - } - options = { ...options, minVersion, maxVersion }; - } - } + const ticketKeys = options.ticketKeys; + if (pfxExtraCAs || ticketKeys != null) cached = false; + // Node counts a falsy key, cert or ca as absent (test-tls-options-boolean-check.js). The native parser only takes null. + options.key = normalizePemKeyOption(options.key, options.passphrase) || null; + options.cert ||= null; + options.ca ||= null; + options.sessionTimeout ??= 0; + if (options.ecdhCurve === undefined) options.ecdhCurve = DEFAULT_ECDH_CURVE; + const rejectUnauthorized = options.rejectUnauthorized; + if (rejectUnauthorized !== undefined && typeof rejectUnauthorized !== "boolean") options.rejectUnauthorized = true; + if (options.allowPartialTrustChain !== undefined) options.allowPartialTrustChain = !!options.allowPartialTrustChain; + if (options.honorCipherOrder) options.secureOptions = options.secureOptions | 0 | SSL_OP_CIPHER_SERVER_PREFERENCE; + ObjectAssign(options, nativeCiphersAndVersions(options, DEFAULT_MIN_VERSION, DEFAULT_MAX_VERSION)); const ctx = (cached ? NativeSecureContext.intern : NativeSecureContext.createPrivate)(options); if (pfxExtraCAs) { for (const pem of pfxExtraCAs) ctx.addCACert(pem); } + if (ticketKeys != null) ctx.setTicketKeys(ticketKeys); return ctx; } @@ -668,44 +598,35 @@ var InternalSecureContext = class SecureContext { context: $Node.NativeSecureContext; servername: string | undefined; - constructor(options?: TLSOptions | null, cached = false) { - // When tls.setDefaultCACertificates() has installed an override and no - // explicit `ca` was given, use the override as the default CA set so the - // process-wide default applies on every construction path (the public - // createSecureContext(), the connect/TLSSocket path, addContext and - // setSecureContext), matching Node's secure-context default. - if (_defaultCACertificatesOverride !== undefined && (options == null || options.ca == null)) { - options = { ...options, ca: _defaultCACertificatesOverride }; - } - if (options) { - validateSecureContextOptions(options); - const cert = options.cert; - if (cert) throwOnInvalidTLSArray("options.cert", cert); - const key = options.key; - if (key) throwOnInvalidTLSArray("options.key", key); - const ca = options.ca; - if (ca) throwOnInvalidTLSArray("options.ca", ca); - const crl = options.crl; - if (crl) throwOnInvalidTLSArray("options.crl", crl); - if (options.servername != null && typeof options.servername !== "string") - throw new TypeError("servername argument must be an string"); - if (options.secureOptions != null && typeof options.secureOptions !== "number") - throw new TypeError("secureOptions argument must be an number"); - } - const requestedCiphers = options?.ciphers; - if (requestedCiphers && StringPrototypeIncludes.$call(requestedCiphers, "TLS_")) { - const { cipherList, tls13Only } = stripTls13CipherNames(requestedCiphers); - options = { ...options, ciphers: cipherList }; - if (tls13Only) options.minVersion = "TLSv1.3"; - } + constructor(options: TLSOptions, cached = false) { // The native handle (SSL_CTX wrapper) is what's memoised — not this JS // object — so per-call fields like `servername` come from THIS call's // options while the expensive SSL_CTX is shared. + this.servername = options.servername; this.context = newNativeSecureContext(options, cached); - this.servername = options?.servername; } }; +// Every check node:tls makes of a context's options. Each path to a context runs it once. +function validateContextOptions(options, matchCiphers) { + validateSecureContextOptions(options); + const { ciphers, cert, key, ca, crl, servername, secureOptions } = options; + if (matchCiphers && ciphers) checkCipherMatch(ciphers); + if (cert) throwOnInvalidTLSArray("options.cert", cert); + if (key) throwOnInvalidTLSArray("options.key", key); + if (ca) throwOnInvalidTLSArray("options.ca", ca); + if (crl) throwOnInvalidTLSArray("options.crl", crl); + if (servername != null) validateString(servername, "options.servername"); + if (secureOptions != null) validateNumber(secureOptions, "options.secureOptions"); +} + +// For options as a caller gave them. tls.Server reads and checks its own before it stages them. +function newSecureContext(options, cached, matchCiphers = false) { + options = readSecureContextOptions(options || {}); + validateContextOptions(options, matchCiphers); + return new InternalSecureContext(options, cached); +} + function SecureContext(options?: TLSOptions | null): void { return createSecureContext(options) as never; } @@ -714,21 +635,12 @@ InternalSecureContext.prototype.constructor = SecureContext; function createSecureContext(options?: TLSOptions | null) { if (options instanceof InternalSecureContext) return options; - // The setDefaultCACertificates() override is applied inside the - // InternalSecureContext constructor so every construction path honors it. // The native handle (SSL_CTX) is memoised inside `NativeSecureContext.intern` // by the per-VM `SSLContextCache`, so no JS-side hashing here. The JS wrapper // is built fresh because it carries the per-call `servername`. // The user-facing constructor owns its SSL_CTX exclusively so addCACert // cannot leak across contexts; a TLSSocket's own and addContext()'s stay cached. - return new InternalSecureContext(options); -} - -// Translate some fields from the handle's C-friendly format into more idiomatic -// javascript object representations before passing them back to the user. Can -// be used on any cert object, but changing the name would be semver-major. -function translatePeerCertificate(c) { - return c; + return newSecureContext(options, false); } const ksecureContext = Symbol("ksecureContext"); @@ -736,7 +648,6 @@ const kcheckServerIdentity = Symbol("kcheckServerIdentity"); const ksession = Symbol("ksession"); const kservername = Symbol("kservername"); const krenegotiationDisabled = Symbol("renegotiationDisabled"); -const kcontexts = Symbol("kcontexts"); const buntls: typeof netSymbols.bunTlsSymbol = CAN_REPLACE_BUILT_IN_FUNCTIONS ? (Symbol.for("::buntls::") as typeof netSymbols.bunTlsSymbol) @@ -751,7 +662,26 @@ const kUpgradeServerTLS: typeof netSymbols.kUpgradeServerTLS = CAN_REPLACE_BUILT ? (Symbol.for("::bunUpgradeServerTLS::") as typeof netSymbols.kUpgradeServerTLS) : netSymbols.kUpgradeServerTLS; +// `target` is a tls.Server or a server-side TLSSocket: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L929 +function takeServerCallbacks(target, options) { + const sniCallback = options.SNICallback; + if (sniCallback) { + validateFunction(sniCallback, "options.SNICallback"); + target._SNICallback = sniCallback; + } + const alpnCallback = options.ALPNCallback; + if (alpnCallback) { + validateFunction(alpnCallback, "options.ALPNCallback"); + // Node forbids combining the dynamic callback with a static list. + if (options.ALPNProtocols) { + throw $ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS(); + } + target._ALPNCallback = alpnCallback; + } +} + function TLSSocket(this: $Node.TLSSocket, socket?, options?) { + this[kAcceptedTLS] = null; this[ksecureContext] = undefined; this.ALPNProtocols = undefined; this[kcheckServerIdentity] = undefined; @@ -774,15 +704,14 @@ function TLSSocket(this: $Node.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) { @@ -790,19 +719,19 @@ function TLSSocket(this: $Node.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 @@ -819,47 +748,31 @@ function TLSSocket(this: $Node.TLSSocket, socket?, options?) { this.isServer = isServer; this._requestCert = !!options.requestCert || !isServer; - // A custom SNICallback must be a function — but Node only validates it on the - // server side (it is meaningless for a client), inside the isServer branch. - // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/wrap.js#L929 - if (isServer) { - const sniCallback = options.SNICallback; - if (sniCallback != null) { - validateFunction(sniCallback, "options.SNICallback"); - this._SNICallback = sniCallback; - } - const alpnCallback = options.ALPNCallback; - if (alpnCallback != null) { - validateFunction(alpnCallback, "options.ALPNCallback"); - if (options.ALPNProtocols) { - throw $ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS(); - } - this._ALPNCallback = alpnCallback; - } - } - - this.ciphers = options.ciphers; - if (this.ciphers) { - validateCiphers(options.ciphers); - } + // They mean nothing to a client, so Node doesn't look at a client's. + if (isServer) takeServerCallbacks(this, options); - if (typeof options === "object") { - const { ALPNProtocols } = options; - if (ALPNProtocols) { - convertALPNProtocols(ALPNProtocols, this); + const { ALPNProtocols, secureContext } = options; + if (ALPNProtocols) { + convertALPNProtocols(ALPNProtocols, this); + // What convertALPNProtocols() leaves alone, Node's setALPNProtocols() refuses. A string or an ArrayBuffer is ignored, as on 1.4.4. + if (this.ALPNProtocols === undefined && typeof ALPNProtocols !== "string" && !isAnyArrayBuffer(ALPNProtocols)) { + throw new TypeError("Must give a Buffer as first argument"); } } - // Internal path: keep the per-digest cache (the user-facing constructors, - // createSecureContext() and new tls.SecureContext(), own theirs exclusively). - const secureContext = options.secureContext; - if (secureContext && !(secureContext.context instanceof NativeSecureContext)) { - throw $ERR_TLS_INVALID_CONTEXT("context must be a SecureContext"); + if (secureContext) { + if (!(secureContext.context instanceof NativeSecureContext)) { + throw $ERR_TLS_INVALID_CONTEXT("context must be a SecureContext"); + } + // Node doesn't look at `ciphers` next to a context. 1.4.4 checked it. + const ciphers = options.ciphers; + if (ciphers) { + validateString(ciphers, "options.ciphers"); + checkCipherMatch(ciphers); + } } - this[ksecureContext] = secureContext || new InternalSecureContext(options, true); - this.authorized = false; + // Cached by digest. createSecureContext() and new tls.SecureContext() own theirs. + this[ksecureContext] = secureContext || newSecureContext(options, true, true); this.secureConnecting = true; - this._secureEstablished = false; - this._securePending = true; const checkServerIdentityOption = options.checkServerIdentity; if (checkServerIdentityOption !== undefined) { validateFunction(checkServerIdentityOption, "options.checkServerIdentity"); @@ -870,7 +783,7 @@ function TLSSocket(this: $Node.TLSSocket, socket?, options?) { // Both upgrades live in net.ts (module-private state); _handle stays unset until one hands back the TLS handle. if (isNetSocketOrDuplex) { if (isServer) { - this[kUpgradeServerTLS](socket as unknown as $Node.Socket, this[buntls](null, null)); + this[kUpgradeServerTLS](socket as unknown as $Node.Socket, this[buntls](null)); } else { // The rule of tls.connect(): an untrusted certificate is rejected unless the caller passes `false`. this._rejectUnauthorized = ObjectPrototypeHasOwnProperty.$call(options, "rejectUnauthorized") @@ -1059,8 +972,9 @@ TLSSocket.prototype.setKeyCert = function setKeyCert(this: $Node.TLSSocket, cont // Serve this connection's identity from the given context (Node calls this // 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); + const ctx = context?.context ? context : newSecureContext(context, true); + // 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( @@ -1115,23 +1029,15 @@ TLSSocket.prototype.getPeerCertificate = function getPeerCertificate(this: $Node // The native parameter means "abbreviated" - the inverse of Node's // `detailed`. Detailed requests get the whole chain with // issuerCertificate links; everything else gets just the leaf. - const cert = arguments.length < 1 ? handle.getPeerCertificate?.() : handle.getPeerCertificate?.(!detailed); - if (cert) { - return translatePeerCertificate(cert); - } - return {}; + return (arguments.length < 1 ? handle.getPeerCertificate?.() : handle.getPeerCertificate?.(!detailed)) || {}; } return null; }; TLSSocket.prototype.getCertificate = function getCertificate(this: $Node.TLSSocket) { - if (!this._handle) return null; - const cert = this._handle.getCertificate?.(); - if (cert) { - // It's not a peer cert, but the formatting is identical. - return translatePeerCertificate(cert); - } - return {}; + const handle = this._handle; + if (!handle) return null; + return handle.getCertificate?.() || {}; }; TLSSocket.prototype.getPeerX509Certificate = function getPeerX509Certificate(this: $Node.TLSSocket) { @@ -1172,13 +1078,13 @@ TLSSocket.prototype.getX509Certificate = function getX509Certificate(this: $Node return this._handle?.getX509Certificate?.(); }; -TLSSocket.prototype[buntls] = function (this: $Node.TLSSocket, port: unknown, host: string | null | undefined) { +TLSSocket.prototype[buntls] = function (host: string | null | undefined) { const ctx = this[ksecureContext]; // RFC 6066 forbids IP literals in SNI. Match Node.js: only default servername to 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 { @@ -1188,7 +1094,6 @@ TLSSocket.prototype[buntls] = function (this: $Node.TLSSocket, port: unknown, ho session: this[ksession], rejectUnauthorized: this._rejectUnauthorized, requestCert: this._requestCert, - ciphers: this.ciphers && stripTls13CipherNames(this.ciphers).cipherList, // Hand the native SSL_CTX wrapper to upgradeTLS so it can up_ref instead // of rebuilding from raw cert/key bytes. secureContext: ctx?.context, @@ -1197,39 +1102,9 @@ TLSSocket.prototype[buntls] = function (this: $Node.TLSSocket, port: unknown, ho }; }; -let CLIENT_RENEG_LIMIT = 3, - CLIENT_RENEG_WINDOW = 600; - -// Not interned: one SSL_CTX is one session cache and one set of ticket keys, and no other server or client may share them. -// `server`: a tls.Server, or an object that inherits from one. -function buildSharedCreds(server, options: TLSOptions | null | undefined) { - return new InternalSecureContext({ - ...options, - pfx: undefined, - _pfxExtraCACerts: undefined, - key: server.key, - cert: server.cert, - ca: server.ca, - crl: server.crl, - ciphers: server.ciphers, - secureOptions: server.secureOptions, - allowPartialTrustChain: server.allowPartialTrustChain, - sessionTimeout: server.sessionTimeout, - sigalgs: server.sigalgs, - ecdhCurve: server.ecdhCurve ?? DEFAULT_ECDH_CURVE, - passphrase: server.passphrase, - secureProtocol: server.secureProtocol, - minVersion: server.minVersion, - maxVersion: server.maxVersion, - // The verify mode accepted sockets inherit. setSecureContext() does not name them. - requestCert: server._requestCert === true, - rejectUnauthorized: server._rejectUnauthorized, - }); -} - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1599-L1611 function SNICallback(this: $Node.TLSSocket, servername: string, callback: (err: unknown, context?: unknown) => void) { - callback(null, selectServerName((this.server as $Node.TLSServer)[kcontexts], servername)); + callback(null, selectServerName((this.server as $Node.TLSServer)[kSNIContexts], servername)); } function Server(this: $Node.TLSServer, options?, secureConnectionListener?): void { @@ -1245,24 +1120,7 @@ function Server(this: $Node.TLSServer, options?, secureConnectionListener?): voi } else if (options != null && typeof options !== "object") { throw $ERR_INVALID_ARG_TYPE("options", "object", options); } - // A custom SNICallback must be a function. - // 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) { - validateFunction(sniCallback, "options.SNICallback"); - this._SNICallback = sniCallback; - } - const alpnCallback = options.ALPNCallback; - if (alpnCallback != null) { - validateFunction(alpnCallback, "options.ALPNCallback"); - // Node forbids combining the dynamic callback with a static list. - if (options.ALPNProtocols) { - throw $ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS(); - } - this._ALPNCallback = alpnCallback; - } - } + if (options != null) takeServerCallbacks(this, options); // The listener belongs on "secureConnection", not "connection": do not let // the net.Server constructor register it. @@ -1274,6 +1132,7 @@ function Server(this: $Node.TLSServer, options?, secureConnectionListener?): voi this.crl = undefined; this.allowPartialTrustChain = undefined; this.sessionTimeout = undefined; + this.ticketKeys = undefined; this.sigalgs = undefined; this.ecdhCurve = undefined; this.passphrase = undefined; @@ -1293,7 +1152,7 @@ function Server(this: $Node.TLSServer, options?, secureConnectionListener?): voi 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 (this: $Node.TLSServer, hostname, context) { // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1571-L1574 @@ -1304,7 +1163,7 @@ function Server(this: $Node.TLSServer, options?, secureConnectionListener?): voi throw new TypeError("hostname must be a string"); } if (!(context instanceof InternalSecureContext)) { - context = new InternalSecureContext(context, true); + context = newSecureContext(context, true); } const handle = this._handle; if (handle) { @@ -1318,192 +1177,98 @@ function Server(this: $Node.TLSServer, options?, secureConnectionListener?): voi }; let listenerContext: $Node.NativeSecureContext | undefined; - - this.setSecureContext = function (this: $Node.TLSServer, options: TLSOptions | null | undefined) { - const serverTLSOptions = options; - const next: Record = { __proto__: null }; - if (options instanceof InternalSecureContext) { - 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, - }; - } - validateSecureContextOptions(options); - options = processPfxOptions(options); - - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1431-L1474: a falsy key, cert, ca or crl counts as absent - let cert = options.cert || undefined; - // Assign unconditionally so a later setSecureContext() that omits an - // option clears the previous call's value (Node resets each omitted - // field) instead of silently keeping stale key material. - if (cert) { - throwOnInvalidTLSArray("options.cert", cert); - } - next.cert = cert; - - let key = options.key || undefined; - if (key) { - throwOnInvalidTLSArray("options.key", key); - } - next.key = key; - - let ca = options.ca || undefined; - // InternalSecureContext applies tls.setDefaultCACertificates() itself; a named pipe listener builds from this field. - if (_defaultCACertificatesOverride !== undefined && ca == null) { - ca = _defaultCACertificatesOverride; - } - // buildSharedCreds() drops them, so they go into `ca`: an mTLS server verifies clients against the bundle's own CA chain. - const pfxExtraCAs = options._pfxExtraCACerts; + // What the last setSecureContext() that named options built its context from. + let contextOptions: Record = { __proto__: null }; + + this.setSecureContext = function (options) { + const isContext = options instanceof InternalSecureContext; + // An instance names no key, as in Node. + let given = isContext ? {} : options; + let staged = contextOptions; + if (given) { + given = readSecureContextOptions(given); + // A falsy value counts as absent: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1423-L1518 + given.pfx ||= undefined; + given.key ||= undefined; + given.passphrase ||= undefined; + given.cert ||= undefined; + given.clientCertEngine ||= undefined; + given.ca ||= undefined; + given.minVersion ||= undefined; + given.maxVersion ||= undefined; + given.secureProtocol ||= undefined; + given.crl ||= undefined; + given.ciphers ||= undefined; + given.secureOptions ||= undefined; + given.sessionTimeout ||= undefined; + given.ticketKeys ||= undefined; + given.servername ||= undefined; + validateContextOptions(given, true); + given = processPfxOptions(given); + + let ca = given.ca; + // An mTLS server verifies clients against the bundle's own CA chain, so it goes into `ca`. + const pfxExtraCAs = given._pfxExtraCACerts; if (pfxExtraCAs?.length) { + // They extend tls.setDefaultCACertificates()'s list, which a `ca` of only them would replace. + ca ??= getTLSDefaultCA(); ca = ca == null ? pfxExtraCAs : Array.isArray(ca) ? [...ca, ...pfxExtraCAs] : [ca, ...pfxExtraCAs]; } - if (ca) { - throwOnInvalidTLSArray("options.ca", ca); - } - next.ca = ca; - - const crl = options.crl || undefined; - if (crl) { - throwOnInvalidTLSArray("options.crl", crl); - } - next.crl = crl; - - next.allowPartialTrustChain = !!options.allowPartialTrustChain; - - next.sessionTimeout = options.sessionTimeout; - - const sigalgs = options.sigalgs; - if (sigalgs !== undefined && sigalgs !== null) { - validateString(sigalgs, "options.sigalgs"); - if (sigalgs === "") throw $ERR_INVALID_ARG_VALUE("options.sigalgs", sigalgs); - } - next.sigalgs = sigalgs; - - const ecdhCurve = options.ecdhCurve; - if (ecdhCurve !== undefined) validateString(ecdhCurve, "options.ecdhCurve"); - next.ecdhCurve = ecdhCurve; - - let passphrase = options.passphrase; - if (passphrase && typeof passphrase !== "string") { - throw $ERR_INVALID_ARG_TYPE("options.passphrase", "string", passphrase); - } - next.passphrase = passphrase; - - let servername = options.servername; - if (servername && typeof servername !== "string") { - throw $ERR_INVALID_ARG_TYPE("options.servername", "string", servername); - } - next.servername = servername; - - let secureOptions = options.secureOptions || 0; - if (secureOptions && typeof secureOptions !== "number") { - throw $ERR_INVALID_ARG_TYPE("options.secureOptions", "number", secureOptions); - } - if (options.honorCipherOrder !== false) secureOptions |= SSL_OP_CIPHER_SERVER_PREFERENCE; - next.secureOptions = secureOptions; - - const ciphers = options.ciphers; - if (typeof ciphers !== "undefined") { - if (typeof ciphers !== "string") { - throw $ERR_INVALID_ARG_TYPE("options.ciphers", "string", ciphers); - } - - validateCiphers(ciphers); - } - // Unconditional so an omitted `ciphers` clears the previous value. - next.ciphers = options.ciphers; - - // Pin the protocol version range the server will negotiate. - // validateSecureContextOptions already rejected unknown method names. - // Assign unconditionally so a later setSecureContext() without these - // options clears the previous call's version constraints instead of - // re-applying them to the next context built. - next.secureProtocol = options.secureProtocol; - next.minVersion = options.minVersion; - next.maxVersion = options.maxVersion; + let secureOptions = given.secureOptions || 0; + if (given.honorCipherOrder !== false) secureOptions |= SSL_OP_CIPHER_SERVER_PREFERENCE; + + // Names every field, so an option this call omits clears the previous call's value, as in Node. + staged = { + __proto__: null, + key: given.key, + cert: given.cert, + ca, + crl: given.crl, + allowPartialTrustChain: !!given.allowPartialTrustChain, + sessionTimeout: given.sessionTimeout, + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1482-L1483: omitting it keeps the previous keys + ticketKeys: given.ticketKeys || this.ticketKeys, + sigalgs: given.sigalgs, + ecdhCurve: given.ecdhCurve, + passphrase: given.passphrase, + servername: given.servername, + secureOptions, + ciphers: given.ciphers, + secureProtocol: given.secureProtocol, + minVersion: given.minVersion, + maxVersion: given.maxVersion, + }; } - 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); - // 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. + // Not interned: no other server or client may share its session cache and ticket keys. Throws on material BoringSSL rejects, before any field changes. + const own = new InternalSecureContext({ + ...given, + pfx: undefined, + _pfxExtraCACerts: undefined, + ...staged, + // The verify mode accepted sockets inherit. setSecureContext() does not name them. + requestCert: this._requestCert === true, + rejectUnauthorized: this._rejectUnauthorized, + }); + // Accepted sockets take their verify mode from the context, so the listener's is always this server's own. listenerContext = own.context; - const handle = this._handle; - if (handle && options) { - setListenerSecureContext(handle, listenerContext); - } - if (options) { - this.cert = next.cert; - this.key = next.key; - this.ca = next.ca; - this.crl = next.crl; - this.allowPartialTrustChain = next.allowPartialTrustChain; - this.sessionTimeout = next.sessionTimeout; - this.sigalgs = next.sigalgs; - this.ecdhCurve = next.ecdhCurve; - this.passphrase = next.passphrase; - this.servername = next.servername; - this.secureOptions = next.secureOptions; - this.ciphers = next.ciphers; - this.secureProtocol = next.secureProtocol; - this.minVersion = next.minVersion; - this.maxVersion = next.maxVersion; + if (given) { + const handle = this._handle; + if (handle) setListenerSecureContext(handle, listenerContext); + contextOptions = staged; + ObjectAssign(this, staged); } - this._sharedCreds = isContext ? serverTLSOptions : own; + this._sharedCreds = isContext ? options : own; }; - this[buntls] = function (this: $Node.TLSServer, port, host, isClient) { - const requestCert = isClient ? true : this._requestCert; + this[buntls] = function (host) { return [ { serverName: sniName(this.servername || host || "localhost"), - // What a socket listener serves. Only a named pipe's still builds its own from the fields below. secureContext: listenerContext, - key: normalizePemKeyOption(this.key, this.passphrase), - cert: this.cert, - ca: this.ca, - crl: this.crl, - allowPartialTrustChain: this.allowPartialTrustChain, - sessionTimeout: this.sessionTimeout ?? 0, - sigalgs: this.sigalgs, - ecdhCurve: this.ecdhCurve ?? DEFAULT_ECDH_CURVE, - passphrase: this.passphrase, - secureOptions: this.secureOptions, rejectUnauthorized: this._rejectUnauthorized, - requestCert, + requestCert: this._requestCert, ALPNProtocols: this.ALPNProtocols, - clientRenegotiationLimit: CLIENT_RENEG_LIMIT, - clientRenegotiationWindow: CLIENT_RENEG_WINDOW, - contexts: contexts, - // Translate minVersion/maxVersion/secureProtocol to the integer - // protocol range the native layer applies (secureProtocol wins, like - // Node's SecureContext::Init). When none are given the module-level - // tls.DEFAULT_MIN_VERSION / DEFAULT_MAX_VERSION apply. - ...(() => { - const processed = this.ciphers && stripTls13CipherNames(this.ciphers); - let minVersion, maxVersion; - const range = secureProtocolToVersionRange(this.secureProtocol); - if (range) { - minVersion = range[0]; - maxVersion = range[1]; - } else { - const min = processed && processed.tls13Only ? "TLSv1.3" : (this.minVersion ?? DEFAULT_MIN_VERSION); - minVersion = tlsStringToProtocolVersion(min); - maxVersion = tlsStringToProtocolVersion(this.maxVersion ?? DEFAULT_MAX_VERSION); - } - return { ciphers: processed && processed.cipherList, minVersion, maxVersion }; - })(), + contexts, }, TLSSocket as unknown as $Node.SocketConstructor, ]; @@ -1518,12 +1283,12 @@ function Server(this: $Node.TLSServer, options?, secureConnectionListener?): voi 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, @@ -1553,18 +1318,18 @@ $toClass(Server, "Server", NetServer); // wrapper and the unwrapped native context). Server.prototype[kNativeSecureContextCtor] = NativeSecureContext; -Server.prototype.getTicketKeys = function (this: $Node.TLSServer) { - throw Error("Not implented in Bun yet"); +Server.prototype.getTicketKeys = function getTicketKeys(this: $Node.TLSServer) { + return this._sharedCreds!.context.getTicketKeys(); }; -Server.prototype.setTicketKeys = function (this: $Node.TLSServer, keys: ArrayBufferView) { - if (!ArrayBuffer.isView(keys)) { +Server.prototype.setTicketKeys = function setTicketKeys(this: $Node.TLSServer, keys: ArrayBufferView) { + 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( @@ -1616,20 +1381,23 @@ function normalizeConnectArgs(listArgs: unknown[]) { // tls.connect(port[, host][, options][, callback]) function connect(...args: unknown[]) { let normal = normalizeConnectArgs(args); - const options = normal[0]; - const { ALPNProtocols, servername } = options as { ALPNProtocols?: unknown; servername?: string }; - - // The TLSSocket applies the NODE_TLS_REJECT_UNAUTHORIZED default itself - // (rejectUnauthorizedDefault); this call exists to emit Node's one-time - // process warning when the env var disables verification, like Node's - // connect() does: - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1730 - getAllowUnauthorized(); - - if (ObjectPrototypeHasOwnProperty.$call(options, "checkServerIdentity")) { - // Node validates whenever the key is present - an explicit `undefined` - // throws ERR_INVALID_ARG_TYPE (test-tls-basic-validations). - validateFunction(options.checkServerIdentity, "options.checkServerIdentity"); + const options: (typeof normal)[0] & { minDHSize?: unknown; ALPNProtocols?: unknown } = normal[0]; + const servername = options.servername as string | undefined; + + // Read per connection, and it warns even if `rejectUnauthorized` overrides it: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1730 + const allowUnauthorized = getAllowUnauthorized(); + + // Node rejects an own key that is `undefined` (test-tls-basic-validations). The TLSSocket constructor checks every other value. + if ( + options.checkServerIdentity === undefined && + ObjectPrototypeHasOwnProperty.$call(options, "checkServerIdentity") + ) { + validateFunction(undefined, "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)) { @@ -1640,19 +1408,26 @@ function connect(...args: unknown[]) { ); } - const hasOwnRejectUnauthorized = ObjectPrototypeHasOwnProperty.$call(options, "rejectUnauthorized"); - const rejectUnauthorized = hasOwnRejectUnauthorized + const rejectUnauthorized = ObjectPrototypeHasOwnProperty.$call(options, "rejectUnauthorized") ? options.rejectUnauthorized !== false - : rejectUnauthorizedDefault(); + : !allowUnauthorized; - const connectOptions: Record = { checkServerIdentity, ...options, rejectUnauthorized }; + const connectOptions: Record = { + checkServerIdentity, + ...options, + // Node's spread would drop an inherited one, which 1.4.4 took. + ALPNProtocols: options.ALPNProtocols, + rejectUnauthorized, + }; if (!ObjectPrototypeHasOwnProperty.$call(options, "ciphers") || connectOptions.ciphers == null) { connectOptions.ciphers = getDefaultCiphers(); } normal[0] = connectOptions; - if (ALPNProtocols) { - 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: $Node.TLSSocket = new TLSSocket(connectOptions); @@ -1660,7 +1435,6 @@ function connect(...args: unknown[]) { // to handle: node releases control before the connection is even started. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1798 tlssock._releaseControl(); - tlssock._rejectUnauthorized = rejectUnauthorized; // Honor the `timeout` option here: Socket.prototype.connect does not (only // the net.createConnection factory does), so tls.connect applies it // explicitly, exactly like Node's tls connect. @@ -1737,35 +1511,17 @@ function cacheBundledRootCertificates(): string[] { bundledRootCertificates ||= getBundledRootCertificates() as string[]; return bundledRootCertificates; } -const getCAStore: () => "bundled" | "system" | "openssl" = $bindgen("node_tls_binding.bind.ts", "getCAStore"); let defaultCACertificates: string[] | undefined; function cacheDefaultCACertificates() { if (defaultCACertificates) return defaultCACertificates; defaultCACertificates = []; - // As in Node, what OpenSSL's default certificate file and directory hold is not listed. - // https://github.com/nodejs/node/blob/v26.3.0/lib/tls.js#L157 - const store = getCAStore(); - if (store !== "openssl") { - 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 (store === "system" || process.env.NODE_USE_SYSTEM_CA === "1") { - const system = cacheSystemCACertificates(); - for (let i = 0; i < system.length; ++i) { - ArrayPrototypePush.$call(defaultCACertificates, system[i]); - } - } - } - - if (process.env.NODE_EXTRA_CA_CERTS) { - const extra = cacheExtraCACertificates(); - for (let i = 0; i < extra.length; ++i) { - ArrayPrototypePush.$call(defaultCACertificates, extra[i]); + const types = getDefaultCACertificateTypes(); + for (let i = 0; i < types.length; ++i) { + const certs = getCACertificates(types[i]); + for (let j = 0; j < certs.length; ++j) { + ArrayPrototypePush.$call(defaultCACertificates, certs[j]); } } @@ -1785,22 +1541,13 @@ function cacheExtraCACertificates(): string[] { return extraCACertificates; } -// Runtime override for the "default" CA certificate set, installed by -// tls.setDefaultCACertificates(). undefined = no override (use the real -// bundled/system default). Only affects type "default"/implicit — "bundled", -// "system" and "extra" are unchanged. -// https://github.com/nodejs/node/blob/main/lib/internal/tls/secure-context.js -let _defaultCACertificatesOverride: Array | undefined; - type CACertInput = string | NodeJS.ArrayBufferView; // tls.setDefaultCACertificates(certs) // https://github.com/nodejs/node/blob/v25.2.1/lib/tls.js#L202 // Node validates `certs` as an Array (its ERR_INVALID_ARG_TYPE renders the // 'Array' name as "an instance of Array"; Bun's validateArray renders the same // 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. +// then hands the certs to the native root store. function setDefaultCACertificates(certs: ReadonlyArray): void { if (!$isArray(certs)) { let received: string; @@ -1826,7 +1573,7 @@ function setDefaultCACertificates(certs: ReadonlyArray): void { if (normalized.length === 0 && snapshot.length > 0) { throw $ERR_CRYPTO_OPERATION_FAILED("No valid certificates found in the provided array"); } - _defaultCACertificatesOverride = normalized; + setTLSDefaultCA(normalized); } function getCACertificates(type = "default") { @@ -1834,10 +1581,7 @@ function getCACertificates(type = "default") { switch (type) { case "default": - if (_defaultCACertificatesOverride !== undefined) { - return _defaultCACertificatesOverride.slice(); - } - return cacheDefaultCACertificates(); + return getTLSDefaultCA() ?? cacheDefaultCACertificates(); case "bundled": return cacheBundledRootCertificates(); case "system": @@ -1878,8 +1622,8 @@ interface TLSModule { } const tlsExports: TLSModule = { - CLIENT_RENEG_LIMIT, - CLIENT_RENEG_WINDOW, + CLIENT_RENEG_LIMIT: 3, + CLIENT_RENEG_WINDOW: 600, connect, convertALPNProtocols, createSecureContext, @@ -1889,7 +1633,8 @@ const tlsExports: TLSModule = { }, set DEFAULT_CIPHERS(value) { if (value) { - validateCiphers(value, "value"); + validateString(value, "value.ciphers"); + checkCipherMatch(value); // filter out TLS_ ciphers value = stripTls13CipherNames(value).cipherList; // Throws ERR_SSL_NO_CIPHER_MATCH for a list such as "!aNULL": every default context is built from what is stored. diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index c3b1c99164f8..f025516c4a5a 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -9,7 +9,6 @@ type TODO = any; declare module "bun" { interface Socket { $write(data: string | BufferSource, encoding?: string): boolean | number; - $end(data?: string | BufferSource, encoding?: string): boolean; } } @@ -643,9 +642,10 @@ declare function $ERR_HTTP2_STATUS_INVALID(code: number): RangeError; declare function $ERR_HTTP2_INVALID_PSEUDOHEADER(name: string): TypeError; declare function $ERR_HTTP2_STREAM_ERROR(code): Error; declare function $ERR_HTTP2_SESSION_ERROR(code): Error; -declare function $ERR_HTTP2_TOO_MANY_ORIGINS(max: number): 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; @@ -734,6 +734,7 @@ declare function $ERR_HTTP2_OUT_OF_STREAMS(): Error; declare function $ERR_HTTP_BODY_NOT_ALLOWED(): Error; declare function $ERR_HTTP_TRAILER_INVALID(): Error; declare function $ERR_HTTP_SOCKET_ASSIGNED(): Error; +declare function $ERR_HTTP_SOCKET_ENCODING(): Error; declare function $ERR_DIR_CLOSED(): Error; declare function $ERR_SOCKET_CONNECTION_TIMEOUT(): Error; declare function $ERR_TLS_HANDSHAKE_TIMEOUT(): Error; diff --git a/src/js/builtins/BunBuiltinNames.h b/src/js/builtins/BunBuiltinNames.h index f3c639645bca..acccc6c8481e 100644 --- a/src/js/builtins/BunBuiltinNames.h +++ b/src/js/builtins/BunBuiltinNames.h @@ -39,7 +39,6 @@ using namespace JSC; macro(_events) \ macro(abort) \ macro(addAbortAlgorithmToSignal) \ - macro(armedTimeout) \ macro(arrayBuffer) \ macro(asUint8Array) \ macro(asymmetricMatch) \ @@ -65,6 +64,7 @@ using namespace JSC; macro(ctimeMs) \ macro(data) \ macro(decode) \ + macro(defaultTimeout) \ macro(dest) \ macro(detail) \ macro(dirname) \ @@ -95,9 +95,7 @@ using namespace JSC; macro(globScan) \ macro(globScanSync) \ macro(handleEvent) \ - macro(hasWaitingResponses) \ macro(headers) \ - macro(heldChunk) \ macro(highWaterMark) \ macro(host) \ macro(hostDefinedImportType) \ @@ -153,14 +151,12 @@ using namespace JSC; macro(originalLine) \ macro(overridableRequire) \ macro(ownReadableState) \ - macro(ownWritableState) \ macro(parse) \ macro(partitioned) \ macro(path) \ macro(paths) \ macro(peekPromiseSettledValue) \ macro(peekPromiseStatus) \ - macro(pipelinedResponses) \ macro(pokePromiseAsHandled) \ macro(port) \ macro(prev) \ @@ -229,15 +225,10 @@ using namespace JSC; // Among the names above: those that only src/js reads and writes, never native code. A property under one of them is an // ordinary property that nothing lists: a Proxy reads and writes it on its target, without a trap. Keep this list sorted. #define BUN_MODULE_PRIVATE_IDENTIFIERS_EACH_PROPERTY_NAME(macro) \ - macro(armedTimeout) \ macro(exchange) \ - macro(hasWaitingResponses) \ - macro(heldChunk) \ macro(isOpen) \ macro(isPrepared) \ macro(ownReadableState) \ - macro(ownWritableState) \ - macro(pipelinedResponses) \ macro(requestListener) \ macro(resumeUnlessFlowing) diff --git a/src/js/builtins/Ipc.ts b/src/js/builtins/Ipc.ts index f89184ed08eb..cc95bc15f2c0 100644 --- a/src/js/builtins/Ipc.ts +++ b/src/js/builtins/Ipc.ts @@ -23,6 +23,8 @@ interface IpcSendOptions { export function serialize(message: unknown, handle: unknown, options: unknown) { const net = require("node:net"); if (handle instanceof net.Server) { + // The handle of an https.Server is none that a net.Server can adopt. + if (handle[require("internal/http").kRealListen]) throw $ERR_INVALID_HANDLE_TYPE(); const native = handle._handle; if (!native) return null; return [native, { cmd: "NODE_HANDLE", msg: message, type: "net.Server" }]; diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 9478bae6d90b..999f1fd96bb1 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -136,9 +136,9 @@ export function getStdinStream( // listeners, resume(), ref(), an explicit read()) may take the reader back. if (stream_reachedEof) return; $debug("ref();", reader ? "already has reader" : "getting reader"); + // Attaching a reader also undoes the pause that disown() left on the source. reader ??= native.getReader(); source.updateRef(forceUnref ? false : true); - source?.setFlowing?.(true); if (needsInternalReadRefresh) { needsInternalReadRefresh = false; @@ -146,18 +146,24 @@ export function getStdinStream( } } - function disown() { + // Asked when pause() or destroy() is called, because disown() runs a tick later. + const isHeldByAnotherReader = () => !reader && native.locked; + + function disown(wasHeldByAnotherReader = false) { $debug("unref();"); - source?.setFlowing?.(false); + // Only a reader of Bun.stdin.stream() that came after the call keeps the source. Nothing else stops it. + if (reader || wasHeldByAnotherReader || !native.locked) source?.setFlowing?.(false); + if (wasHeldByAnotherReader) source?.updateRef?.(false); if (reader) { // releaseLock() rejects any in-flight internalRead() with a TypeError; that // rejection is handled there by observing that `reader` was cleared here. reader.releaseLock(); reader = undefined; $debug("released reader"); + // A paused source keeps nothing alive; its ref is for the next reader of Bun.stdin.stream(). + source?.updateRef?.(!forceUnref); } - source?.updateRef?.(false); } const ReadStream = isTTY ? require("node:tty").ReadStream : require("node:fs").ReadStream; @@ -301,11 +307,12 @@ export function getStdinStream( stream._readableState.reading = false; stream.on("pause", () => { + const wasHeldByAnotherReader = isHeldByAnotherReader(); process.nextTick(() => { // Only disown if the stream is still paused (not resumed in the meantime) if (!stream.readableFlowing) { stream._readableState.reading = false; - disown(); + disown(wasHeldByAnotherReader); } }); }); @@ -321,12 +328,23 @@ export function getStdinStream( } }); + // 'close' comes a tick or more after the call, and so does _destroy() while the stream isn't constructed yet. + let wasHeldWhenDestroyed: boolean | undefined; + const originalDestroy = stream.destroy; + stream.destroy = function (err, callback) { + if (!this.destroyed) wasHeldWhenDestroyed = isHeldByAnotherReader(); + return originalDestroy.$call(this, err, callback); + }; + stream.on("close", () => { if (!stream_destroyed) { stream_destroyed = true; + // Readable.prototype.destroy.call(process.stdin) doesn't pass through the function above. + const wasHeldByAnotherReader = wasHeldWhenDestroyed ?? isHeldByAnotherReader(); + wasHeldWhenDestroyed = undefined; process.nextTick(() => { stream.destroy(); - disown(); + disown(wasHeldByAnotherReader); }); } }); diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index ea65519dcbe2..3212b700f972 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -796,6 +796,9 @@ export type SocketFaultRule = { | "EINVAL" | "ENETUNREACH" | "EHOSTUNREACH" + | "EPROTOTYPE" + | "EMFILE" + | "EPERM" | number; /** clamp recv/send length to this many bytes; required and > 0 when action === "short" */ bytes?: number; @@ -816,13 +819,17 @@ export const socketFaultInjection = { clear: $bindgen("socket_body.bind.ts", "clearSocketFaults"), }; +/** Runs the uSockets timeout sweep of this thread's loop `count` times. The loop runs one every 4 seconds. */ +export const sweepSocketTimers = $bindgen("socket_body.bind.ts", "sweepSocketTimers"); +export const fakeLibraryTimers = $bindgen("FakeTimers.bind.ts", "fakeLibraryTimers"); + export const namedPipeInternals = { /** * Live native contexts behind sockets over Windows named pipes: one per * connecting or connected `Bun.connect({ unix: "\\\\.\\pipe\\..." })` socket and * one per accepted pipe client. Always 0 on other platforms. */ - liveCount: $bindgen("socket_body.bind.ts", "namedPipeContextLiveCount"), + liveCount: $bindgen("socket_body.bind.ts", "namedPipeLiveCount"), }; export const duplexTLSInternals = { @@ -831,7 +838,7 @@ export const duplexTLSInternals = { * (`tls.connect({ socket: duplex })`, `new tls.TLSSocket(duplex)`): one per * socket, until the task that frees it after the close has run. */ - liveCount: $bindgen("socket_body.bind.ts", "duplexUpgradeContextLiveCount"), + liveCount: $bindgen("socket_body.bind.ts", "upgradedDuplexLiveCount"), }; type SerializationContext = "worker" | "window" | "postMessage" | "default"; export const structuredCloneAdvanced: ( @@ -995,6 +1002,10 @@ export const getEventLoopStats: () => { eventLoopAlive: boolean; /** usockets/libuv loop iterations so far (us_internal_loop_pre count). */ iteration: number; + /** The sockets the usockets timeout sweep runs for. Its timer is armed while this isn't 0. */ + sweepTimerCount: number; + /** When the next sweep is due, in ns of the monotonic clock, or -1 while the timer is off. Not on Windows, where libuv has the timer. */ + sweepDeadline?: number; } = $bindgen("event_loop.bind.ts", "getActiveTasks"); export const hostedGitInfo = { @@ -1053,6 +1064,8 @@ export const proxyInternals = { noProxyMatches: $bindgen("proxy_testing.bind.ts", "noProxyMatches"), /** The proxy an environment of exactly `env` selects for `url`, or null. */ proxyFor: (env: Record, url: string) => proxyFor(Object.entries(env).flat(), url), + /** How many CONNECT tunnels fetch() has open or pooled. */ + tunnelLiveCount: $bindgen("proxy_testing.bind.ts", "tunnelLiveCount"), /** What the HTTP client's URL parser makes of `href`. */ parseURL: $bindgen("proxy_testing.bind.ts", "parseURL") as (href: string) => { username: string; diff --git a/src/js/internal/cluster/RoundRobinHandle.ts b/src/js/internal/cluster/RoundRobinHandle.ts index c0ed13b4c2f9..491eb14c3355 100644 --- a/src/js/internal/cluster/RoundRobinHandle.ts +++ b/src/js/internal/cluster/RoundRobinHandle.ts @@ -22,6 +22,7 @@ export default class RoundRobinHandle { server: $Node.NetServer | null; listening: boolean; inFlight: Map; + errno = 0; declare data: unknown; constructor(key: string, address, { port, fd, flags, backlog, readableAll, writableAll }) { @@ -38,22 +39,27 @@ export default class RoundRobinHandle { RoundRobinHandle.prototype.onServerConnection.bind(this), ); - if (fd >= 0) this.server.listen({ fd, backlog }); - else if (port >= 0) { - this.server.listen({ - port, - host: address, - // Currently, net module only supports `ipv6Only` option in `flags`. - ipv6Only: !!(flags & UV_TCP_IPV6ONLY), - backlog, - }); - } else - this.server.listen({ - path: address, - backlog, - readableAll, - writableAll, - }); // UNIX socket path. + try { + if (fd >= 0) this.server.listen({ fd, backlog }); + else if (port >= 0) { + this.server.listen({ + port, + host: address, + // Currently, net module only supports `ipv6Only` option in `flags`. + ipv6Only: !!(flags & UV_TCP_IPV6ONLY), + backlog, + }); + } else + this.server.listen({ + path: address, + backlog, + readableAll, + writableAll, + }); // UNIX socket path. + } catch { + // net validates these in the worker, so only a message it didn't write gets here. + this.errno = UV_EINVAL; + } this.server.once("listening", RoundRobinHandle.prototype.onServerListening.bind(this)); } @@ -85,6 +91,8 @@ export default class RoundRobinHandle { add(worker: ClusterWorker, send: SendReply) { $assert(this.all.has(worker.id) === false); this.all.set(worker.id, worker); + const { errno } = this; + if (errno) return send(errno, null, null); const done = () => { if (this.handle!.getsockname && typeof this.server!.address() === "object") { @@ -113,12 +121,6 @@ export default class RoundRobinHandle { return this.all.has(worker.id); } - // Returns the listener's file descriptor, or -1 if it isn't listening yet or remove() already closed it. - get fd() { - const fd = this.server?._handle?.fd; - return typeof fd === "number" ? fd : -1; - } - // With the channel still up the unacked newconn is settled by its ack; once it is gone, a crashed worker's goes to another worker and a disconnected worker's (already settled by it) is dropped. remove(worker: ClusterWorker, channelGone = false) { if (channelGone) { diff --git a/src/js/internal/cluster/SharedHandle.ts b/src/js/internal/cluster/SharedHandle.ts index b3e3fc27ebcc..5ca858168609 100644 --- a/src/js/internal/cluster/SharedHandle.ts +++ b/src/js/internal/cluster/SharedHandle.ts @@ -14,10 +14,7 @@ const clusterRawBind: ( flags: number, ) => RawHandle | number = $bindgen("node_cluster_binding.bind.ts", "clusterRawBind"); const closeRawHandle: (fd: number) => void = $bindgen("node_cluster_binding.bind.ts", "clusterCloseHandle"); -const validateFd: (fd: number, wantDgram: boolean) => number = $bindgen( - "node_cluster_binding.bind.ts", - "clusterValidateFd", -); +const adoptFd: (fd: number, wantDgram: boolean) => number = $bindgen("node_cluster_binding.bind.ts", "clusterAdoptFd"); export default class SharedHandle { key: string; @@ -36,7 +33,7 @@ export default class SharedHandle { this.sharedOnly = sharedOnly === true; if (typeof fd === "number" && fd >= 0) { - const err = validateFd(fd, addressType === "udp4" || addressType === "udp6"); + const err = adoptFd(fd, addressType === "udp4" || addressType === "udp6"); if (err !== 0) { this.errno = err; } else { @@ -64,12 +61,6 @@ export default class SharedHandle { return this.workers.has(worker.id); } - // Returns the file descriptor that remove() closes, or -1. - get fd() { - const handle = this.handle; - return handle ? handle.fd : -1; - } - remove(worker: ClusterWorker) { const workers = this.workers; if (!workers.has(worker.id)) return false; diff --git a/src/js/internal/cluster/child.ts b/src/js/internal/cluster/child.ts index 847ad88cd90f..e42560af076c 100644 --- a/src/js/internal/cluster/child.ts +++ b/src/js/internal/cluster/child.ts @@ -23,6 +23,7 @@ interface ChildCluster extends InstanceType { Worker: typeof Worker; _setupWorker(): void; _getServer(obj: ListeningObject, options, cb: GetServerCallback): void; + _trackServer(server): void; } /** A `net.Server` or a `dgram.Socket`. */ @@ -55,8 +56,13 @@ interface RoundRobinFauxHandle { onconnection?(err: number, handle: ConnectionHandle): void; } +/** Stands in `handles`, as its own key, for a server that bound its own socket. */ +interface TrackedServerHandle { + [owner_symbol]: Pick; +} + const cluster = new EventEmitter() as ChildCluster; -const handles = new Map(); +const handles = new Map(); const indexes = new Map }>(); const noop = FunctionPrototype as () => void; const TIMEOUT_MAX = 2 ** 31 - 1; @@ -144,8 +150,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], ":"); @@ -200,6 +208,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: string, index: number) { const indexSet = indexes.get(indexesKey); if (!indexSet) { @@ -384,7 +399,7 @@ Worker.prototype._disconnect = function (this: ClusterWorker, primaryInitiated?) waitingCount++; if (handle[owner_symbol]) (handle[owner_symbol] as HandleOwner).close(checkWaitingCount); - else handle.close(checkWaitingCount); + else (handle as SharedHandle | RoundRobinFauxHandle).close(checkWaitingCount); }); handles.clear(); diff --git a/src/js/internal/cluster/primary.ts b/src/js/internal/cluster/primary.ts index c932b291be27..464710e76e45 100644 --- a/src/js/internal/cluster/primary.ts +++ b/src/js/internal/cluster/primary.ts @@ -10,12 +10,7 @@ const onInternalMessage: ( worker: ClusterWorker, callback: (this: ClusterWorker, message, handle: unknown) => void, ) => void = $bindgen("node_cluster_binding.bind.ts", "onInternalMessagePrimary"); -const validateFd: (fd: number, wantDgram: boolean) => number = $bindgen( - "node_cluster_binding.bind.ts", - "clusterValidateFd", -); -const isDgramSocketFd: (fd: number) => boolean = $bindgen("udp_socket.bind.ts", "dgramIsFdAdopted"); -const { UV_EEXIST, UV_EINVAL, UV_ENOBUFS } = process.binding("uv"); +const { UV_EINVAL, UV_ENOBUFS } = process.binding("uv"); let child_process: $BunModules["node:child_process"] | undefined; let RoundRobinHandle: $BunModules["internal/cluster/RoundRobinHandle"] | undefined; @@ -236,6 +231,7 @@ cluster.disconnect = function (cb) { }; const methodMessageMapping = { + __proto__: null, close, exitedAfterDisconnect, listening, @@ -246,9 +242,16 @@ const methodMessageMapping = { function onmessage(this: ClusterWorker, message, _handle: unknown) { const worker = this; - const fn: ((worker: ClusterWorker, message) => void) | undefined = methodMessageMapping[message.act]; + // child.ts only writes strings, numbers, booleans and null outside `data`. Drop anything else, so that no coercion of a field can throw. + for (const name of ObjectKeys(message)) { + const value = message[name]; + const type = typeof value; + if (name !== "data" && value !== null && type !== "string" && type !== "number" && type !== "boolean") { + message[name] = undefined; + } + } - if (typeof fn === "function") fn(worker, message); + methodMessageMapping[message.act]?.(worker, message); } function online(worker: ClusterWorker) { @@ -262,19 +265,6 @@ function exitedAfterDisconnect(worker: ClusterWorker, message) { send(worker, { ack: message.seq }); } -// A handle closes its fd when its last worker is removed, so a second handle for the same fd -// would close it while the first handle still uses it. -function isFdInUse(fd: number, isUdp: boolean) { - // SharedHandle rejects a number that isn't a valid fd. - if ((fd | 0) !== fd) return false; - if (isUdp && isDgramSocketFd(fd)) return true; - let inUse = false; - handles.forEach(handle => { - if (handle.fd === fd) inUse = true; - }); - return inUse; -} - function queryServer(worker: ClusterWorker, message) { // Stop processing if worker already disconnecting if (worker.exitedAfterDisconnect) return; @@ -282,16 +272,17 @@ function queryServer(worker: ClusterWorker, message) { RoundRobinHandle ??= require("internal/cluster/RoundRobinHandle"); SharedHandle ??= require("internal/cluster/SharedHandle"); + // Port 0 of an http worker is a port of its own, as when http workers bound theirs without asking. const key = `${message.address}:${message.port}:${message.addressType}:${message.fd}` + - (message.port === 0 ? `:${message.index}` : ""); + (message.port === 0 ? `:${message.index}:${message.isHttp === true}` : ""); const cachedHandle = handles.get(key); let handle: Handle | undefined; 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; @@ -312,8 +303,13 @@ function queryServer(worker: ClusterWorker, 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; @@ -329,16 +325,6 @@ function queryServer(worker: ClusterWorker, message) { worker.emit("error", error); return; } - const fd = message.fd; - if (typeof fd === "number" && fd >= 0) { - const isUdp = message.addressType === "udp4" || message.addressType === "udp6"; - if (isFdInUse(fd, isUdp)) { - // Like node, report a wrong fd type before reporting that the fd is in use. - const errno = validateFd(fd, isUdp) || UV_EEXIST; - send(worker, { errno, key, ack: message.seq, data: cachedHandle ? cachedHandle.data : message.data }, null); - return; - } - } if ( schedulingPolicy !== SCHED_RR || message.sharedOnly === true || @@ -411,7 +397,8 @@ function send(worker: ClusterWorker, message: object, handle?: object | null, cb Worker.prototype.disconnect = function (this: ClusterWorker) { this.exitedAfterDisconnect = true; send(this, { act: "disconnect" }); - this.process.disconnect!(); + // A process that never loads the child half of node:cluster doesn't answer. Node loads it in every worker. + if (this.state === "none" && this.process.connected) this.process.disconnect!(); removeHandlesForWorker(this, false); removeWorker(this); return this; diff --git a/src/js/internal/debugger.ts b/src/js/internal/debugger.ts index 2e65756febef..b8a0e162a6d6 100644 --- a/src/js/internal/debugger.ts +++ b/src/js/internal/debugger.ts @@ -450,7 +450,6 @@ class Debugger { } socket.data.framer.onData(socket, bytes); }, - drain: (_socket: FramedSocket) => {}, close: (socket: FramedSocket) => { const socketData = socket.data; if (socketData) { @@ -721,9 +720,6 @@ async function connectToUnixServer( socket.data.framer.onData(socket, bytes); }, - // Ensure we always drain the socket. - drain: (_socket: FramedSocket) => {}, - close: (socket: FramedSocket) => { const socketData = socket.data; if (socketData) { diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index 678010214feb..05f1ee59ee11 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -1,72 +1,33 @@ const { isIPv4 } = require("internal/net/isIP"); -const { setServerCustomOptions, setServerAppFlags, setServerMaxHeadersCount, drainMicrotasks } = $cpp( - "NodeHTTP.cpp", - "createNodeHTTPInternalBinding", -) as { - setServerCustomOptions: ( - server: any, - requireHostHeader: boolean, - useStrictMethodValidation: boolean, - lenientHttpFlags: number, - maxHeaderSize: number, - onClientError: (ssl: boolean, socket: any, errorCode: number, rawPacket: ArrayBuffer) => undefined, - onConnection?: (socketHandle: any) => undefined, - ) => void; - setServerAppFlags: ( - server: any, - requireHostHeader: boolean, - useStrictMethodValidation: boolean, - lenientHttpFlags: number, - httpAllowHalfOpen: boolean, - ) => void; - setServerMaxHeadersCount: (server: any, maxHeadersCount: number) => void; - drainMicrotasks: () => void; -}; +const queueMicrotask = globalThis.queueMicrotask; + +const { drainMicrotasks } = $cpp("NodeHTTP.cpp", "createNodeHTTPInternalBinding") as { drainMicrotasks: () => void }; const abortedSymbol = Symbol("aborted"); -const headerStateSymbol = Symbol("headerState"); const eofInProgress = Symbol("eofInProgress"); const fakeSocketSymbol = Symbol("fakeSocket"); const isTlsSymbol = Symbol("is_tls"); const kHandle = Symbol("handle"); -const kOnReadParsed = Symbol("kOnReadParsed"); +// http.Server.prototype method for the `ws` package: takes a connection out of HTTP. +const kLeaveHttp = Symbol("kLeaveHttp"); +// On a server socket: the stream that 'upgrade' got in its place, while the body of the request is incomplete. +const kUpgradeStream = Symbol("kUpgradeStream"); const kRealListen = Symbol("kRealListen"); const noBodySymbol = Symbol("noBody"); -const optionsSymbol = Symbol("options"); const tlsSymbol = Symbol("tls"); const kAbortController = Symbol.for("kAbortController"); const kInternalSocketData = Symbol.for("::bunternal::"); const serverSymbol = Symbol.for("::bunternal::"); const kPendingCallbacks = Symbol("pendingCallbacks"); -const kRequest = Symbol("request"); // IncomingMessage slot for the request's native response. null if the request was parsed in JS. const kHeaderSource = Symbol("kHeaderSource"); -// Set on a server socket at the 'connect'/'upgrade' handoff: the native response of that request. -const kHandoffResponse = Symbol("kHandoffResponse"); -// Method of a server socket. Whoever takes the connection over calls it while the connection is still open. -const kRememberPeer = Symbol("kRememberPeer"); -// Getter of a server socket: whether the native side has read bytes or the end of a tunnel's stream that it will push -// into the socket in a task. -const kHasQueuedReads = Symbol("kHasQueuedReads"); -// Property of a server socket: whether the connection closed before the kernel had taken everything that was written. -// undefined until it has closed. -const kClosedWithUnsentData = Symbol("kClosedWithUnsentData"); const kCloseCallback = Symbol("closeCallback"); -// node:_http_server registers its pipelined-response machinery here at module -// initialization, letting internal/http1_server_fallback drive the same -// per-connection queue without widening node:_http_server's exports. The -// fallback loads node:http (and with it _http_server) before reading these. -const http1ServerPipeline: { - queuePipelinedResponse?: (socket: unknown, res: unknown, isAncient: boolean) => void; - advanceResponsePipeline?: (server: unknown, socket: unknown) => void; - abortQueuedPipelinedResponses?: (socket: unknown) => void; - lastPipelinedResponse?: (socket: unknown) => { _last: boolean } | undefined; - maybePauseFallbackReads?: (socket: unknown) => void; - resumeFallbackReadsOnDrain?: (socket: unknown) => void; - finishDrainedResponse?: (res: unknown) => void; - kMustCloseConnection?: symbol; +// Filled by node:_http_server at module initialization. node:https builds https.Server from these. +const httpServerParts: { + initHttpServer?: (this: unknown, options: unknown, callback: unknown) => unknown; + serverPrototypeDescriptors?: PropertyDescriptorMap; } = {}; export const enum NodeHTTPResponseAbortEvent { @@ -78,7 +39,7 @@ export const enum NodeHTTPResponseAbortEvent { export const enum NodeHTTPBodyReadState { none, pending = 1 << 1, - /** The last chunk arrived, or a WebSocket took the connection. */ + /** The last chunk arrived, or the reader let go of the body. */ done = 1 << 2, /** The connection closed before the last chunk. */ aborted = 1 << 3, @@ -89,18 +50,10 @@ export const enum NodeHTTPResponseFlags { socket_closed = 1 << 0, request_has_completed = 1 << 1, ended = 1 << 2, - upgraded = 1 << 3, - dispatch_threw_while_queued = 1 << 9, closed_or_completed = socket_closed | request_has_completed, } -export const enum NodeHTTPHeaderState { - none, - assigned, - sent, -} - function emitErrorNextTickIfErrorListenerNT(self, err, cb) { process.nextTick(emitErrorNextTickIfErrorListener, self, err, cb); } @@ -172,21 +125,17 @@ function emitEOFIncomingMessageOuter(self) { function clearServerParserIncoming(parser, req) { if (parser.incoming === req) parser.incoming = null; } +// One microtask a request, in their order, and no function object for each. +let requestsAtTheirEnd; +function emitEOFOfNextRequest() { + emitEOFIncomingMessageOuter(requestsAtTheirEnd.shift()); +} function emitEOFIncomingMessage(self) { self[eofInProgress] = true; process.nextTick(emitEOFIncomingMessageOuter, self); } function onDataIncomingMessage(this: any, chunk, isLast, aborted: NodeHTTPResponseAbortEvent) { - if (aborted === NodeHTTPResponseAbortEvent.readParsed) { - const socket = this.socket; - const onReadParsed = socket?.[kOnReadParsed]; - if (onReadParsed) { - socket[kOnReadParsed] = undefined; - onReadParsed(socket); - } - return; - } if (aborted === NodeHTTPResponseAbortEvent.abort) { // The request is aborted from the socket's #onClose (like Node.js's // socketOnClose → abortIncoming), which the native close path always @@ -212,17 +161,24 @@ function onDataIncomingMessage(this: any, chunk, isLast, aborted: NodeHTTPRespon else if (finSent && !this._consuming) socket[kHandle].stopParsing(); else if (socket && (finSent || !socket.writableEnded)) { socket.pause(); - // For a pipelined request the socket's current response is an earlier one, and an ended response does not pause. - this[kHandle]?.pause(); } } } if (isLast) { - 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(); + try { + if (chunk) { + // Node's parser runs the promise jobs of the last chunk's 'data' listeners before parserOnMessageComplete pushes the EOF. + this[eofInProgress] = true; + (requestsAtTheirEnd ??= new (require("internal/fifo"))()).push(this); + queueMicrotask(emitEOFOfNextRequest); + } else { + emitEOFIncomingMessage(this); + } + } finally { + // Like Node's parserOnMessageComplete: a readStop above left the socket not flowing, which would swallow the next request's 'pause'. + if (!this.upgrade && socket && !socket._paused && socket.readable) socket.resume(); + } } } @@ -330,57 +286,13 @@ const STATUS_CODES = { 511: "Network Authentication Required", }; -function hasServerResponseFinished(self, chunk, callback, fromEnd) { - const finished = self.finished; - - // Only end() takes "" for no chunk. To Node.js's write_() it is a write: https://github.com/nodejs/node/blob/v26.3.0/lib/_http_outgoing.js#L943-L957 - if (chunk || (!fromEnd && chunk === "")) { - const destroyed = self.destroyed; - - if (finished || destroyed) { - let err; - if (finished) { - err = $ERR_STREAM_WRITE_AFTER_END(); - } else if (destroyed) { - err = $ERR_STREAM_DESTROYED("write"); - } - - if (!destroyed) { - process.nextTick(emitErrorNt, self, err, callback); - } else if (!fromEnd && $isCallable(callback)) { - // Node.js's end() never gives this error to its callback: https://github.com/nodejs/node/blob/v26.3.0/lib/_http_outgoing.js#L1086-L1098 - process.nextTick(callback, err); - } - - return true; - } - } else if (finished) { - if ($isCallable(callback)) { - if (!self.writableFinished) { - self.on("finish", callback); - } else { - callback($ERR_STREAM_ALREADY_FINISHED("end")); - } - } - - return true; - } - - return false; -} - -function emitErrorNt(msg, err, callback) { - if ($isCallable(callback)) { - callback(err); - } - if ($isCallable(msg.emit) && !msg.destroyed) { - msg.emit("error", err); - } -} const setMaxHTTPHeaderSize = $bindgen("node_http_binding.bind.ts", "setMaxHTTPHeaderSize"); const getMaxHTTPHeaderSize = $bindgen("node_http_binding.bind.ts", "getMaxHTTPHeaderSize"); +const RE_CONN_CLOSE = /(?:^|\W)close(?:$|\W)/i; const kOutHeaders = Symbol("kOutHeaders"); const kNeedDrain = Symbol("kNeedDrain"); +const kFinishesWithoutLastWrite = Symbol("kFinishesWithoutLastWrite"); +const kFailHeldWrites = Symbol("kFailHeldWrites"); const kProxyConfig = Symbol("kProxyConfig"); const kWaitForProxyTunnel = Symbol("kWaitForProxyTunnel"); const kPerRequestCheckServerIdentity = Symbol("kPerRequestCheckServerIdentity"); @@ -424,7 +336,7 @@ class ProxyConfig { bypassList; proxyConnectionOptions; - constructor(proxyUrl, keepAlive, noProxyList) { + constructor(proxyUrl, noProxyList) { let parsedURL; try { parsedURL = new URL(proxyUrl); @@ -522,7 +434,7 @@ function parseProxyUrl(env, protocol) { return proxyUrl; } -function parseProxyConfigFromEnv(env, protocol, keepAlive) { +function parseProxyConfigFromEnv(env, protocol) { // We only support proxying for HTTP and HTTPS requests. if (protocol !== "http:" && protocol !== "https:") return null; const proxyUrl = parseProxyUrl(env, protocol); @@ -530,7 +442,7 @@ function parseProxyConfigFromEnv(env, protocol, keepAlive) { // Only http:// and https:// proxies are supported. Ignore instead of throw, in case other protocols are supposed to be handled by the user land. if (!proxyUrl.startsWith("http://") && !proxyUrl.startsWith("https://")) return null; - return new ProxyConfig(proxyUrl, keepAlive, env.no_proxy || env.NO_PROXY); + return new ProxyConfig(proxyUrl, env.no_proxy || env.NO_PROXY); } function checkShouldUseProxy(proxyConfig: ProxyConfig, reqOptions: any) { @@ -541,6 +453,7 @@ function checkShouldUseProxy(proxyConfig: ProxyConfig, reqOptions: any) { export { METHODS, + RE_CONN_CLOSE, STATUS_CODES, abortedSymbol, callCloseCallback, @@ -552,39 +465,31 @@ export { eofInProgress, fakeSocketSymbol, getMaxHTTPHeaderSize, - hasServerResponseFinished, - headerStateSymbol, - http1ServerPipeline, + httpServerParts, isTlsSymbol, kAbortController, kCloseCallback, - kClosedWithUnsentData, + kFailHeldWrites, + kFinishesWithoutLastWrite, kHandle, - kHandoffResponse, - kHasQueuedReads, kHeaderSource, kInternalSocketData, + kLeaveHttp, kNeedDrain, - kOnReadParsed, kOutHeaders, kPendingCallbacks, kPerRequestCheckServerIdentity, kProxyConfig, kRealListen, - kRememberPeer, - kRequest, + kUpgradeStream, kWaitForProxyTunnel, noBodySymbol, onDataIncomingMessage, - optionsSymbol, parseProxyConfigFromEnv, parseProxyUrl, redactInvalidProxyUrl, serverSymbol, setMaxHTTPHeaderSize, - setServerAppFlags, - setServerCustomOptions, - setServerMaxHeadersCount, tlsSymbol, utcDate, }; diff --git a/src/js/internal/http/FakeSocket.ts b/src/js/internal/http/FakeSocket.ts index 4ada6d8b95f9..771d7d2f0cd0 100644 --- a/src/js/internal/http/FakeSocket.ts +++ b/src/js/internal/http/FakeSocket.ts @@ -1,27 +1,21 @@ -const { kInternalSocketData, serverSymbol } = require("internal/http"); -const { kAutoDestroyed } = require("internal/shared"); const { Duplex } = require("internal/stream"); type FakeSocket = InstanceType; var FakeSocket = class Socket extends Duplex { - [kInternalSocketData]?: [import("node:http").Server, import("node:http").ServerResponse, Request]; bytesRead = 0; bytesWritten = 0; connecting = false; timeout = 0; isServer = false; - #address; + #address: { address?: string; port?: number; family?: string } = {}; _httpMessage: any; constructor(httpMessage: any) { super(); this._httpMessage = httpMessage; } address() { - // Call server.requestIP() without doing any property getter twice. - var internalData; - return (this.#address ??= - (internalData = this[kInternalSocketData])?.[0]?.[serverSymbol]?.requestIP(internalData[2]) ?? {}); + return this.#address; } get bufferSize() { @@ -35,11 +29,7 @@ var FakeSocket = class Socket extends Duplex { this.emit("timeout"); }; - _destroy(_err, _callback) { - const socketData = this[kInternalSocketData]; - if (!socketData) return; // sometimes 'this' is Socket not FakeSocket - if (!socketData[1]["req"][kAutoDestroyed]) socketData[1].end(); - } + _destroy(_err, _callback) {} _final(_callback) {} @@ -113,12 +103,7 @@ var FakeSocket = class Socket extends Duplex { return this; } - setTimeout(timeout, callback) { - const socketData = this[kInternalSocketData]; - if (!socketData) return; // sometimes 'this' is Socket not FakeSocket - - const http_res = socketData[1]; - http_res?.req?.setTimeout(timeout, callback); + setTimeout(_timeout, _callback) { return this; } diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts deleted file mode 100644 index 476bb70ce9d2..000000000000 --- a/src/js/internal/http1_server_fallback.ts +++ /dev/null @@ -1,733 +0,0 @@ -// 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 { SafeSet } = require("internal/primordials"); -const AsyncContextFrame = require("internal/async_context_frame"); - -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"); -const reportError = globalThis.reportError; - -function rethrowUncaught(err) { - throw err; -} - -// 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]; - res[kPendingCallbacks] = []; - for (let i = 0; i < callbacks.length; i++) { - try { - callbacks[i](err); - } catch (e) { - // An uncaught exception, as in Node. It must not cut the socket's 'close' listeners short. - process.nextTick(rethrowUncaught, e); - } - } -} - -type IncomingMessage = import("node:http").IncomingMessage; - -interface Http1FallbackRequest extends IncomingMessage { - upgrade: boolean; - _dumped: boolean; - joinDuplicateHeaders?: boolean; - _addHeaderLines(headers: string[], n: number): void; -} - -interface Http1FallbackResponseHead { - statusCode: number; - statusMessage: string | undefined; - headers: string[]; - autoHeaderBits: number; - keepAliveTimeoutSecs: number; -} - -function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTimeout) { - const { _checkInvalidHeaderChar: checkInvalidHeaderChar } = require("node:_http_common"); - let head: Http1FallbackResponseHead | null = null; - let headWritten = false; - let chunked = false; - let noBody = false; - let closeDelimited = false; - // The drain callback of a write() that reported backpressure, and its async context (native's onwritable slot). - let onwritable: ((...args: unknown[]) => void) | null | undefined = null; - let onwritableFrame; - - function writeHeadToSocket(contentLength) { - if (headWritten) return; - headWritten = true; - const statusCode = head?.statusCode ?? 200; - let statusMessage = head?.statusMessage; - if (typeof statusMessage !== "string" || statusMessage === "") { - statusMessage = STATUS_CODES[statusCode] || "unknown"; - } - let out = `HTTP/1.1 ${statusCode} ${statusMessage}\r\n`; - let hasContentLength = false; - let hasTransferEncoding = false; - let hasDate = false; - let hasConnection = false; - let hasKeepAlive = false; - const headers = head?.headers; - if (headers) { - // ServerResponse drives this handle with renderNativeHeaders(): a flat - // [name, value, name, value, ...] array with original-case names. - for (let i = 0, end = headers.length - 1; i < end; i += 2) { - const name = headers[i]; - const value = headers[i + 1]; - if (name.length === 1 && name.charCodeAt(0) === 0) { - // node:http's NUL-named framing sentinel pair (see NodeHTTP.cpp): - // value "2" = no body (HEAD), anything else = close-delimited. - if (value === "2") noBody = true; - else closeDelimited = true; - continue; - } - switch (name.toLowerCase()) { - case "content-length": - hasContentLength = true; - break; - case "transfer-encoding": - hasTransferEncoding = true; - if (String(value).toLowerCase().includes("chunked")) chunked = true; - break; - case "date": - hasDate = true; - break; - case "connection": - hasConnection = true; - break; - case "keep-alive": - hasKeepAlive = true; - break; - } - out += `${name}: ${value}\r\n`; - } - } - // renderNativeHeaders carries its framing and Connection decisions in the - // auto-header bits (AUTO_HEADER_* in _http_server.ts / kAutoHeader* in - // NodeHTTP.cpp) rather than the flat array. - const autoBits = head?.autoHeaderBits ?? 0; - // Framing decided here but Transfer-Encoding rendered after Connection (Node _storeHeader - // order); suppress the Content-Length it would otherwise invent (RFC 9112 §6.1 smuggling). - const chunkedFromAutoBits = (autoBits & 16) !== 0; - // Decide the framing here, but write it after Date/Connection/Keep-Alive: - // Node's _storeHeader emits Content-Length and the chunked Transfer-Encoding - // after its automatic connection block. - let autoContentLength = null; - let autoChunked = false; - if (!hasContentLength && !hasTransferEncoding && !noBody && !closeDelimited) { - if (chunkedFromAutoBits || contentLength === null) { - chunked = true; - autoChunked = !chunkedFromAutoBits; - } else { - autoContentLength = contentLength; - } - } - // Mirror native writeAutoHeaders: each line written iff its bit is set, so sendDate=false / - // removeHeader / suppressed Keep-Alive round-trip identically. A head-less write (no writeHead - // on this handle — only off node:http's ServerResponse) keeps the old defaults. - if (head === null) { - if (!hasDate) { - out += `Date: ${new Date().toUTCString()}\r\n`; - } - if (!hasConnection && !closeDelimited) { - if (shouldKeepAlive) { - out += "Connection: keep-alive\r\n"; - if (!hasKeepAlive) { - out += `Keep-Alive: timeout=${Math.floor((keepAliveTimeout || 5000) / 1000)}\r\n`; - } - } else { - out += "Connection: close\r\n"; - } - } - } else { - if (!hasDate && (autoBits & 1) !== 0) { - out += `Date: ${new Date().toUTCString()}\r\n`; - } - if (!hasConnection) { - if ((autoBits & 2) !== 0) { - out += "Connection: keep-alive\r\n"; - if (!hasKeepAlive && (autoBits & 8) !== 0) { - out += `Keep-Alive: timeout=${head.keepAliveTimeoutSecs}\r\n`; - } - } else if ((autoBits & 4) !== 0) { - out += "Connection: close\r\n"; - } - } - } - // Last, where Node's _storeHeader puts them — after Connection/Keep-Alive. - if (autoContentLength !== null) { - out += `Content-Length: ${autoContentLength}\r\n`; - } else if (autoChunked || (chunkedFromAutoBits && chunked)) { - out += "Transfer-Encoding: chunked\r\n"; - } - out += "\r\n"; - writeToSocket(out); - } - - function toBuffer(chunk, encoding) { - if (chunk == null) return null; - if (typeof chunk === "string") return Buffer.from(chunk, encoding || "utf8"); - return chunk; - } - - // Like the `conn.writable` gate of Node's _writeRaw: a write to a socket that was ended (after the client's FIN) would destroy it. - function writeToSocket(data, callback?) { - if (!socket.writableEnded) socket.write(data, callback); - } - - function writeBody(buf, callback?) { - const length = buf ? (buf.byteLength ?? buf.length) : 0; - if (length) { - if (chunked) { - writeToSocket(length.toString(16) + "\r\n"); - writeToSocket(buf); - writeToSocket("\r\n", callback); - } else { - writeToSocket(buf, callback); - } - } - return length; - } - - // Runs `fn` once in the place of the waiting callback, in that callback's async context. - function settleWaiting(fn) { - const frame = onwritableFrame; - onwritable = null; - onwritableFrame = undefined; - AsyncContextFrame.run(frame, fn); - } - - // The bytes end() left in the socket are out, or the socket closed with them. - function flushed() { - if (!handle.ended || handle.finished) return; - handle.finished = true; - try { - handle.onflushed?.(); - } catch (err) { - // A 'finish' listener that throws must not unwind into the socket's write callbacks or 'close' listeners. - reportError(err); - } - } - - // A failed write or a destroyed socket leaves 'finish' to the socket's 'close': streams fail writes inside destroy(), or just before it. - function onEndWritten(err) { - if (!err && !socket.destroyed) flushed(); - } - - const handle = { - // NodeHTTPResponseFlags.socket_closed, like the native getter: end() and write() stop at it. - get flags() { - return this.aborted || socket.destroyed ? 1 : 0; - }, - ended: false, - // True once the bytes end() wrote have left the socket, like the native handle's. - finished: false, - aborted: false, - shouldKeepAlive, - onfinished: null as (() => void) | null, - // Like the native getter: an empty slot reads as undefined. - get onwritable() { - return onwritable ?? undefined; - }, - set onwritable(callback) { - onwritable = callback; - onwritableFrame = callback ? AsyncContextFrame.current() : undefined; - }, - // The socket's bytes while a write waits for 'drain' or an ended response is still flushing; a socket under its high water mark emits no 'drain'. - get bufferedAmount() { - return onwritable || (this.ended && !this.finished) ? socket.writableLength : 0; - }, - // Native on_drain: the socket drained, so the waiting callback runs. - socketDrained() { - if (onwritable) settleWaiting(onwritable); - }, - // The socket finished or closed, so no 'drain' comes: `fn` runs, and the waiting callback never does. - socketEnded(fn) { - if (onwritable) settleWaiting(fn); - }, - // Runs once, from flushed(). ServerResponse#end()'s `onwritable` stays unused: its 'finish' comes a tick later, after a closing socket's 'close'. - onflushed: null as (() => void) | null, - cork(callback) { - return callback(); - }, - writeContinue() { - writeToSocket("HTTP/1.1 100 Continue\r\n\r\n"); - }, - writeInformational(chunk, encoding) { - // _writeRaw hands the fully-rendered 1xx block here (writeEarlyHints / - // writeProcessing / writeInformation all route through it). - if (!socket.writableEnded) socket.write(chunk, encoding); - }, - writeHead(statusCode, statusMessage, headers, autoHeaderBits, keepAliveTimeoutSecs) { - const originalStatusCode = statusCode; - statusCode |= 0; - if (statusCode < 100 || statusCode > 999) { - throw $ERR_HTTP_INVALID_STATUS_CODE(`${originalStatusCode}`); - } - if (typeof statusMessage === "string" && checkInvalidHeaderChar(statusMessage)) { - throw $ERR_INVALID_CHAR("statusMessage"); - } - head = { statusCode, statusMessage, headers, autoHeaderBits, keepAliveTimeoutSecs }; - }, - flushHeaders() { - writeHeadToSocket(null); - }, - writeHeadAndEnd( - statusCode, - statusMessage, - headers, - chunk, - encoding, - strictContentLength, - autoHeaderBits, - keepAliveTimeoutSecs, - ) { - // The native NodeHTTPResponse batches writeHead + end into one call; - // this fallback composes the same two steps. - this.writeHead(statusCode, statusMessage, headers, autoHeaderBits, keepAliveTimeoutSecs); - return this.end(chunk, encoding, undefined, strictContentLength); - }, - write(chunk, encoding, callback, _strictContentLength) { - const buf = toBuffer(chunk, encoding); - 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. - if (buf === null || !socket.writableNeedDrain) return length; - // Like native write_or_end: a negative result, and the callback waits for the drain. - if (callback) this.onwritable = callback; - // An empty write has no length to negate. - return length > 0 ? -length : -1; - }, - end(chunk, encoding, _callback, _strictContentLength) { - if (this.ended) return 0; - // 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; - writeHeadToSocket(length); - // Like Node's `_hasBody && chunkedEncoding` gate: a bodiless (HEAD) - // response never writes the terminating chunk, even when the user set - // Transfer-Encoding: chunked themselves. - const terminated = chunked && !noBody; - writeBody(buf, terminated ? undefined : onEndWritten); - if (terminated) writeToSocket("0\r\n\r\n", onEndWritten); - 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) { - // An ended socket took no write, so its own 'finish' tells. The empty write is for an end() that wrote nothing: behind a body, _writev would copy that body. - if (socket.writableEnded) socket.once("finish", onEndWritten); - else if (!length && !terminated) socket.write("", "latin1", onEndWritten); - } else { - this.finished = true; - } - const onfinished = this.onfinished; - if (onfinished) { - this.onfinished = null; - onfinished(); - } - // A close-delimited body ends at EOF, so the response ends the connection. - if (closeDelimited && !socket.destroyed) { - socket.end(); - } - // The native handle's contract: -(length + 1) while bytes still drain, so that ServerResponse#end() holds 'finish' back. - return this.finished ? length : -(length + 1); - }, - flushed, - abort() { - this.aborted = true; - if (!socket.destroyed) socket.destroy(); - }, - }; - return handle; -} - -// HTTP/1.1 fallback for Http2SecureServer `allowHTTP1: true`: parse the TLS socket and emit -// 'request' with http.IncomingMessage/ServerResponse, like Node's httpConnectionListener routing. -function connectionListenerHTTP1(server, socket, options) { - const http = require("node:http"); - const { - HTTPParser, - prepareError, - calculateLenientFlags, - continueExpression, - MAX_HEADER_PAIRS, - } = require("node:_http_common"); - const { ConnResetException } = require("internal/shared"); - const { kHandle: kHttp1ResponseHandle, kHeaderSource, http1ServerPipeline } = require("internal/http"); - // Populated by node:_http_server, which the require("node:http") above loads. - const { - queuePipelinedResponse, - advanceResponsePipeline, - abortQueuedPipelinedResponses, - lastPipelinedResponse, - maybePauseFallbackReads, - resumeFallbackReadsOnDrain, - finishDrainedResponse, - kMustCloseConnection, - } = http1ServerPipeline; - const { allMethods } = process.binding("http_parser"); - - const http1Options = options.http1Options || {}; - const IncomingMessageClass: new (socket) => Http1FallbackRequest = - http1Options.IncomingMessage || http.IncomingMessage; - const ServerResponseClass = http1Options.ServerResponse || http.ServerResponse; - const keepAliveTimeout = typeof server.keepAliveTimeout === "number" ? server.keepAliveTimeout : 5000; - - // Node's connectionListenerInternal sets this so handlers can reach the server - // through req.socket.server (nodejs/node#13435). - socket.server = server; - - const connections = (server[kHttp1Connections] ??= new SafeSet()); - connections.add(socket); - socket[kHttp1ActiveRequests] = 0; - socket[kHttp1MessageOpen] = false; - - const kOnHeaders = HTTPParser.kOnHeaders | 0; - const kOnMessageBegin = HTTPParser.kOnMessageBegin | 0; - const kOnHeadersComplete = HTTPParser.kOnHeadersComplete | 0; - const kOnBody = HTTPParser.kOnBody | 0; - const kOnMessageComplete = HTTPParser.kOnMessageComplete | 0; - - // Mirror Node's connectionListenerInternal: carry maxHeaderSize / leniency / maxHeadersCount - // into the parser. https://github.com/nodejs/node/blob/main/lib/_http_server.js - const lenientFlags = calculateLenientFlags(server.httpValidation, server.insecureHTTPParser); - const parser = new HTTPParser(); - parser.initialize(HTTPParser.REQUEST, {}, server.maxHeaderSize || 0, lenientFlags); - parser.socket = socket; - socket.parser = parser; - // Node takes its parser from the node:_http_common freelist, which seeds MAX_HEADER_PAIRS. - const { maxHeadersCount } = server; - parser.maxHeaderPairs = typeof maxHeadersCount === "number" ? maxHeadersCount << 1 : MAX_HEADER_PAIRS; - - let req: Http1FallbackRequest | null = null; - let pendingUpgrade: Http1FallbackRequest | null = null; - // Node's per-connection state.requestsCount, behind server.maxRequestsPerSocket. - let requestsCount = 0; - - // Like node:_http_common: after the parser's first kOnHeaders flush (32 fields, or trailers), kOnHeadersComplete gets no headers and no url. - let flushedHeaders = []; - let flushedUrl = ""; - parser[kOnMessageBegin] = function onHttp1MessageBegin() { - socket[kHttp1MessageOpen] = true; - }; - - parser[kOnHeaders] = function onHttp1Headers(headers, url) { - for (let i = 0; i < headers.length; i++) $arrayPush(flushedHeaders, headers[i]); - flushedUrl += url; - }; - - parser[kOnHeadersComplete] = function onHttp1HeadersComplete( - versionMajor, - versionMinor, - rawHeaders, - methodNum, - url, - _statusCode, - _statusMessage, - upgrade, - shouldKeepAlive, - ) { - if (rawHeaders === undefined) { - rawHeaders = flushedHeaders; - flushedHeaders = []; - } - if (url === undefined) { - url = flushedUrl; - flushedUrl = ""; - } - - socket[kHttp1ActiveRequests]++; - - req = new IncomingMessageClass(socket); - req[kHeaderSource] = null; - req.socket = socket; - req.httpVersionMajor = versionMajor; - req.httpVersionMinor = versionMinor; - req.httpVersion = `${versionMajor}.${versionMinor}`; - req.url = url; - req.method = typeof methodNum === "number" ? allMethods[methodNum] : methodNum; - req.upgrade = upgrade; - // _addHeaderLine reads this off the request (Node's parserOnHeadersComplete copies it the same way). - if (server.joinDuplicateHeaders) { - req.joinDuplicateHeaders = true; - } - req._addHeaderLines(rawHeaders, rawHeaders.length); - - // Node's parserOnIncoming: upgrade only sticks for CONNECT or when an 'upgrade' listener - // exists; otherwise fall through to normal dispatch. Returning 2 makes llhttp stop after - // this message so tunnel bytes are never parsed as HTTP. - if (upgrade) { - req.upgrade = - req.method === "CONNECT" || - (typeof server.shouldUpgradeCallback === "function" - ? !!server.shouldUpgradeCallback(req) - : server.listenerCount("upgrade") > 0); - if (req.upgrade) { - pendingUpgrade = req; - return 2; - } - } - // The body is fed by the parser callbacks below; reading just resumes the - // socket - unless the pipelining read gate paused it (the gate's release - // resumes it instead). - req._read = function (_size) { - this._consuming = true; - if (!socket._paused && socket.readable) socket.resume(); - }; - - const res = new ServerResponseClass(req); - // The native dispatcher seeds these from the server; renderNativeHeaders - // reads them to decide the Keep-Alive auto-header bits, so the fallback - // path must carry them too or keep-alive responses lose their timeout line. - res._keepAliveTimeout = keepAliveTimeout; - const { maxRequestsPerSocket } = server; - res._maxRequestsPerSocket = maxRequestsPerSocket; - const handle = createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTimeout); - handle.onfinished = function () { - socket[kHttp1ActiveRequests] = Math.max(0, (socket[kHttp1ActiveRequests] || 1) - 1); - if (!shouldKeepAlive && !socket.destroyed) { - socket.end(); - } - }; - handle.onflushed = function () { - finishDrainedResponse(res); - }; - res[kHttp1ResponseHandle] = handle; - // Node's parserOnIncoming outgoing queue: pipelined requests parse while - // the previous response is still assigned (its 'finish' detach is a tick - // away), so queue this response instead of letting assignSocket throw - // ERR_HTTP_SOCKET_ASSIGNED. - if (socket._httpMessage) { - queuePipelinedResponse(socket, res, versionMajor < 1 || versionMinor < 1); - } else { - res.assignSocket(socket); - } - // node's resOnFinish: release the socket once the response completes, - // then either end the connection (a response that advertised Connection: - // close must not be followed by another one - the close path aborts the - // queued responses) or hand the socket to the next queued pipelined - // response, replaying whatever it buffered. - res.on("finish", function onFallbackResponseFinish() { - const finishedReq = this.req; - if (!finishedReq._consuming && !finishedReq._readableState.resumeScheduled) finishedReq._dump(); - this.detachSocket(socket); - // `_last`: onHttp1SocketEnd saw the client's FIN while this response owned the socket. - if (this[kMustCloseConnection] || this._last) { - if (typeof socket.destroySoon === "function") { - socket.destroySoon(); - } else if (!socket.writableEnded) { - socket.end(); - } - return; - } - advanceResponsePipeline(server, socket); - }); - - // Node's parserOnIncoming read gate: stop reading once the connection's - // outgoing side is backed up, so pipelined requests cannot flood it. - maybePauseFallbackReads(socket); - - // 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 - ) { - requestsCount++; - res.maxRequestsOnConnectionReached = maxRequestsPerSocket <= requestsCount; - if (maxRequestsPerSocket < requestsCount) { - server.emit("dropRequest", req, socket); - res.writeHead(503); - res.end(); - return 0; - } - } - - // 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 (continueExpression.test(String(expect))) { - if (server.listenerCount("checkContinue") > 0) { - server.emit("checkContinue", req, res); - } else { - res.writeContinue(); - server.emit("request", req, res); - } - } else if (server.listenerCount("checkExpectation") > 0) { - server.emit("checkExpectation", req, res); - } else { - res.writeHead(417); - res.end(); - } - return 0; - } - server.emit("request", req, res); - return 0; - }; - parser[kOnBody] = function onHttp1Body(chunk) { - if (req && !req._dumped) req.push(chunk); - }; - parser[kOnMessageComplete] = function onHttp1MessageComplete() { - socket[kHttp1MessageOpen] = false; - // Fields flushed after the header section are the trailers. - const rawTrailers = flushedHeaders; - if (rawTrailers.length !== 0) flushedHeaders = []; - flushedUrl = ""; - if (req) { - req.complete = true; - req._addHeaderLines(rawTrailers, rawTrailers.length); - req.push(null); - } - }; - - 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); - } - } - function onHttp1SocketData(data) { - const ret = parser.execute(data); - if (ret instanceof Error) { - onHttp1SocketError(ret, data); - return; - } - if (pendingUpgrade) { - // Node's onParserExecuteCommon: connection stops being HTTP here. Free parser, hand the - // socket over with the first tunnel bytes, destroy when nobody is listening (only CONNECT - // reaches here listener-less; Upgrade already fell through above). - const upgradeReq = pendingUpgrade; - pendingUpgrade = null; - socket.removeListener("data", onHttp1SocketData); - socket.removeListener("error", onHttp1SocketErrorListener); - socket.removeListener("end", onHttp1SocketEnd); - socket.removeListener("drain", onHttp1SocketDrain); - connections.delete(socket); - try { - parser.close(); - } catch {} - socket.parser = null; - const eventName = upgradeReq.method === "CONNECT" ? "connect" : "upgrade"; - const bodyHead = typeof ret === "number" ? data.slice(ret) : Buffer.alloc(0); - if (server.listenerCount(eventName) > 0) { - socket.readableFlowing = null; - server.emit(eventName, upgradeReq, socket, bodyHead); - } else { - socket.destroy(); - } - } - } - 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() { - const ret = parser.finish(); - if (ret instanceof Error) { - onHttp1SocketError(ret, undefined); - return; - } - if (!server.httpAllowHalfOpen) { - if (req && !req.complete) req.destroy(); - if (socket.writable) socket.end(); - return; - } - const httpMessage = lastPipelinedResponse(socket) ?? socket._httpMessage; - if (httpMessage) { - httpMessage._last = true; - } else if (socket.writable) { - socket.end(); - } - } - // Node's socketOnDrain: a transport-backpressure pause lifts when the - // socket drains (a queued-bytes pause lifts from the pipeline advance). - function onHttp1SocketDrain() { - resumeFallbackReadsOnDrain(socket); - // Node's socketOnDrain then emits 'drain' on the response that waits for it. - socket._httpMessage?.[kHttp1ResponseHandle]?.socketDrained(); - } - socket.on("data", onHttp1SocketData); - socket.on("error", onHttp1SocketErrorListener); - socket.once("end", onHttp1SocketEnd); - socket.on("drain", onHttp1SocketDrain); - socket.once("finish", () => { - 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", () => { - 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(() => - failPendingWriteCallbacks(inflight, socket.errored ?? inflight.errored ?? $ERR_STREAM_DESTROYED("write")), - ); - // Like the native socket's close path (Node's socketOnClose -> - // abortIncoming): abort the in-flight request, then the responses (and - // requests) still queued behind it, so they all emit 'close'. The - // in-flight response's own 'close' comes from onServerResponseClose, - // installed by assignSocket. - const inflightReq = inflight?.req; - if (inflightReq && !inflightReq.destroyed) { - if (inflightReq.listenerCount("error") > 0) { - inflightReq.destroy(new ConnResetException("aborted")); - } else { - inflightReq.destroy(); - } - } - abortQueuedPipelinedResponses(socket); - try { - parser.close(); - } catch {} - }); -} - -function closeIdleHttp1Connections(server) { - const connections = server[kHttp1Connections]; - if (!connections) return; - for (const socket of connections) { - if (!socket[kHttp1ActiveRequests] && !socket[kHttp1MessageOpen] && !socket.destroyed) { - socket.destroy(); - } - } -} - -function closeAllHttp1Connections(server) { - const connections = server[kHttp1Connections]; - if (!connections) return; - for (const socket of connections) { - if (!socket.destroyed) socket.destroy(); - } -} - -export default { - connectionListenerHTTP1, - closeIdleHttp1Connections, - closeAllHttp1Connections, - kHttp1Connections, -}; diff --git a/src/js/internal/net/errors.ts b/src/js/internal/net/errors.ts new file mode 100644 index 000000000000..50b13c11c807 --- /dev/null +++ b/src/js/internal/net/errors.ts @@ -0,0 +1,53 @@ +// Apart from internal/shared, which every program loads. +const { ErrnoException, ExceptionWithHostPort } = require("internal/shared"); +const { getSystemErrorMessage, getSystemErrorName } = require("node:util"); + +// https://github.com/nodejs/node/blob/v26.5.0/lib/internal/errors.js#L689-L716 +function uvExceptionWithHostPort(err: number, syscall: string, address: string | null, port?: number) { + const code = getSystemErrorName(err); + let details = ""; + if (port && port > 0) { + details = ` ${address}:${port}`; + } else if (address) { + details = ` ${address}`; + } + + const error: any = new Error(`${syscall} ${code}: ${getSystemErrorMessage(err)}${details}`); + error.code = code; + error.errno = err; + error.syscall = syscall; + error.address = address; + if (port) { + error.port = port; + } + return error; +} + +// The error net.Server emits for what Bun.serve() or Bun.listen() threw. `address` is null for a descriptor, `port` is -1 for a pipe. +function listenException(err, address: string | null, port?: number) { + // Node emits a failed lookup's error as dns.lookup() made it. + if (err?.syscall === "getaddrinfo") return err; + let code = err?.code; + if (address === null) { + // https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L2063-L2070 + if (code === "EBADF" || code === "ENOTSOCK" || code === "EOPNOTSUPP") code = "EINVAL"; + } else if (port === -1) { + // https://github.com/libuv/libuv/blob/v1.51.0/src/unix/pipe.c#L58-L128 + if (code === "ENAMETOOLONG") code = "EINVAL"; + else if (code === "ENOENT") code = "EACCES"; + } + const errno = process.binding("uv")[`UV_${code}`]; + return errno === undefined ? err : uvExceptionWithHostPort(errno, "listen", address, port); +} + +// The error dgram.Socket reports for what Bun.udpSocket() threw. `address` is undefined for a descriptor, which Node opens. +function bindException(err, address?: string, port?: number) { + const syscall = address === undefined ? "open" : "bind"; + const errno = err?.syscall === syscall ? process.binding("uv")[`UV_${err.code}`] : undefined; + if (errno === undefined) return err; + return address === undefined + ? new ErrnoException(errno, syscall) + : new ExceptionWithHostPort(errno, syscall, address, port); +} + +export default { listenException, bindException }; diff --git a/src/js/internal/net/symbols.ts b/src/js/internal/net/symbols.ts index 0f05738b1e81..a91d3f1bb0f5 100644 --- a/src/js/internal/net/symbols.ts +++ b/src/js/internal/net/symbols.ts @@ -1,12 +1,24 @@ 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"), + // net.Server.prototype method. node:http calls it on its own server, whose connections uWS accepts. + kForeignListener: Symbol("kForeignListener"), + // A function that node:http puts on a socket it handed to 'connect' or 'upgrade'. net.Socket calls it when the connection has closed. + kHandleClosed: Symbol("kHandleClosed"), 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"), + // 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/quic/http2util.ts b/src/js/internal/quic/http2util.ts index ab8eb8998c99..4d2ec382d602 100644 --- a/src/js/internal/quic/http2util.ts +++ b/src/js/internal/quic/http2util.ts @@ -8,11 +8,7 @@ const ArrayIsArray = Array.isArray; // Matches Node's sensitiveHeaders marker used by node:http2. const kSensitiveHeaders = Symbol.for("nodejs.http2.sensitiveHeaders"); -// Verbatim from Node lib/internal/http.js (checkIsHttpToken). -const tokenRegExp = /^[\^_`a-zA-Z\-0-9!#$%&'*+.|~]+$/; -function checkIsHttpToken(val) { - return tokenRegExp.test(val); -} +const { checkIsHttpToken } = require("internal/validators"); const kValidPseudoHeaders = new Set([":status", ":method", ":authority", ":scheme", ":path", ":protocol"]); @@ -195,6 +191,8 @@ function buildNgHeaderString( } export default { + kValidPseudoHeaders, + kSingleValueFields, assertValidPseudoHeader, assertValidPseudoHeaderTrailer, buildNgHeaderString, diff --git a/src/js/internal/quic/quic.ts b/src/js/internal/quic/quic.ts index 74b99964a25d..40b41889bf1f 100644 --- a/src/js/internal/quic/quic.ts +++ b/src/js/internal/quic/quic.ts @@ -49,7 +49,6 @@ function wrapCertificate(der: Uint8Array | undefined) { // Stand-ins for the Node.js primordials used by the original source. const ArrayIsArray = Array.isArray; -const StringPrototypeStartsWith = uncurryThis(String.prototype.startsWith); const StringPrototypeIncludes = uncurryThis(String.prototype.includes); const NumberIsInteger = Number.isInteger; const NumberIsNaN = Number.isNaN; @@ -148,25 +147,6 @@ 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 kSocketAddressHandle = Symbol("kSocketAddressHandle"); -Object.defineProperty(SocketAddress.prototype, kSocketAddressHandle, { - __proto__: null, - get() { - return this; - }, - configurable: true, -}); -const kBlockListHandle = Symbol("kBlockListHandle"); -Object.defineProperty(BlockList.prototype, kBlockListHandle, { - __proto__: null, - get() { - return this; - }, - configurable: true, -}); function InternalSocketAddress(handle): void { return handle; } @@ -224,6 +204,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; @@ -252,6 +233,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; @@ -369,6 +351,17 @@ const kNilDatagramId = 0n; const endpointRegistry: Set = 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: (endpoint: QuicEndpoint) => void; process.on("exit", () => { for (const endpoint of endpointRegistry) { @@ -843,6 +836,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 @@ -1023,6 +1021,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(this: StreamHandle) { debug("stream blocked callback", this[kOwner]); this[kOwner]![kBlocked](); @@ -1037,16 +1041,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); @@ -1060,6 +1055,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(this: StreamHandle, headers, kind) { debug(`stream ${this[kOwner]!.id} headers callback`, headers, kind); this[kOwner]![kHeaders](headers, kind); @@ -1266,10 +1270,9 @@ function invokeOnerror(fn: Callback, error: unknown) { function validateBody(body) { if (body === undefined) return body; - if (isArrayBuffer(body) || isSharedArrayBuffer(body) || isArrayBufferView(body)) { + if (isArrayBuffer(body) || isSharedArrayBuffer(body) || isArrayBufferView(body) || isBlob(body)) { return body; } - if (isBlob(body)) return body; if (typeof body === "string") { return Buffer.from(body, "utf8"); @@ -1310,6 +1313,12 @@ function parseHeaderPairs(pairs) { return block; } +// One native flag gates all three kinds of header block, as in Node's updateHeaderInterest(). +function updateHeaderInterest(inner: QuicStreamInner) { + inner.state.wantsHeaders = + inner.onheaders !== undefined || inner.ontrailers !== undefined || inner.oninfo !== undefined; +} + /** * Applies session and stream callbacks from an options object to a session. * @param {QuicSession} session @@ -1383,14 +1392,21 @@ function configureOutbound(handle: StreamHandle, stream: QuicStream, 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; } @@ -1405,11 +1421,17 @@ function configureOutbound(handle: StreamHandle, stream: QuicStream, 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; } @@ -1485,9 +1507,9 @@ async function writeBatchWithDrain(handle: StreamHandle, stream: QuicStream, bat } async function consumeAsyncSource(handle: StreamHandle, stream: QuicStream, source) { - handle.initStreamingSource(); - initStreamingBackpressure(stream); try { + handle.initStreamingSource(); + initStreamingBackpressure(stream); const normalized = streamFrom(source); for await (const batch of normalized) { if (stream.destroyed) return; @@ -1504,11 +1526,11 @@ async function consumeAsyncSource(handle: StreamHandle, stream: QuicStream, sour } async function consumeSyncSource(handle: StreamHandle, stream: QuicStream, source) { - handle.initStreamingSource(); - initStreamingBackpressure(stream); - const normalized: Iterable = streamFromSync(source); - const iter: Iterator = normalized[SymbolIterator](); try { + handle.initStreamingSource(); + initStreamingBackpressure(stream); + const normalized: Iterable = streamFromSync(source); + const iter: Iterator = normalized[SymbolIterator](); while (true) { if (stream.destroyed) return; const { value: batch, done } = iter.next(); @@ -1536,6 +1558,7 @@ function isSyncIterable(obj) { let getQuicStreamState: (stream: QuicStream) => QuicStreamInner["state"]; let getQuicSessionState: (session: QuicSession | undefined) => QuicSessionInner["state"]; let isQuicSessionDestroying: (session: QuicSession | undefined) => QuicSessionInner["destroying"]; +let isQuicSessionClosedByNative; let getQuicEndpointState: (endpoint: QuicEndpoint) => QuicEndpointInner["state"]; let assertIsQuicEndpoint: (val: unknown) => asserts val is QuicEndpoint; let assertIsQuicStream: (val: unknown) => asserts val is QuicStream; @@ -1544,9 +1567,24 @@ let assertHeadersSupported: (session: QuicSession | undefined) => void; let assertEndpointNotClosedOrClosing: (endpoint: QuicEndpoint) => void; let assertEndpointIsNotBusy: (endpoint: QuicEndpoint) => void; let isQuicStream: (val: unknown) => val is QuicStream; +let isReceiveOnlyQuicStream; let isQuicSession: (val: unknown) => val is QuicSession; let isQuicEndpoint: (val: unknown) => val is QuicEndpoint; +// 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) { case kCloseContextClose: { @@ -1636,6 +1674,7 @@ interface QuicStreamInner { state: QuicStreamState; stats: QuicStreamStats | undefined; pendingClose: PendingPromise | undefined; + pendingOpen: PendingPromise | undefined; reader: QuicStreamReader | undefined; destroying: boolean; iteratorLocked: boolean; @@ -1648,11 +1687,13 @@ interface QuicStreamInner { onerror: Callback | undefined; onblocked: Callback | undefined; onreset: Callback | undefined; + onstopsending: Callback | undefined; onheaders: Callback | undefined; ontrailers: Callback | undefined; oninfo: Callback | undefined; onwanttrailers: Callback | undefined; earlySnapshot?: boolean; + finReceivedSnapshot?: boolean; } class QuicStream { @@ -1665,6 +1706,7 @@ class QuicStream { state: undefined as unknown as QuicStreamState, stats: undefined, pendingClose: undefined, + pendingOpen: undefined, reader: undefined, destroying: false, iteratorLocked: false, @@ -1677,6 +1719,7 @@ class QuicStream { onerror: undefined, onblocked: undefined, onreset: undefined, + onstopsending: undefined, onheaders: undefined, ontrailers: undefined, oninfo: undefined, @@ -1688,6 +1731,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): asserts val is QuicStream { if (!isQuicStream(val)) { throw $ERR_INVALID_THIS("QuicStream"); @@ -1723,6 +1771,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" }); @@ -1763,6 +1814,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; } /** @@ -1775,6 +1830,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 @@ -1864,6 +1928,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); @@ -1875,13 +1958,12 @@ class QuicStream { const inner = this.#inner; if (fn === undefined) { inner.onheaders = undefined; - inner.state.wantsHeaders = false; } else { validateFunction(fn, "onheaders"); assertHeadersSupported(inner.session); inner.onheaders = FunctionPrototypeBind(fn, this); - inner.state.wantsHeaders = true; } + updateHeaderInterest(inner); } /** @type {Function|undefined} */ @@ -1900,6 +1982,7 @@ class QuicStream { assertHeadersSupported(inner.session); inner.oninfo = FunctionPrototypeBind(fn, this); } + updateHeaderInterest(inner); } /** @type {Function|undefined} */ @@ -1918,6 +2001,7 @@ class QuicStream { assertHeadersSupported(inner.session); inner.ontrailers = FunctionPrototypeBind(fn, this); } + updateHeaderInterest(inner); } /** @type {Function|undefined} */ @@ -2094,8 +2178,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; @@ -2205,6 +2294,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; @@ -2357,12 +2462,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); } @@ -2432,7 +2532,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", @@ -2490,6 +2591,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({ @@ -2510,6 +2623,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); @@ -2519,13 +2633,12 @@ class QuicStream { inner.pendingClose.resolve = undefined; inner.onblocked = undefined; inner.onreset = undefined; + inner.onstopsending = undefined; inner.onheaders = undefined; inner.onerror = undefined; inner.ontrailers = undefined; inner.oninfo = undefined; inner.onwanttrailers = undefined; - inner.headers = undefined; - inner.pendingTrailers = undefined; this.#handle = undefined; this[kDrain]?.(); if (inner.fileHandle !== undefined) { @@ -2534,6 +2647,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"); @@ -2563,6 +2684,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; @@ -2570,7 +2697,6 @@ class QuicStream { switch (kindName) { case "initial": - assert(inner.onheaders, "Unexpected stream headers event"); inner.headers ??= block; if (onStreamHeadersChannel.hasSubscribers) { onStreamHeadersChannel.publish({ @@ -2580,7 +2706,10 @@ class QuicStream { headers: block, }); } - safeCallbackInvoke(inner.onheaders, this, block); + { + const { onheaders } = inner; + if (onheaders) safeCallbackInvoke(onheaders, this, block); + } break; case "trailing": if (onStreamTrailersChannel.hasSubscribers) { @@ -2618,7 +2747,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; @@ -2630,6 +2769,9 @@ class QuicStream { if (depth < 0) { return "QuicStream { }"; } + if (beingInspected.has(this)) { + return "[Circular]"; + } const opts = { __proto__: null, @@ -2639,7 +2781,8 @@ class QuicStream { const { id, direction, pending, stats, session } = this; - return `QuicStream ${inspect( + return `QuicStream ${inspectOnce( + this, { __proto__: null, id, @@ -2718,6 +2861,7 @@ interface QuicSessionInner { isPendingClose: boolean; selfInitiatedClose: boolean; destroying: boolean; + closedByNative: boolean; handshakeCompleted: boolean; pendingClose: PendingPromise; pendingOpen: PendingPromise; @@ -2760,6 +2904,7 @@ class QuicSession { isPendingClose: false, selfInitiatedClose: false, destroying: false, + closedByNative: false, handshakeCompleted: false, pendingClose: PromiseWithResolvers(), pendingOpen: PromiseWithResolvers(), @@ -2812,6 +2957,11 @@ class QuicSession { assertIsQuicSession(session); return session.#inner.destroying; }; + + isQuicSessionClosedByNative = function (session) { + assertIsQuicSession(session); + return session.#inner.closedByNative; + }; } /** @@ -3646,6 +3796,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(); @@ -4022,6 +4173,9 @@ class QuicSession { if (depth < 0) { return "QuicSession { }"; } + if (beingInspected.has(this)) { + return "[Circular]"; + } const opts = { __proto__: null, @@ -4031,7 +4185,8 @@ class QuicSession { const { isPendingClose: closing, endpoint, path, state, stats, streams } = this.#inner; - return `QuicSession ${inspect( + return `QuicSession ${inspectOnce( + this, { closed: this.closed, closing, @@ -4216,7 +4371,6 @@ interface QuicEndpointInner { busy: boolean; isPendingClose: boolean; listening: boolean; - clientHttp: boolean | undefined; pendingClose: PendingPromise; pendingError: unknown; sessions: Set; @@ -4236,7 +4390,6 @@ class QuicEndpoint { busy: false, isPendingClose: false, listening: false, - clientHttp: undefined, pendingClose: PromiseWithResolvers(), pendingError: undefined, sessions: new SafeSet(), @@ -4311,10 +4464,8 @@ class QuicEndpoint { udpSendBufferSize, udpTTL, idleTimeout, - validateAddress, ipv6Only, reusePort, - cc, resetTokenSecret, tokenSecret, } = options; @@ -4396,39 +4547,23 @@ class QuicEndpoint { } } + // The other options are checked like in Node, and have no effect. return { __proto__: null, - address: address?.[kSocketAddressHandle], - retryTokenExpiration, - tokenExpiration, + address, maxConnectionsPerHost, maxConnectionsTotal, disableStatelessReset, - addressLRUSize, - retryRate, - retryBurst, statelessResetRate, statelessResetBurst, - versionNegotiationRate, - versionNegotiationBurst, - immediateCloseRate, - immediateCloseBurst, - sessionCreationRate, - sessionCreationBurst, - blockList: blockList?.[kBlockListHandle], + blockList, blockListPolicy, - rxDiagnosticLoss, - txDiagnosticLoss, udpReceiveBufferSize, udpSendBufferSize, udpTTL, idleTimeout, - validateAddress, ipv6Only, reusePort, - cc, - resetTokenSecret, - tokenSecret, }; } @@ -4712,14 +4847,6 @@ class QuicEndpoint { return this.#inner.listening; } - get [kClientHttp]() { - return this.#inner.clientHttp; - } - - [kNoteClientHttp](wantHttp) { - this.#inner.clientHttp ??= wantHttp; - } - /** @type {boolean} */ get destroyed() { assertIsQuicEndpoint(this); @@ -4882,6 +5009,9 @@ class QuicEndpoint { if (depth < 0) { return "QuicEndpoint { }"; } + if (beingInspected.has(this)) { + return "[Circular]"; + } const opts = { __proto__: null, @@ -4891,7 +5021,8 @@ class QuicEndpoint { const { address, busy, isPendingClose: closing, listening, sessions, stats, state } = this.#inner; - return `QuicEndpoint ${inspect( + return `QuicEndpoint ${inspectOnce( + this, { address, busy, @@ -4912,28 +5043,9 @@ class QuicEndpoint { } } -/** - * MUST match `alpn_cstr_is_http` in `src/runtime/node/quic/endpoint.rs` - * (which cross-references this fn). `alpn` is the RAW, not-yet-validated - * `options.alpn`: never throw here -- a non-string is treated as the HTTP/3 - * default and rejected with the proper error by `processTlsOptions` later. - * @param {string|string[]|undefined} alpn - */ -function alpnWantsHttp(alpn) { - const first = ArrayIsArray(alpn) ? alpn[0] : alpn; - return typeof first !== "string" || first === "h3" || StringPrototypeStartsWith(first, "h3-"); -} - -function findSuitableEndpoint(wantHttp) { +function findSuitableEndpoint() { for (const endpoint of endpointRegistry) { - if (!endpoint.destroyed && !endpoint.closing && !endpoint.busy) { - const mode = endpoint[kClientHttp]; - if (mode !== undefined && mode !== wantHttp) { - continue; - } - if (endpoint.listening) { - continue; - } + if (!endpoint.destroyed && !endpoint.closing && !endpoint.busy && !endpoint.listening) { return endpoint; } } @@ -4946,7 +5058,7 @@ function findSuitableEndpoint(wantHttp) { * @param {boolean} forServer * @returns {QuicEndpoint} */ -function processEndpointOption(endpoint, reuseEndpoint = true, forServer = false, wantHttp = true): QuicEndpoint { +function processEndpointOption(endpoint, reuseEndpoint, forServer): QuicEndpoint { if (isQuicEndpoint(endpoint)) { return endpoint; } @@ -4954,7 +5066,7 @@ function processEndpointOption(endpoint, reuseEndpoint = true, forServer = false return new QuicEndpoint(endpoint); } if (reuseEndpoint && !forServer) { - const existing = findSuitableEndpoint(wantHttp); + const existing = findSuitableEndpoint(); if (existing !== undefined) return existing; } return new QuicEndpoint(); @@ -5254,13 +5366,8 @@ function processSessionOptions(options, config: ProcessSessionOptions = kEmptyOb qlog = false, sessionTicket, token, - maxPayloadSize, - unacknowledgedPacketThreshold = 0, handshakeTimeout, - initialRtt, keepAlive, - maxStreamWindow, - maxWindow, cc, datagramDropPolicy = "drop-oldest", drainingPeriodMultiplier = 3, @@ -5308,6 +5415,8 @@ function processSessionOptions(options, config: ProcessSessionOptions = kEmptyOb validateInteger(maxDatagramSendAttempts, "options.maxDatagramSendAttempts", 1, 255); + // Native code reads this copy, so each option is read from the caller's object once, and what was checked is what is used. + const checkedTransportParams = { __proto__: null }; // Node accepts number | bigint and rejects everything else with ERR_INVALID_ARG_TYPE. for (const name of [ "initialMaxStreamDataBidiLocal", @@ -5334,9 +5443,10 @@ function processSessionOptions(options, config: ProcessSessionOptions = kEmptyOb ) { throw $ERR_INVALID_ARG_VALUE(`options.transportParams.${name}`, v, "must be a non-negative number or bigint"); } + checkedTransportParams[name] = v; } - const { preferredAddressIpv4, preferredAddressIpv6 } = transportParams; + const { preferredAddressIpv4, preferredAddressIpv6, disableActiveMigration } = transportParams; if (preferredAddressIpv4 !== undefined) { if (!SocketAddress.isSocketAddress(preferredAddressIpv4)) { throw $ERR_INVALID_ARG_TYPE( @@ -5370,9 +5480,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, @@ -5384,6 +5491,9 @@ function processSessionOptions(options, config: ProcessSessionOptions = kEmptyOb enableConnectProtocol = false, enableDatagrams = true, } = application; + + const actualEndpoint = processEndpointOption(endpoint, reuseEndpoint, forServer); + if (sessionTicket !== undefined) { if (!isArrayBufferView(sessionTicket)) { throw $ERR_INVALID_ARG_TYPE("options.sessionTicket", ["ArrayBufferView"], sessionTicket); @@ -5421,11 +5531,7 @@ function processSessionOptions(options, config: ProcessSessionOptions = kEmptyOb version, minVersion, preferredAddressPolicy: getPreferredAddressPolicy(preferredAddressPolicy), - transportParams: { - ...transportParams, - preferredAddressIpv4: preferredAddressIpv4?.[kSocketAddressHandle], - preferredAddressIpv6: preferredAddressIpv6?.[kSocketAddressHandle], - }, + transportParams: { ...checkedTransportParams, preferredAddressIpv4, preferredAddressIpv6, disableActiveMigration }, tls: { ...processTlsOptions(options, forServer), verifyPeerStrict: verifyPeer === "strict", @@ -5433,19 +5539,11 @@ function processSessionOptions(options, config: ProcessSessionOptions = kEmptyOb }, verifyPeer, qlog, - maxPayloadSize, - unacknowledgedPacketThreshold, handshakeTimeout, - initialRtt, keepAlive, - maxStreamWindow, - maxWindow, sessionTicket, - token, cc, datagramDropPolicy, - drainingPeriodMultiplier, - maxDatagramSendAttempts, streamIdleTimeout, application: normalizedApplication, onerror, @@ -5518,9 +5616,7 @@ async function connect(address, options: SessionOptions = kEmptyObject) { }); } - const session = endpoint[kConnect](address[kSocketAddressHandle], rest); - - endpoint[kNoteClientHttp](alpnWantsHttp(options.alpn)); + const session = endpoint[kConnect](address, rest); if (onEndpointClientSessionChannel.hasSubscribers) { onEndpointClientSessionChannel.publish({ 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/shared.ts b/src/js/internal/shared.ts index fcdd8a6e81f9..30b6b21be223 100644 --- a/src/js/internal/shared.ts +++ b/src/js/internal/shared.ts @@ -440,19 +440,9 @@ const kHandle = Symbol("kHandle"); const kClusterOwner = Symbol("kClusterOwner"); const kCapture = Symbol("kCapture"); const kShapeMode = Symbol("shapeMode"); -const kAutoDestroyed = Symbol("kAutoDestroyed"); const kWeakHandler = Symbol("kWeak"); const kCustomPromisifyArgsSymbol = Symbol("customPromisifyArgs"); -declare global { - namespace $Node { - interface Readable { - /** Set when the stream destroyed itself after its end, as `autoDestroy` asks. */ - [kAutoDestroyed]?: boolean; - } - } -} - // Filled by the module that writes the functions. What lies in a public prototype object when another module loads may be a program's patch. const originals: { eventEmitter: @@ -491,7 +481,6 @@ export default { kClusterOwner, kCapture, kShapeMode, - kAutoDestroyed, kWeakHandler, kCustomPromisifyArgsSymbol, kEmptyObject, diff --git a/src/js/internal/sql/pool.ts b/src/js/internal/sql/pool.ts index 6dea513df10f..ca52c6ce04c7 100644 --- a/src/js/internal/sql/pool.ts +++ b/src/js/internal/sql/pool.ts @@ -44,7 +44,7 @@ export type NativeCreateConnection = ( password: string, database: string, sslmode: SSLMode, - tls: Bun.TLSOptions | true | null, // true: an empty TLSOptions + tls: Bun.TLSOptions | null, query: string, path: string, onConnected: (err: Error | null, connection: ConnectionHandle) => void, @@ -439,7 +439,7 @@ async function createPooledConnectionHandle( password, database || "", sslMode, - (tls || null) as Bun.TLSOptions | true | null, + (tls || null) as Bun.TLSOptions | null, query || "", path || "", onConnected, diff --git a/src/js/internal/streams/native-readable.ts b/src/js/internal/streams/native-readable.ts index b2f52f8dc8eb..db0cb8074241 100644 --- a/src/js/internal/streams/native-readable.ts +++ b/src/js/internal/streams/native-readable.ts @@ -6,11 +6,12 @@ // Bun, `fromWeb` is able to check if the stream is backed by a native handle, // to which it will take this path. const Readable = require("internal/streams/readable"); -const transferToNativeReadable: (stream: ReadableStream) => void = $newCppFunction( +const transferToNativeReadable: (stream: ReadableStream) => NativePtr | undefined = $newCppFunction( "streams/BunStreamConsumers.cpp", "jsFunctionTransferToNativeReadableStream", 1, ); +const { addAbortSignal } = require("internal/streams/add-abort-signal"); const { errorOrDestroy } = require("internal/streams/destroy"); const kRefCount = Symbol("refCount"); @@ -57,10 +58,24 @@ let debugId = 0; function constructNativeReadable(readableStream: ReadableStream, options: $Node.ReadableOptions): NativeReadable { $assert(typeof readableStream === "object" && readableStream instanceof ReadableStream, "Invalid readable stream"); - const bunNativePtr = readableStream.$bunNativePtr; // A closed stream with nothing left to read has no native handle, so read() ends it immediately. - $assert(bunNativePtr === undefined || typeof bunNativePtr === "object", "Invalid native ptr"); + return fromNativeHandle(transferToNativeReadable(readableStream), options, undefined); +} + +// Readable.fromWeb(): returns undefined for a stream that something has started, whose queue only a reader can empty. +function tryConstructNativeReadable(readableStream: ReadableStream, options, signal): NativeReadable | undefined { + const bunNativePtr = transferToNativeReadable(readableStream); + if (bunNativePtr === undefined) return undefined; + try { + return fromNativeHandle(bunNativePtr, options, signal); + } catch (error) { + bunNativePtr.cancel(error); + throw error; + } +} +// The caller took the handle first, because a getter in `options` could start the stream. +function fromNativeHandle(bunNativePtr: NativePtr | undefined, options, signal): NativeReadable { const stream = new Readable(options) as NativeReadable; stream._read = read; stream._destroy = destroy; @@ -88,12 +103,8 @@ function constructNativeReadable(readableStream: ReadableStream, options: $Node. stream.$start = ensureConstructed; } - // https://github.com/oven-sh/bun/pull/12801 - // https://github.com/oven-sh/bun/issues/9555 - // There may be a ReadableStream.Strong handle to the ReadableStream. - // We can't update those handles to point to the NativeReadable from JS - // So we instead mark it as no longer usable, and create a new NativeReadable - transferToNativeReadable(readableStream); + // Not through `options`: an aborted signal destroys the stream at once, and only `destroy` above cancels the handle. + if (signal) addAbortSignal(signal, stream); $debug(`[${stream.debugId}] constructed!`); @@ -299,4 +310,4 @@ function unref(this: NativeReadable) { } } -export default { constructNativeReadable }; +export default { constructNativeReadable, tryConstructNativeReadable }; diff --git a/src/js/internal/streams/readable.ts b/src/js/internal/streams/readable.ts index 4e273f20ad23..180716fa4ad0 100644 --- a/src/js/internal/streams/readable.ts +++ b/src/js/internal/streams/readable.ts @@ -25,7 +25,6 @@ const { validateAbortSignal, validateObject } = require("internal/validators"); const { StringDecoder } = require("node:string_decoder"); const from = require("internal/streams/from"); const { SafeSet } = require("internal/primordials"); -const { kAutoDestroyed } = require("internal/shared"); const isStandardOutputStream: (value: unknown) => boolean | undefined = $newCppFunction( "BunProcess.cpp", "jsFunctionIsStandardOutputStream", @@ -1703,7 +1702,6 @@ function endReadableNT(state, stream) { (wState.finished || wState.writable === false)); if (autoDestroy) { - stream[kAutoDestroyed] = true; // workaround for node:http Server not using node:net Server stream.destroy(); } } diff --git a/src/js/internal/test/binding.ts b/src/js/internal/test/binding.ts index 593c7c31e10f..18dfe978b47a 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/tls.ts b/src/js/internal/tls.ts index c3abeb4db86c..6c6aacb9eac2 100644 --- a/src/js/internal/tls.ts +++ b/src/js/internal/tls.ts @@ -65,25 +65,14 @@ function isValidTLSArray(obj: unknown) { // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/secure-context.js#L74-L87 const VALID_TLS_ERROR_MESSAGE_TYPES = "string or an instance of Buffer, TypedArray, or DataView"; -// BoringSSL TLS1_x_VERSION constants (from openssl/tls1.h). The native TLS -// config applies these via SSL_CTX_set_min/max_proto_version. -const TLS1_VERSION = 0x0301; -const TLS1_1_VERSION = 0x0302; -const TLS1_2_VERSION = 0x0303; -const TLS1_3_VERSION = 0x0304; -function tlsStringToProtocolVersion(v: string | null | undefined) { - switch (v) { - case "TLSv1": - return TLS1_VERSION; - case "TLSv1.1": - return TLS1_1_VERSION; - case "TLSv1.2": - return TLS1_2_VERSION; - case "TLSv1.3": - return TLS1_3_VERSION; - default: - return 0; - } +const protocolVersionCode: (name: string) => number = $bindgen("SSLConfig.bind.ts", "protocolVersionCode"); + +// The code SSL_CTX_set_min/max_proto_version takes, 0 for none. A `fallback` that names no version counts as none, as on 1.4.4 (Node throws). +function toProtocolVersion(which: string, version: unknown, fallback: unknown) { + if (version == null) return typeof fallback === "string" ? protocolVersionCode(fallback) : 0; + const code = typeof version === "string" ? protocolVersionCode(version) : 0; + if (code === 0) throw $ERR_TLS_INVALID_PROTOCOL_VERSION(JSON.stringify(version), which); + return code; } // Matches Node: SSLv2/SSLv3 methods are disabled, anything unrecognized is an @@ -137,29 +126,30 @@ function validateSecureProtocol( // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L120 function secureProtocolToVersionRange(secureProtocol: string | null | undefined) { if (typeof secureProtocol !== "string") return null; + let name; if ( secureProtocol === "TLSv1_method" || secureProtocol === "TLSv1_client_method" || secureProtocol === "TLSv1_server_method" ) - return [TLS1_VERSION, TLS1_VERSION]; - if ( + name = "TLSv1"; + else if ( secureProtocol === "TLSv1_1_method" || secureProtocol === "TLSv1_1_client_method" || secureProtocol === "TLSv1_1_server_method" ) - return [TLS1_1_VERSION, TLS1_1_VERSION]; - if ( + name = "TLSv1.1"; + else if ( secureProtocol === "TLSv1_2_method" || secureProtocol === "TLSv1_2_client_method" || secureProtocol === "TLSv1_2_server_method" ) - return [TLS1_2_VERSION, TLS1_2_VERSION]; - return null; + name = "TLSv1.2"; + else return null; + const version = protocolVersionCode(name); + return [version, version]; } -const VALID_TLS_VERSIONS = new Set(["TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3"]); - const SUPPORTED_ECDH_GROUPS = new Set([ "P-256", "prime256v1", @@ -249,10 +239,6 @@ function validateSecureContextOptions(options) { if (dhparam === "auto") { throw $ERR_CRYPTO_UNSUPPORTED_OPERATION("Automatic DH parameter selection is not supported"); } - if (minVersion != null && !VALID_TLS_VERSIONS.has(minVersion)) - throw $ERR_TLS_INVALID_PROTOCOL_VERSION(JSON.stringify(minVersion), "minimum"); - if (maxVersion != null && !VALID_TLS_VERSIONS.has(maxVersion)) - throw $ERR_TLS_INVALID_PROTOCOL_VERSION(JSON.stringify(maxVersion), "maximum"); if (ticketKeys !== undefined && ticketKeys !== null) { validateBuffer(ticketKeys, "options.ticketKeys"); const ticketKeysByteLength = ticketKeys.byteLength; @@ -279,6 +265,59 @@ function validateSecureContextOptions(options) { } } +// Reads each option by name, as Node does, so an inherited one, a getter and a non-enumerable one count. A spread would drop them. +function readSecureContextOptions(options) { + return { + __proto__: null, + pfx: options.pfx, + key: options.key, + passphrase: options.passphrase, + cert: options.cert, + clientCertEngine: options.clientCertEngine, + ca: options.ca, + minVersion: options.minVersion, + maxVersion: options.maxVersion, + secureProtocol: options.secureProtocol, + crl: options.crl, + sigalgs: options.sigalgs, + ciphers: options.ciphers, + ecdhCurve: options.ecdhCurve, + dhparam: options.dhparam, + honorCipherOrder: options.honorCipherOrder, + secureOptions: options.secureOptions, + sessionTimeout: options.sessionTimeout, + ticketKeys: options.ticketKeys, + privateKeyIdentifier: options.privateKeyIdentifier, + privateKeyEngine: options.privateKeyEngine, + allowPartialTrustChain: options.allowPartialTrustChain, + // Not in Node: what else the native parser takes. ALPNProtocols belongs to the socket, not to the context. + servername: options.servername, + serverName: options.serverName, + keyFile: options.keyFile, + certFile: options.certFile, + caFile: options.caFile, + dhParamsFile: options.dhParamsFile, + lowMemoryMode: options.lowMemoryMode, + requestCert: options.requestCert, + rejectUnauthorized: options.rejectUnauthorized, + clientRenegotiationLimit: options.clientRenegotiationLimit, + clientRenegotiationWindow: options.clientRenegotiationWindow, + }; +} + +// https.Server takes Node's options only, as on 1.4.4. BoringSSL has no DHE suites to apply dhparam to. +const ignoredByHttpsServer = { + dhparam: undefined, + keyFile: undefined, + certFile: undefined, + caFile: undefined, + dhParamsFile: undefined, + lowMemoryMode: undefined, + serverName: undefined, + clientRenegotiationLimit: undefined, + clientRenegotiationWindow: undefined, +}; + const SSL_OP_CIPHER_SERVER_PREFERENCE = 0x00400000; let NativeSecureContext: $Node.NativeSecureContextConstructor | undefined; @@ -297,8 +336,6 @@ function processPfxOptions(options: Record): Record { 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; @@ -312,7 +349,8 @@ function processPfxOptions(options: Record): Record { 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 @@ -367,6 +405,25 @@ function stripTls13CipherNames(ciphers: string): { cipherList: string; tls13Only return { cipherList, tls13Only: cipherList === "" && kept.length !== parts.length }; } +// `ciphers`, `secureProtocol`, `minVersion` and `maxVersion` of node:tls as the `ciphers`, `minVersion` and `maxVersion` the native parser takes. +function nativeCiphersAndVersions(options, defaultMinVersion?, defaultMaxVersion?) { + let { ciphers } = options; + let minVersion = toProtocolVersion("minimum", options.minVersion, defaultMinVersion); + let maxVersion = toProtocolVersion("maximum", options.maxVersion, defaultMaxVersion); + if (ciphers) { + const stripped = stripTls13CipherNames(ciphers); + ciphers = stripped.cipherList; + if (stripped.tls13Only) minVersion = protocolVersionCode("TLSv1.3"); + } + // It wins, as in Node's SecureContext::Init. + const range = secureProtocolToVersionRange(options.secureProtocol); + if (range) { + minVersion = range[0]; + maxVersion = range[1]; + } + return { ciphers, minVersion, maxVersion }; +} + // The native `ca` replaces the default store, so the CAs of an archive can only extend a `ca` the caller gave. function unsealPfxForNative(tls) { // `tls` can be the caller's own object, so a `pfx` that comes from a polluted Object.prototype doesn't count. @@ -383,8 +440,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; } @@ -412,13 +467,12 @@ function nodeClientTlsToNative(options) { // BoringSSL has no DHE suites to apply it to. options.dhparam = undefined; validateSecureContextOptions(options); - if (options.ciphers) { - // BoringSSL has no security levels to set. - const leveled = StringPrototypeReplace.$call(options.ciphers, /:?@SECLEVEL=\d/g, ""); - const { cipherList, tls13Only } = stripTls13CipherNames(leveled); - options.ciphers = cipherList; - if (tls13Only) options.minVersion = "TLSv1.3"; - } + // BoringSSL has no security levels to set. + if (options.ciphers) options.ciphers = StringPrototypeReplace.$call(options.ciphers, /:?@SECLEVEL=\d/g, ""); + const { ciphers, minVersion, maxVersion } = nativeCiphersAndVersions(options); + options.ciphers = ciphers; + options.minVersion = minVersion || undefined; + options.maxVersion = maxVersion || undefined; let tls; for (let i = 0; i < nodeClientTlsKeys.length; i++) { const name = nodeClientTlsKeys[i]; @@ -430,17 +484,9 @@ function nodeClientTlsToNative(options) { if (value !== true) continue; } else if (name === "key") { value = normalizePemKeyOption(value, options.passphrase); - } else if (name === "minVersion" || name === "maxVersion") { - value = tlsStringToProtocolVersion(value); } (tls ??= { __proto__: null })[name] = value; } - const range = secureProtocolToVersionRange(options.secureProtocol); - if (range) { - tls ??= { __proto__: null }; - tls.minVersion = range[0]; - tls.maxVersion = range[1]; - } return tls && unsealPfxForNative(tls); } @@ -476,17 +522,32 @@ function tlsHandshakeError(verifyError: { code?: string; reason?: string; messag 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, + ignoredByHttpsServer, + nativeCiphersAndVersions, nodeClientTlsKeys, nodeClientTlsToNative, normalizePemKeyOption, processPfxOptions, - secureProtocolToVersionRange, + readSecureContextOptions, stripTls13CipherNames, throwOnInvalidTLSArray, tlsHandshakeError, - tlsStringToProtocolVersion, unsealPfxForNative, validateSecureContextOptions, }; diff --git a/src/js/internal/webstreams_adapters.ts b/src/js/internal/webstreams_adapters.ts index 296dde631d79..59864e27b804 100644 --- a/src/js/internal/webstreams_adapters.ts +++ b/src/js/internal/webstreams_adapters.ts @@ -12,6 +12,7 @@ const Writable = require("internal/streams/writable"); const Readable = require("internal/streams/readable"); const Duplex = require("internal/streams/duplex"); const { destroyer } = require("internal/streams/destroy"); +const { addAbortSignal } = require("internal/streams/add-abort-signal"); const { isDestroyed, isReadable, isWritable, isWritableEnded } = require("internal/streams/utils"); const { kEmptyObject } = require("internal/shared"); const { validateBoolean, validateObject, validateOneOf } = require("internal/validators"); @@ -36,14 +37,6 @@ const SafePromisePrototypeFinally = $Promise.prototype.finally; const constants_zlib = $processBindingConstants.zlib; -function tryTransferToNativeReadable(stream: ReadableStream, options: StreamReadableFromReadableStreamOptions) { - const ptr = stream.$bunNativePtr; - if (!ptr || ptr === -1) { - return undefined; - } - return require("internal/streams/native-readable").constructNativeReadable(stream, options); -} - type DefaultReader = ReturnType; class ReadableFromWeb extends Readable { @@ -52,19 +45,23 @@ class ReadableFromWeb extends Readable { #stream: ReadableStream | undefined; // node-fetch, undici: `stream` is a Response body, which text(), json(), ... lock for good. #responseBody: boolean; + // Readable.fromWeb() of a native stream passes the reader it took at the call, which is never released, as in Node. + #lockedForGood: boolean; - constructor(options: StreamReadableFromReadableStreamOptions, stream: ReadableStream) { + constructor(options: StreamReadableFromReadableStreamOptions, stream: ReadableStream, reader?: DefaultReader) { const { objectMode, highWaterMark, encoding, signal, responseBody = false } = options; super({ objectMode, highWaterMark, encoding, - signal, }); - this.#reader = undefined; - this.#stream = stream; + this.#reader = reader; + this.#stream = reader ? undefined : stream; this.#closed = false; this.#responseBody = responseBody; + this.#lockedForGood = reader !== undefined; + // Not through super(): an aborted signal calls _destroy() at once, which reads the fields above. + if (signal) addAbortSignal(signal, this); } // Locked before this wrapper opened it: a body method has the contents, nothing to read or cancel. @@ -73,7 +70,7 @@ class ReadableFromWeb extends Readable { } #handleDone(reader: DefaultReader) { - reader.releaseLock(); + if (!this.#lockedForGood) reader.releaseLock(); this.#reader = undefined; this.#closed = true; this.push(null); @@ -82,9 +79,11 @@ class ReadableFromWeb extends Readable { #handleError(reader: DefaultReader | undefined, error: Error) { if (reader) { this.#reader = undefined; - try { - reader.releaseLock(); - } catch {} + if (!this.#lockedForGood) { + try { + reader.releaseLock(); + } catch {} + } } this.#closed = true; this.destroy(error); @@ -615,20 +614,22 @@ function newStreamReadableFromReadableStream( // Node acquires the reader at this point, so a locked stream throws here too. if (readableStream.locked) throw $ERR_INVALID_STATE_TypeError("ReadableStream is locked"); - const nativeStream = tryTransferToNativeReadable(readableStream, options); - - return ( - nativeStream || - new ReadableFromWeb( - { - highWaterMark, - encoding, - objectMode, - signal, - }, - readableStream, - ) + const nativeReadable = require("internal/streams/native-readable").tryConstructNativeReadable( + readableStream, + { highWaterMark, encoding, objectMode }, + signal, ); + if (nativeReadable) return nativeReadable; + + // A native stream that something has started is locked from here on too, like one that gave up its handle. + const reader = readableStream.$bunNativePtr !== undefined ? readableStream.getReader() : undefined; + try { + return new ReadableFromWeb({ highWaterMark, encoding, objectMode, signal }, readableStream, reader); + } catch (error) { + // Nothing else can close the source of a stream that stays locked. + reader?.cancel(error); + throw error; + } } let dep0201Warned = false; diff --git a/src/js/node/_http_agent.ts b/src/js/node/_http_agent.ts index 3a529acc3878..97126c2b5589 100644 --- a/src/js/node/_http_agent.ts +++ b/src/js/node/_http_agent.ts @@ -80,7 +80,7 @@ function Agent(options): void { const proxyEnv = this.options.proxyEnv; if (typeof proxyEnv === "object" && proxyEnv !== null) { - this[kProxyConfig] = parseProxyConfigFromEnv(proxyEnv, this.protocol, this.keepAlive); + this[kProxyConfig] = parseProxyConfigFromEnv(proxyEnv, this.protocol); $debug(`new ${this.protocol} agent with proxy config`, this[kProxyConfig]); } diff --git a/src/js/node/_http_common.ts b/src/js/node/_http_common.ts index 61d8baec4dc2..00dea1a7b846 100644 --- a/src/js/node/_http_common.ts +++ b/src/js/node/_http_common.ts @@ -2,6 +2,7 @@ // https://github.com/nodejs/node/blob/v26.3.0/lib/_http_common.js const { checkIsHttpToken } = require("internal/validators"); const FreeList = require("internal/freelist"); +const { drainMicrotasks } = require("internal/http"); interface HTTPParserError extends Error { bytesParsed: number; code: string; @@ -141,6 +142,13 @@ const MAX_HEADER_PAIRS = 2000; // across multiple TCP packets or too large to be // processed in a single run. This method is also // called to process trailing HTTP headers. +// Node makes kOnHeaders and kOnBody with MakeCallback(), which runs the ticks and microtasks when the read came from the handle. Its other callbacks skip them. +function runTaskQueues(parser) { + // What the socket held did not come from the handle. A tick could hand the parser the rest of it, or the end, in the middle of this read. + const socket = parser.socket; + if (parser._consumed && socket.readableLength === 0 && !socket._readableState.ended) drainMicrotasks(); +} + function parserOnHeaders(headers, url) { // Once we exceeded headers limit - stop collecting them const capacity = this.maxHeaderPairs - this._headers.length; @@ -150,6 +158,7 @@ function parserOnHeaders(headers, url) { this._headers.push(...headers.slice(0, capacity)); } this._url += url; + runTaskQueues(this); } // `headers` and `url` are set only if .onHeaders() has not been called for @@ -216,13 +225,12 @@ function parserOnBody(b) { const stream = this.incoming; // If the stream has already been removed, then drop it. - if (stream === null || stream[kSkipPendingData]) return; - - // Pretend this was the result of a stream._read call. - if (!stream._dumped) { + if (stream !== null && !stream[kSkipPendingData] && !stream._dumped && !stream.destroyed) { + // Pretend this was the result of a stream._read call. const ret = stream.push(b); if (!ret) readStop(this.socket); } + runTaskQueues(this); } function parserOnMessageComplete() { @@ -240,6 +248,10 @@ function parserOnMessageComplete() { parser._url = ""; } + // JS feeds this parser, so user frames can be on the stack and no queue has run. A read that the 'request' listener scheduled counts as begun. + const state = stream._readableState; + if (!parser._consumed && (state.resumeScheduled || state.readableListening)) stream._consuming = true; + // For emit end event stream.push(null); } diff --git a/src/js/node/_http_incoming.ts b/src/js/node/_http_incoming.ts index f559ba7c42d5..9f1de0e4e899 100644 --- a/src/js/node/_http_incoming.ts +++ b/src/js/node/_http_incoming.ts @@ -11,7 +11,6 @@ const { abortedSymbol, eofInProgress, kHandle, - kHandoffResponse, kHeaderSource, noBodySymbol, emitErrorNextTickIfErrorListenerNT, @@ -331,9 +330,9 @@ IncomingMessage.prototype._read = function _read(_n) { // Native server path. const socket = this.socket; if (socket && socket.readable) { - if (this.upgrade || socket[kHandoffResponse] !== undefined) { + if (this.upgrade || socket.parser === null) { // Upgrade request with a body (Node 26 semantics): reading the request - // must not flip the raw socket into flowing mode - tunnel bytes pushed + // must not flip the raw socket into flowing mode - raw bytes pushed // to the socket before the 'upgrade' listener attaches its own 'data' // handler would be discarded by a flowing stream with no readers. // Resume the native body source directly instead. @@ -408,7 +407,11 @@ IncomingMessage.prototype._destroy = function _destroy(err, cb) { socket.destroy(err); const state = { cleanup: undefined as undefined | (() => void) }; state.cleanup = finished(socket, onFinishedAfterDestroy.bind(this, state, err, cb)); + } else if (err == null && !this.aborted) { + cb(); } else { + // pipeline() and `for await` destroy a request but keep its connection (req.socket is null). The rest of the body is dropped, not held back. + if (!socket && !this.complete) readStart(this.client); process.nextTick(onError, this, err, cb); } }; @@ -720,7 +723,8 @@ IncomingMessage.prototype._dump = function _dump() { // Remove 'data' event listeners explicitly. this.removeAllListeners("data"); // ondata stays armed: it drops the chunks and still delivers the last one, which completes the request. - this.resume(); + if (this._readableState.ended && !this.destroyed && this.readableLength === 0) this.read(0); + else this.resume(); } }; diff --git a/src/js/node/_http_outgoing.ts b/src/js/node/_http_outgoing.ts index ef8cc79cc014..dd89423f08f4 100644 --- a/src/js/node/_http_outgoing.ts +++ b/src/js/node/_http_outgoing.ts @@ -6,7 +6,14 @@ 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"); +const { + kOutHeaders, + kNeedDrain, + kFailHeldWrites, + kFinishesWithoutLastWrite, + utcDate, + RE_CONN_CLOSE, +} = require("internal/http"); const { validateHeaderName, validateHeaderValue, @@ -14,7 +21,6 @@ const { chunkExpression: RE_TE_CHUNKED, isLenient, } = require("node:_http_common"); -const { FakeSocket } = require("internal/http/FakeSocket"); const EE = require("node:events"); const ObjectDefineProperty = Object.defineProperty; @@ -117,14 +123,13 @@ 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"); const nop = () => {}; -const RE_CONN_CLOSE = /(?:^|\W)close(?:$|\W)/i; - // isCookieField performs a case-insensitive comparison of a provided string // against the word "cookie." As of V8 6.6 this is faster than handrolling or // using a case-insensitive RegExp. @@ -334,33 +339,68 @@ OutgoingMessage.prototype.cork = function cork() { OutgoingMessage.prototype.uncork = function uncork() { this[kCorked]--; - if (this[kSocket]) { - this[kSocket].uncork(); - } + const socket = this[kSocket]; + try { + if (this[kCorked] || this[kChunkedBuffer].length === 0) { + return; + } - if (this[kCorked] || this[kChunkedBuffer].length === 0) { - return; - } + const len = this[kChunkedLength]; + const buf = this[kChunkedBuffer]; + if (this.destroyed || socket?.destroyed) { + failCorkedChunks(this, this[kErrored] || socket?._writableState?.errored || $ERR_STREAM_DESTROYED("write")); + return; + } - const len = this[kChunkedLength]; - const buf = this[kChunkedBuffer]; + $assert(this.chunkedEncoding); - $assert(this.chunkedEncoding); + // Node emits 'drain' at the end of uncork(), where a listener that is added after uncork() returns misses it. + if (this[kNeedDrain]) process.nextTick(emitDrainIfFlushedNT, this); - 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; +function failCorkedChunks(msg, err) { + const buf = msg[kChunkedBuffer]; + for (let n = 2; n < buf.length; n += 3) { + if (buf[n] !== nop) process.nextTick(buf[n], err); + } + buf.length = 0; + msg[kChunkedLength] = 0; +} + +// Fails the callbacks of what the message holds and can't send any more. Node drops them. +OutgoingMessage.prototype[kFailHeldWrites] = function (err?) { + const held = this.outputData; + if (held.length === 0 && this[kChunkedBuffer].length === 0) return false; + err ??= $ERR_STREAM_DESTROYED("write"); + // None of this reached the socket, so a last write among it doesn't complete the message. + this[kFinishesWithoutLastWrite] = false; + failCorkedChunks(this, err); + this.outputData = []; + this._onPendingData(-this.outputSize); + this.outputSize = 0; + for (let i = 0; i < held.length; i++) { + const callback = held[i].callback; + if (typeof callback === "function") process.nextTick(callback, err); + } + return true; }; function runChunkCallbacks(callbacks, err) { @@ -439,18 +479,20 @@ OutgoingMessage.prototype._send = function _send(data, encoding, callback, byteL OutgoingMessage.prototype._writeRaw = _writeRaw; function _writeRaw(this: any, data, encoding, callback, _size?) { const conn = this[kSocket]; + if (typeof encoding === "function") { + callback = encoding; + encoding = null; + } + if (conn?.destroyed) { // The socket was destroyed. If we're still trying to write to it, // then we haven't gotten the 'close' event yet. + if (typeof callback === "function") process.nextTick(callback, conn.errored ?? $ERR_STREAM_DESTROYED("write")); return false; } - if (typeof encoding === "function") { - callback = encoding; - encoding = null; - } - - if (conn && conn._httpMessage === this && conn.writable) { + // A socket whose last write failed takes this one too, and fails its callback before it closes. + if (conn && conn._httpMessage === this && (conn.writable || conn.errored)) { // There might be pending data in the this.output buffer. if (this.outputData.length) { this._flushOutput(conn); @@ -466,7 +508,7 @@ function _writeRaw(this: any, data, encoding, callback, _size?) { } OutgoingMessage.prototype._storeHeader = _storeHeader; -function _storeHeader(this: any, firstLine, headers) { +function _storeHeader(this: any, firstLine, headers, trailerHeaderChecked = false) { // firstLine in the case of request is: 'GET /index.html HTTP/1.1\r\n' // in the case of response it is: 'HTTP/1.1 200 OK\r\n' const state = { @@ -590,7 +632,7 @@ function _storeHeader(this: any, firstLine, headers) { // message will be terminated by the first empty line after the // header fields, regardless of the header fields present in the // message, and thus cannot contain a message body or 'trailers'. - if (this.chunkedEncoding !== true && state.trailer) { + if (this.chunkedEncoding !== true && state.trailer && !trailerHeaderChecked) { throw $ERR_HTTP_TRAILER_INVALID(); } @@ -755,12 +797,12 @@ OutgoingMessage.prototype.appendHeader = function appendHeader(name, value) { throw $ERR_HTTP_HEADERS_SENT("append"); } const field = validatedLowercaseHeaderName(name); - validateHeaderValueLenient(name, value, this._isLenientHeaderValidation()); const headers = this[kOutHeaders]; if (headers === null || !headers[field]) { return this.setHeader(name, value); } + validateHeaderValueLenient(name, value, this._isLenientHeaderValidation()); // Prepare the field for appending, if required if (!ArrayIsArray(headers[field][1])) { @@ -987,6 +1029,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(); @@ -996,7 +1044,8 @@ function write_(msg, chunk, encoding, callback, fromEnd) { let ret; if (msg.chunkedEncoding && chunk.length !== 0) { len ??= typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.byteLength; - if (msg[kCorked] && msg._headerSent) { + // After an uncork() too many the count is below zero, which in Node holds every later chunk back until end(). + if (msg[kCorked] > 0 && msg._headerSent) { msg[kChunkedBuffer].push(chunk, encoding, callback); msg[kChunkedLength] += len; ret = msg[kChunkedLength] < msg[kHighWaterMark]; @@ -1059,9 +1108,21 @@ 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?) { + const callbacks = outmsg[kEndCallbacks]; + if (callbacks) outmsg[kEndCallbacks] = undefined; + if ((outmsg[kFinishesWithoutLastWrite] && outmsg[kSocket]) || (!err && !outmsg.socket?._hadError)) { + return void outmsg.emit("finish"); + } + if (!callbacks) return; + 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) { @@ -1088,7 +1149,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")); } @@ -1103,19 +1164,28 @@ 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)) { 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); + // As in Node, a last write to a connection that was gone before end() doesn't complete the message or call the callback. + const lastWrite = this[kSocket]?.destroyed ? nop : finish; if (this._hasBody && this.chunkedEncoding) { - this._send("0\r\n" + this._trailer + "\r\n", "latin1", finish); + this._send("0\r\n" + this._trailer + "\r\n", "latin1", lastWrite); } else if (!this._headerSent || this.writableLength || chunk) { - this._send("", "latin1", finish); + this._send("", "latin1", lastWrite); } else { process.nextTick(finish); } @@ -1169,19 +1239,24 @@ OutgoingMessage.prototype._flush = function _flush() { const socket = this[kSocket]; if (socket?.writable) { + if (this[kNeedDrain]) process.nextTick(emitDrainIfFlushedNT, this); // There might be remaining data in this.output; write it out this._flushOutput(socket); if (this.finished) { // This is a queue to the server or client to bring in the next this. this._finish(); - } else if (this[kNeedDrain] && this.writableLength === 0) { - this[kNeedDrain] = false; - this.emit("drain"); } } }; +// Queued ahead of a flush: as in Node, 'drain' comes a tick later than the flush and before the callbacks of the writes it sent. +function emitDrainIfFlushedNT(msg) { + if (!msg[kNeedDrain] || msg.writableLength !== 0) return; + msg[kNeedDrain] = false; + if (!msg.destroyed && !msg.finished) msg.emit("drain"); +} + OutgoingMessage.prototype._flushOutput = function _flushOutput(socket) { const outputLength = this.outputData.length; if (outputLength <= 0) return undefined; @@ -1332,5 +1407,4 @@ export default { validateHeaderName, validateHeaderValue, OutgoingMessage, - FakeSocket, }; diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index c4513292b7c7..7fda6a00df93 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -1,29 +1,40 @@ // Hardcoded module "node:_http_server" const EventEmitter: typeof import("node:events").EventEmitter = require("node:events"); -const { Stream } = require("node:stream"); +const { Stream, Duplex } = require("node:stream"); const { isIP } = require("internal/net/isIP"); const { _checkInvalidHeaderChar: checkInvalidHeaderChar, chunkExpression, + continueExpression, validateHeaderName, validateHeaderValue, HTTPParser, calculateLenientFlags, + parsers, + freeParser, + prepareError, + kIncomingMessage, } = require("node:_http_common"); +const { ConnectionsList } = process.binding("http_parser") as any; +const AsyncContextFrame = require("internal/async_context_frame"); const { validateObject, validateLinkHeaderValue, validateBoolean, validateInteger, + validateNumber, validateFunction, validateOneOf, + validatePort, } = require("internal/validators"); const { ConnResetException, ErrnoException, + ExceptionWithHostPort, hasObserver, startPerf, stopPerf, + kClusterOwner, kInternalSendOptions, isStoppedModuleGraphRunning, } = require("internal/shared"); @@ -31,45 +42,41 @@ const kServerResponseStatistics = Symbol("ServerResponseStatistics"); const { isPrimary } = require("internal/cluster/isPrimary"); const { addServerAbortSignalOption } = require("internal/net/server_abort_signal"); +const { kForeignListener, kHandleClosed, kSNIContexts } = require("internal/net/symbols"); +const { getDefaultHighWaterMark } = require("internal/streams/state"); const { kInternalSocketData, serverSymbol, kHandle, - kOnReadParsed, - kHandoffResponse, - kHasQueuedReads, + kLeaveHttp, + httpServerParts, kRealListen, + kUpgradeStream, tlsSymbol, - optionsSymbol, - headerStateSymbol, - NodeHTTPHeaderState, kPendingCallbacks, - kRememberPeer, - kRequest, kCloseCallback, - kClosedWithUnsentData, NodeHTTPResponseFlags, callCloseCallback, emitCloseNT, NodeHTTPResponseAbortEvent, STATUS_CODES, isTlsSymbol, - hasServerResponseFinished, - NodeHTTPBodyReadState, drainMicrotasks, - setServerCustomOptions, - setServerAppFlags, - setServerMaxHeadersCount, getMaxHTTPHeaderSize, fakeSocketSymbol, kOutHeaders, + kNeedDrain, + kFailHeldWrites, + kFinishesWithoutLastWrite, onDataIncomingMessage, - http1ServerPipeline, + RE_CONN_CLOSE, } = require("internal/http"); +// The key that internal/timers exports as `kTimeout`. +const kTimeout = Symbol.for("::buntimeout::"); const { FakeSocket } = require("internal/http/FakeSocket"); const NumberIsNaN = Number.isNaN; -const { IncomingMessage, kReqShouldKeepAlive } = require("node:_http_incoming"); +const { IncomingMessage, kReqShouldKeepAlive, readStart } = require("node:_http_incoming"); const { OutgoingMessage, kErrored, @@ -80,13 +87,19 @@ const { parseUniqueHeadersOption, } = require("node:_http_outgoing"); const OutgoingMessagePrototype = OutgoingMessage.prototype; -const { kIncomingMessage } = require("node:_http_common"); -let http1Fallback; +const outgoingSocketSetter = Object.getOwnPropertyDescriptor(OutgoingMessagePrototype, "socket")!.set!; +const kConnections = Symbol("http.server.connections"); +const buntls = Symbol.for("::buntls::"); const kConnectionsCheckingInterval = Symbol("http.server.connectionsCheckingInterval"); const kTrackedConnections = Symbol("http.server.trackedConnections"); const kPendingDrainClose = Symbol("http.server.pendingDrainClose"); const kPendingCloseGenerations = Symbol("http.server.pendingCloseGenerations"); const kListenerGeneration = Symbol("http.server.listenerGeneration"); +const 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; // Set on the 'connect'/'upgrade' handoff; closeAll/closeIdleConnections() skip these, as in Node. const kHandedOff = Symbol("http.server.socketHandedOff"); function closeHandle(handle) { @@ -100,27 +113,40 @@ const kMaxHeadersCount = Symbol("http.server.maxHeadersCount"); // category, so this is near-zero cost when tracing is off. const kHttpTraceCat = "node,node.http"; let traceEvents: typeof import("internal/trace_events").default; -function traceServerRequestStart(http_res) { +// What Node's ServerResponse constructor starts. Node makes no response for an 'upgrade' or a 'connect'. +function observeRequest(req, res) { + if (hasObserver("http")) { + startPerf(res, kServerResponseStatistics, { + type: "http", + name: "HttpRequest", + detail: { req: { method: req.method, url: req.url, headers: req.headers } }, + }); + res.once("finish", stopServerResponsePerf); + } + traceEvents ??= require("internal/trace_events"); if (!traceEvents.isCategoryGroupEnabled(kHttpTraceCat)) return; traceEvents.emitEvent("b", kHttpTraceCat, "http.server.request"); - http_res.once("finish", traceServerRequestEnd); + res.once("finish", traceServerRequestEnd); } function traceServerRequestEnd() { traceEvents.emitEvent("e", kHttpTraceCat, "http.server.request"); } -const getBunServerAllClosedPromise = $bindgen("node_http_binding.bind.ts", "getBunServerAllClosedPromise"); -const getBunServerOpenCount = $bindgen("node_http_binding.bind.ts", "getBunServerOpenCount"); +const addServerName = $bindgen("Listener.bind.ts", "addServerName"); +const setServerAppFlags = $bindgen("node_http_binding.bind.ts", "setServerAppFlags"); +const setServerCustomOptions = $bindgen("node_http_binding.bind.ts", "setServerCustomOptions"); +const setServerMaxHeadersCount = $bindgen("node_http_binding.bind.ts", "setServerMaxHeadersCount"); +const setBunServerSocketHandlers = $bindgen("node_http_binding.bind.ts", "setBunServerSocketHandlers"); +const httpConnectionOf = $bindgen("node_http_binding.bind.ts", "httpConnectionOf"); const kServerResponse = Symbol("ServerResponse"); -const kChunkedEncoding = Symbol("kChunkedEncoding"); -const kShouldKeepAlive = Symbol("kShouldKeepAlive"); const kOptimizeEmptyRequests = Symbol("kOptimizeEmptyRequests"); const kEmptyBuffer = Buffer.alloc(0); const ObjectKeys = Object.keys; +const ArrayBufferIsView = ArrayBuffer.isView; const MathMin = Math.min; const MathFloor = Math.floor; -const DateNow = Date.now; +const timerClockMs = $bindgen("Timer.bind.ts", "timerClockMs"); let cluster; @@ -135,19 +161,38 @@ function emitCloseServer(self: Server) { } const relistened = !!self[serverSymbol]; // Node queues 'close' inside a close() that leaves no connection open. A listen() after it, or a connection to that listener, cannot hold the event back. - if ((relistened || self[kTrackedConnections].size > 0) && !drainedAtClose) return; + // Without one, the event waits for the 'close' of the sockets that close() swept. + if (relistened ? !drainedAtClose : self._connections > 0) return; self[kPendingDrainClose] = false; pendingGenerations.clear(); if (!relistened) self[kListenerGeneration] = undefined; self.emit("close"); } +function emitCloseNotRunning(self: Server) { + self.emit("close"); +} function markListenerGenerationClosed(self: Server, generation) { generation.nativeClosed = true; - emitCloseServer(self); + emitCloseServerInImmediate(self); } 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]) { @@ -195,9 +240,9 @@ function onServerResponseClose() { } // Node's write_() checks before _implicitHeader(): the write that renders the headers is counted but not checked. end() is always checked. -function strictContentLength(response, headerState, fromEnd) { +function strictContentLength(response, hadHeader, fromEnd) { if (!response.strictContentLength) return; - if (!fromEnd && headerState === NodeHTTPHeaderState.none) return; + if (!fromEnd && !hadHeader) return; let contentLength = response._contentLength ?? response.getHeader("content-length"); if ( contentLength != null && @@ -231,15 +276,18 @@ function createServerParserShim(socket) { incoming: null, outgoing: null, socket, + _consumed: true, free: serverParserShimFree, close: serverParserShimClose, + resume: serverParserShimClose, [kParserOnTimeout]: serverParserShimOnTimeout, }; } function releaseServerParserShim(socket, req?) { const parser = socket.parser; - if (parser == null) return; + // A socket that was handed off and came back through emit('connection') has Node's parser, which socketOnClose frees. + if (parser == null || parser instanceof HTTPParser) return; socket.parser = null; parser.incoming = null; parser.socket = null; @@ -248,10 +296,10 @@ function releaseServerParserShim(socket, req?) { if (req != null && req.parser != null) req.parser = null; } -function onNodeHTTPServerSocketTimeout() { - const req = this[kRequest]; - // Like Node.js's socketOnTimeout: the request only sees 'timeout' while its - // message is still being received. +function socketOnTimeout() { + // A keep-alive timer that fired is dead: the next response finish arms a new one. + if (this[kKeepAliveTimeoutSet]) this[kTimeout] = null; + const req = this.parser?.incoming; const reqTimeout = req && !req.complete && req.emit("timeout", this); const res = this._httpMessage; const resTimeout = res && res.emit("timeout", this); @@ -272,6 +320,13 @@ function emitListenErrorNextTick(self, err) { self.emit("error", err); } +function emitListenError(server, err, port, host, socketPath, fd) { + const { listenException } = require("internal/net/errors"); + if (fd !== undefined) err = listenException(err, null); + else err = socketPath ? listenException(err, socketPath, -1) : listenException(err, host ?? "::", port); + process.nextTick(emitListenErrorNextTick, server, 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; @@ -279,18 +334,37 @@ function normalizeServerTls(tls) { return tls; } -// Node registers connectionListener on every http.Server so `server.emit("connection", socket)` -// works for foreign Duplex sockets. The native listener handles its own sockets end to end; -// this picks up the rest. https://github.com/nodejs/node/blob/main/lib/_http_server.js +// uWS parses the connections it accepts. Every other Duplex gets Node's connection listener. 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, { - http1Options: { - IncomingMessage: this[kIncomingMessage], - ServerResponse: this[kServerResponse], - }, - }); + // A socket that 'connect' or 'upgrade' got is a connection like any other when it's emitted again. + if (!socket[kHandedOff]) { + if (socket[kHandle] !== undefined) return; + const http = httpConnectionOf(socket._handle); + if (http !== undefined) { + attachConnection(this, socket, http); + return; + } + } + 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 connectionListenerInternal(this, socket); +} + +function secureConnectionListener(this: Server, socket) { + if (socket[kHandle] !== undefined && !socket[kHandedOff]) nativeConnectionListener(this, socket); + else connectionListenerInternal(this, socket); +} + +// 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); + else if (err?.code === "ERR_TLS_HANDSHAKE_TIMEOUT") process.nextTick(destroyUnlessSecure, socket); +} + +// handshakeTimeout closes the socket whatever the 'clientError' listener did with it. Node leaves that to the listener. +function destroyUnlessSecure(socket) { + if (!socket._secureEstablished) socket.destroy(); } type NodeHTTPServer = import("node:http").Server; @@ -300,18 +374,36 @@ interface Server extends NodeHTTPServer { httpValidation?: "strict" | "relaxed" | "insecure"; requireHostHeader: boolean; httpAllowHalfOpen: boolean; + allowHalfOpen: boolean; blockList?: import("node:net").BlockList; + keepAlive: boolean; + keepAliveInitialDelay: number; +} +const kConstructed = Symbol("kConstructed"); + +// Bun only: http.createServer() with one of these serves TLS. Own enumerable ones count, which are what tls.Server gets. +function hasOwnTLSIdentity(options) { + const isOwn = Object.prototype.propertyIsEnumerable; + return !!( + (isOwn.$call(options, "pfx") && options.pfx) || + (isOwn.$call(options, "cert") && options.cert) || + (isOwn.$call(options, "key") && options.key) || + (isOwn.$call(options, "ca") && options.ca) + ); } + function Server(options, callback): void { if (!(this instanceof Server)) return new Server(options, callback); + return initHttpServer.$call(this, options, callback); +} + +// The constructor body, with no `instanceof` guard. https.Server runs it on an instance that is not an http.Server. +function initHttpServer(options, callback) { EventEmitter.$call(this); this.on("listening", setupConnectionsTracking); this.on("connection", connectionListener); - this.prependListener("connection", socket => { - if (socket != null && typeof socket === "object") socket.server = this; - }); - + this._handle = null; this._unref = false; this.timeout = 0; this.maxRequestsPerSocket = 0; @@ -319,11 +411,15 @@ function Server(options, callback): void { defineHttpAllowHalfOpen(this); this[kInternalSocketData] = undefined; this[kTrackedConnections] = new Set(); + this[kHandedOffSockets] = new Set(); + this._connections = 0; this[kPendingDrainClose] = false; this[kPendingCloseGenerations] = new Set(); this[kListenerGeneration] = undefined; - this[tlsSymbol] = null; + // A constructor that is called on an existing server keeps its TLS configuration. + this[tlsSymbol] ??= null; this.noDelay = true; + let isTLS = false; if (typeof options === "function") { callback = options; options = {}; @@ -331,120 +427,85 @@ function Server(options, callback): void { options = {}; } 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); - } + isTLS = (this[isTlsSymbol] && !this[tlsSymbol]) || hasOwnTLSIdentity(options); + } - let serverName = options.servername; - if (serverName && typeof serverName !== "string") { - throw $ERR_INVALID_ARG_TYPE("options.servername", "string", serverName); - } + storeHTTPOptions.$call(this, options); + // Node's https.Server gives tls.Server, and so net.Server, a spread of the caller's object. Its http.Server reads net.Server's options by name. + const transportOptions = this[isTlsSymbol] ? { ...options } : options; + // Node's http.Server always allows half-open sockets. https.Server takes the option, like tls.Server. + this.allowHalfOpen = this[isTlsSymbol] ? !!transportOptions.allowHalfOpen : true; + // This tls.Server never listens. It validates the TLS options and owns the SecureContext, like the one Node's https.Server extends. + if (isTLS) { + const { ignoredByHttpsServer } = require("internal/tls"); + const tlsServer = (this[tlsSymbol] = new (require("node:tls").Server)({ + ...transportOptions, + ...ignoredByHttpsServer, + })); + 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() asks for it, so a server whose TLS options threw can't listen in cleartext. + this[kConstructed] = true; + + // Node passes it to net.Server, which applies it to every accepted socket, and from there to req and res. + this[kHighWaterMark] = transportOptions.highWaterMark; + this.highWaterMark = transportOptions.highWaterMark ?? getDefaultHighWaterMark(); - let secureOptions = options.secureOptions || 0; - if (secureOptions && typeof secureOptions !== "number") { - throw $ERR_INVALID_ARG_TYPE("options.secureOptions", "number", secureOptions); - } + this[kUniqueHeaders] = parseUniqueHeadersOption(options.uniqueHeaders); - 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, - }); - } + // 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 = transportOptions.keepAliveInitialDelay; + if (keepAliveInitialDelay !== undefined) { + validateNumber(keepAliveInitialDelay, "options.keepAliveInitialDelay"); + if (keepAliveInitialDelay < 0) keepAliveInitialDelay = 0; } - - this[optionsSymbol] = options; - storeHTTPOptions.$call(this, options); + this.keepAlive = Boolean(transportOptions.keepAlive); + this.keepAliveInitialDelay = ~~(keepAliveInitialDelay / 1000); if (callback) this.on("request", callback); return this; } $toClass(Server, "Server", EventEmitter); +// What net.ts's accept path and handshake handler read of a tls.Server. listen({ tls }) has the two certificate options only. +Object.defineProperties(Server.prototype, { + _requestCert: { + configurable: true, + get() { + const tlsServer = this[tlsSymbol]; + return tlsServer ? tlsServer._requestCert : this[kListenerGeneration]?.tls?.requestCert; + }, + }, + _rejectUnauthorized: { + configurable: true, + get() { + const tlsServer = this[tlsSymbol]; + return tlsServer ? tlsServer._rejectUnauthorized : this[kListenerGeneration]?.tls?.rejectUnauthorized; + }, + }, + _handshakeTimeout: { + configurable: true, + get() { + return this[tlsSymbol]?._handshakeTimeout ?? 120_000; + }, + }, + _ALPNCallback: { + configurable: true, + get() { + return this[tlsSymbol]?._ALPNCallback; + }, + }, + _SNICallback: { + configurable: true, + get() { + return this[tlsSymbol]?._SNICallback; + }, + }, +}); + Object.defineProperty(Server.prototype, "listening", { __proto__: null, get() { @@ -460,6 +521,10 @@ Server.prototype[kServerResponse] = undefined; Server.prototype[kConnectionsCheckingInterval] = undefined; +Server.prototype[kClusterHandle] = undefined; + +Server.prototype[kClusterListeningId] = 0; + function rethrowUncaught(err) { throw err; } @@ -474,6 +539,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 +550,26 @@ function setupConnectionsTracking(this: any) { function checkConnections(this: Server) { const headersTimeout = this.headersTimeout; const requestTimeout = this.requestTimeout; - if (headersTimeout === 0 && requestTimeout === 0) { - return; + const sweep = headersTimeout !== 0 || requestTimeout !== 0; + + // llhttp keeps the times of the connections it parses, uWS those of its own. + const parsed = this[kConnections]; + if (sweep && parsed !== undefined) { + const expired = parsed.expired(headersTimeout, requestTimeout); + for (let i = 0; i < expired.length; i++) { + const socket = expired[i].socket; + if (socket) onRequestTimeout(socket); + } } 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 (!sweep) { return; } @@ -506,6 +587,8 @@ function onRequestTimeout(socket) { } Server.prototype.ref = function () { + // Only what unref() let go of: a socket can have an unref() of the program's own. + if (this._unref) for (const socket of this[kHandedOffSockets]) socket.ref(); this._unref = false; this[serverSymbol]?.ref?.(); return this; @@ -514,32 +597,69 @@ Server.prototype.ref = function () { Server.prototype.unref = function () { this._unref = true; this[serverSymbol]?.unref?.(); + for (const socket of this[kHandedOffSockets]) socket.unref(); return this; }; Server.prototype.closeAllConnections = function () { - http1Fallback?.closeAllHttp1Connections(this); - // Like Node, the listener and the sockets handed to 'connect' and 'upgrade' listeners stay. + const parsed = this[kConnections]; + if (parsed !== undefined) { + const connections = parsed.all(); + for (let i = 0, l = connections.length; i < l; i++) { + connections[i].socket.destroy(); + } + } + // Like Node, this skips the listener, sockets handed to 'connect' / 'upgrade' listeners, and TLS connections still in their handshake. const tracked = this[kTrackedConnections]; if (tracked && tracked.size > 0) { - for (const socket of $Array.from(tracked) as NodeHTTPServerSocket[]) { - if (!socket[kHandedOff]) socket.destroyNow(); + for (const socket of $Array.from(tracked) as any[]) { + destroyNow(socket); } } }; +// net.Socket's _destroy() calls this after it queued the socket's own 'close', so the immediate set here runs behind that. The one that stop()'s promise sets can run ahead: an idle connection that its peer or closeIdleConnections() closes emits 'close' after the server, as in Node. +Server.prototype._emitCloseIfDrained = function () { + // This server has no 'close' listener on them, as in Node, so it forgets the closed ones now and then. + const handedOff = this[kHandedOffSockets]; + if (handedOff.size > 2 * this._connections + 16) { + for (const socket of handedOff) if (socket.destroyed) handedOff.delete(socket); + } + if (this._connections === 0 && this[kPendingDrainClose]) + queueMicrotask(emitCloseServerInImmediate.bind(undefined, this)); +}; +function emitCloseServerInImmediate(self: Server) { + setImmediate(emitCloseServer, self); +} + Server.prototype.getConnections = function (callback) { // Connections are tracked from the moment they are accepted (the native // server surfaces accepts through the onConnection callback). - const count = this[kTrackedConnections]?.size ?? 0; + const count = this._connections; if (typeof callback === "function") { process.nextTick(callback, null, count); } return this; }; +function closeIdleParsedConnections(server) { + const parsed = server[kConnections]; + if (parsed === undefined) return; + const connections = parsed.idle(); + for (let i = 0, l = connections.length; i < l; i++) { + const socket = connections[i].socket; + if (socket._httpMessage && !socket._httpMessage.finished) continue; + socket.destroy(); + } +} + +function httpServerPreClose(server) { + server.closeIdleConnections(); + clearInterval(server[kConnectionsCheckingInterval]); +} + Server.prototype.closeIdleConnections = function () { - http1Fallback?.closeIdleHttp1Connections(this); + closeIdleParsedConnections(this); const server = this[serverSymbol]; if (server) { server.closeIdleConnections(); @@ -547,9 +667,9 @@ Server.prototype.closeIdleConnections = function () { } const tracked = this[kTrackedConnections]; if (tracked && tracked.size > 0) { - for (const socket of $Array.from(tracked) as NodeHTTPServerSocket[]) { + for (const socket of $Array.from(tracked) as any[]) { // uWS knows whether a request is arriving on the connection. A missing _httpMessage does not tell. - if (!socket[kHandedOff]) socket[kHandle]?.closeIfIdle(); + socket[kHandle]?.closeIfIdle(); } } }; @@ -559,23 +679,38 @@ Server.prototype.close = function (optionalCallback?) { // Node.js's httpServerPreClose clears the connections-checking interval // even when the server was never listening. clearInterval(this[kConnectionsCheckingInterval]); - http1Fallback?.closeIdleHttp1Connections(this); + closeIdleParsedConnections(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. + // https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L2596-L2647 + if (typeof optionalCallback === "function") this.once("close", () => optionalCallback($ERR_SERVER_NOT_RUNNING())); + if (this._connections === 0) process.nextTick(emitCloseNotRunning, this); return this; } if (typeof optionalCallback === "function") this.once("close", 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 (generation) generation.drainedAtClose = this._connections === swept; + if (nativeServerOwners !== undefined) { + // Under `bun --hot`, stopping a shared native server also takes the listener away from its current http.Server. + const owner = nativeServerOwners.get(server); + if (owner != null && owner !== this) nativeServerOwners.set(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,23 +757,48 @@ Server.prototype.listen = function () { const server = this; let port, host; let socketPath; - let tls = this[tlsSymbol]; + let fd: number | undefined; + let reusePort = false; + let ipv6Only = false; + let exclusive = false; + const tlsServer = this[tlsSymbol]; + // tls.Server's context for its own listener always has the server's verify mode. socket.authorized reads requestCert. + let tls = tlsServer && { + __proto__: null, + secureContext: tlsServer[buntls](null)[0].secureContext, + requestCert: tlsServer._requestCert === true, + serverName: tlsServer.servername, + }; // This logic must align with: // - https://github.com/nodejs/node/blob/2eff28fb7a93d3f672f80b582f664a7c701569fb/lib/net.js#L274-L307 const argc = arguments.length; if (argc > 0) { const arg0 = arguments[0]; - if (($isObject(arg0) || $isCallable(arg0)) && arg0 !== null) { + if ($isObject(arg0)) { // (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; + ipv6Only = arg0.ipv6Only === 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)) { - tls = normalizeServerTls({ __proto__: null, ...otherTLS }); + if ($isObject(otherTLS)) { + tls = normalizeServerTls({ __proto__: null, ...otherTLS, secureContext: undefined }); } } else if (typeof arg0 === "string" && !(Number(arg0) >= 0)) { // (path[...][, cb]) @@ -654,15 +814,18 @@ Server.prototype.listen = function () { // Bun defaults to port 3000. // Node defaults to port 0. - if (port === undefined && !socketPath) { + if (port == null && !socketPath) { 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; + } else if (socketPath) { + // Like net.Server.listen(), listening on a `path` ignores `host`. + host = undefined; } const lastArg = arguments[argc - 1]; @@ -672,15 +835,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, ipv6Only, 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, ipv6Only, 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,22 +878,76 @@ Server.prototype.listen = function () { process.send!(message, undefined, kInternalSendOptions); }); - server[kRealListen](tls, port, host, socketPath, true); + server[kRealListen](tls, port, host, socketPath, true, ipv6Only, 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, ipv6Only, fd) { + const listeningId = ++server[kClusterListeningId]; + function query(address, queryPort, addressType) { + const flags = ipv6Only ? 1 : 0; + const serverQuery = { address, port: queryPort, addressType, fd, flags, sharedOnly: true, isHttp: 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, 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, ipv6Only, fd?: number) { { // What the first connection and the first request need, so that neither waits for it. - getNodeHTTPServerSocket(); + loadNet(tls); + // The constructor adds them for TLS options of its own. listen({ tls }) comes without. + if (tls && this.listenerCount("secureConnection", secureConnectionListener) === 0) { + this.prependListener("secureConnection", secureConnectionListener); + this.prependListener("tlsClientError", onTLSClientError); + } traceEvents ??= require("internal/trace_events"); - const ResponseClass = this[optionsSymbol].ServerResponse || ServerResponse; - const RequestClass = this[optionsSymbol].IncomingMessage || IncomingMessage; + if (!this[kConstructed]) throw $ERR_INVALID_STATE("The server's constructor threw"); + const ResponseClass = this[kServerResponse]; + const RequestClass = this[kIncomingMessage]; const canUseInternalAssignSocket = ResponseClass?.prototype.assignSocket === ServerResponse.prototype.assignSocket; let server = this; let listenerGeneration; @@ -728,11 +961,12 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort port, hostname: host, unix: socketPath, + fd, reusePort, + ipv6Only, maxRequestBodySize: Number.MAX_SAFE_INTEGER, onNodeHTTPRequest( - bunServer, url: string, method: string, // Native dispatch bitfield (kDispatch* in NodeHTTP.cpp): presence and @@ -748,15 +982,11 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort connectHead?: Buffer, isPipelinedDispatch?: boolean, ) { - if (!socket) { - socket = new (getNodeHTTPServerSocket())(server, socketHandle, !!tls, listenerGeneration); - } - if (isPipelinedDispatch) { - // The native handle holds this request's turn until its response exists, also when a throw prevents that. - (socket[kPipelinedResponses] ??= []).push(handle); - // For a throw before the kick below. drainMicrotasks() further down runs this one when nothing throws. - kickPipelineIfIdle(server, socket); + // Holds this request's turn until its response exists, also when a throw prevents that. + connectionState(socket).outgoing.push(kNoResponse); + // For such a throw when no response is in flight whose 'finish' would come back to the queue. + if (socket._httpMessage == null) process.nextTick(sendNextResponseIfIdle, socket); } // Like Node.js's resetSocketTimeout (parserOnIncoming): a new request @@ -804,53 +1034,67 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort socketParser[kParserOnTimeout] = serverParserShimOnTimeout; const isPipelined = !!isPipelinedDispatch; - // Pipelined or not, like Node.js: the native parser is in tunnel mode from this request on. + // Pipelined or not, like Node.js: nothing behind this request is parsed. if (method === "CONNECT") { - // Handle CONNECT method for HTTP tunneling/proxy if (server.listenerCount("connect") > 0) { // For CONNECT, emit the event and let the handler respond // Don't assign the socket to a response for CONNECT // The handler should write the raw response - socket[kEnableStreaming](true); - // The connection already parses as a CONNECT tunnel natively; this - // additionally marks it half-open-capable so a client FIN ends the - // readable side without tearing the tunnel down (allowHalfOpen). - socketHandle.upgradeToTunnel(false, handle); - socket[kHandoffResponse] = handle; - // The parser is detached: the socket is handed over with only - // net.Socket's 'end' listener left, like Node.js. - 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. + // Node.js's parserOnIncoming: req.upgrade is true for CONNECT regardless of shouldUpgradeCallback. http_req.upgrade = true; - // Node frees the parser before handing the raw socket to 'connect'. - releaseServerParserShim(socket, http_req); // llhttp completes a CONNECT request at the end of its head, before Node emits 'connect'. http_req.complete = true; + handOffSocket(socket, http_req, isPipelined); + if (isPipelined) { + const { outgoing } = connectionState(socket); + outgoing[outgoing.length - 1] = { handedOff: handle, destroyed: false, destroy() {} }; + } try { server.emit("connect", http_req, socket, head); } catch (err) { // Like 'upgrade' below. Native would end the pending response, here the one ahead. process.nextTick(rethrowUncaught, err); } - // Native would tie a returned promise to the socket's current response, the one in flight. - if (isPipelined) return; - // Attach the internal close listener after the user's "connect" - // 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)); - return promise; + return; } else { // Node.js will close the socket and will NOT respond with 400 Bad Request socketHandle.close(); } return; } - socket[kEnableStreaming](false); + + if (isPipelined) { + // A response is still owed on this connection. This one waits for its turn on Node's path, which can hold what it writes. + handle.onabort = socket[kBoundOnAbort] ??= onServerRequestEvent.bind(socket); + if (hasBody) handle.ondata = onDataIncomingMessage.bind(http_req); + const state = connectionState(socket); + state.outgoing.pop(); // the turn held above + drainMicrotasks(); + const waiting = state.outgoing.length; + let completed = false; + try { + parserOnIncoming(server, socket, state, http_req, http_req[kReqShouldKeepAlive], dispatchBits); + completed = true; + } finally { + const res = socket._httpMessage?.req === http_req ? socket._httpMessage : state.outgoing[waiting]; + if (completed) { + // As for a request that didn't have to wait, the connection closes behind the 503. + const limit = server.maxRequestsPerSocket; + if (limit > 0 && socket.requestsCount > limit) res._last = true; + } else if (res === undefined) { + state.outgoing.push(kNoResponse); + if (socket._httpMessage == null) process.nextTick(sendNextResponseIfIdle, socket); + } else if (res !== socket._httpMessage) { + // It threw. Bun answers that with a close, at the response's turn unless it's complete by then. + res[kDispatchThrew] = true; + } else if (!res.finished) { + closeBehindLastWrite(socket); + } + } + if (!hasBody) completeWithoutBody(http_req, socket); + return; + } // The builtin ServerResponse consumes its options synchronously, so a // reusable scratch object avoids one allocation per request. User @@ -860,14 +1104,14 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort if (ResponseClass === ServerResponse) { scratchResponseOptions[kHandle] = handle; scratchResponseOptions.highWaterMark = socket.writableHighWaterMark; - scratchResponseOptions[kRejectNonStandardBodyWrites] = server.rejectNonStandardBodyWrites; + scratchResponseOptions.rejectNonStandardBodyWrites = server.rejectNonStandardBodyWrites; http_res = new ResponseClass(http_req, scratchResponseOptions); scratchResponseOptions[kHandle] = undefined; } else { http_res = new ResponseClass(http_req, { [kHandle]: handle, highWaterMark: socket.writableHighWaterMark, - [kRejectNonStandardBodyWrites]: server.rejectNonStandardBodyWrites, + rejectNonStandardBodyWrites: server.rejectNonStandardBodyWrites, }); } http_res._keepAliveTimeout = server.keepAliveTimeout; @@ -889,28 +1133,13 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort // maxRequestsPerSocket limit - pipelined requests past the limit // still need to be answered with 503.) if (!http_req[kReqShouldKeepAlive]) { - http_res[kMustCloseConnection] = true; + http_res._last = true; } // One plain on() listener (once() allocates a wrapper, a second listener // deoptimizes every 'finish' emit), registered before the 'request' event // like Node's resOnFinish so res.on-replacing middleware cannot swallow it. http_res.on("finish", emitResponseFinish); - if (hasObserver("http")) { - startPerf(http_res, kServerResponseStatistics, { - type: "http", - name: "HttpRequest", - detail: { - req: { - method: http_req.method, - url: http_req.url, - headers: http_req.headers, - }, - }, - }); - http_res.once("finish", stopServerResponsePerf); - } - handle.onabort = socket[kBoundOnAbort] ??= onServerRequestEvent.bind(socket); // Like Node's connectionListener -> parserOnBody: body bytes flow into // the IncomingMessage as they arrive, and the push callback readStop()s @@ -923,24 +1152,13 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort let pendingPromise: Promise | undefined; let didFinish = false; - if (isSocketNew) { - server.emit("connection", socket); - } - - // Target of the socket 'timeout' handler until this request's response detaches. - socket[kRequest] = http_req; // Node.js (llhttp) only flags a request as an upgrade when it carries // both an Upgrade header and a Connection header with the "upgrade" // token; the server then consults shouldUpgradeCallback (default: an // 'upgrade' listener is installed) and otherwise dispatches the // request normally. - // Pipelined requests never upgrade, because a queued native response can't. let is_upgrade = false; - if ( - !isPipelined && - (dispatchBits & DISPATCH_HAS_UPGRADE) !== 0 && - (dispatchBits & DISPATCH_CONN_UPGRADE) !== 0 - ) { + if ((dispatchBits & DISPATCH_HAS_UPGRADE) !== 0 && (dispatchBits & DISPATCH_CONN_UPGRADE) !== 0) { // Like Node.js, shouldUpgradeCallback sees req.upgrade === true. http_req.upgrade = true; is_upgrade = !!server.shouldUpgradeCallback(http_req); @@ -949,24 +1167,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort // 'upgrade' listener and false for a declined upgrade that falls // through to 'request'. http_req.upgrade = is_upgrade; - if (isPipelined) { - // A previous response on this connection has not finished yet: like - // Node.js, this response is queued (res.socket === null) and its - // writes are buffered until the in-flight response finishes and the - // pipeline assigns it the socket (advanceResponsePipeline). - socket[kPipelinedResponses]?.pop(); // the turn that the native handle held - queuePipelinedResponse(socket, http_res, !!isAncientHTTP); - // A pipelined dispatch can arrive after the previous response finished and detached - // (bytes still flushing keep it pending), leaving nothing in flight to advance the - // queue. Kick the pipeline once this dispatch settles. - kickPipelineIfIdle(server, socket); - // Node's parserOnIncoming stops reading the connection once the bytes - // queued on responses that do not own the socket yet reach the - // socket's high water mark, so pipelined requests cannot flood it. - if (!socket._paused && (socket[kOutgoingData] ?? 0) >= socket.writableHighWaterMark) { - pausePipelineReads(socket); - } - } else if (!is_upgrade) { + if (!is_upgrade) { // Node.js's connectionListener registers socketOnClose, which frees // the parser - even a manually emitted 'close' stops parsing of any // pipelined requests still in the buffer. @@ -986,12 +1187,8 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort if (pendingPromise) $resolvePromiseWithFirstResolvingFunctionCallCheck(pendingPromise, undefined); } - if (!isPipelined) { - setCloseCallback(http_res, onClose); - } - // Node traces every parsed request before Expect/limit routing - // (parserOnIncoming); upgrades never reach that path. - if (!is_upgrade) traceServerRequestStart(http_res); + setCloseCallback(http_res, onClose); + if (!is_upgrade) observeRequest(http_req, http_res); // Like Node.js: with the optimizeEmptyRequests server option, // requests without body headers skip the Readable life cycle (no @@ -1004,65 +1201,24 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort http_req._dumpAndCloseReadable(); } - // Node's parserOnIncoming never counts an accepted upgrade or a missing-Host 400 against maxRequestsPerSocket. - let rejectMissingHost = false; - let reachedRequestsLimit = false; - // Node's parserOnIncoming counts requests and routes Expect for HTTP/1.1 only. - const isHttp11 = http_req.httpVersionMajor === 1 && http_req.httpVersionMinor === 1; - if (!is_upgrade) { - const maxRequestsPerSocket = server.maxRequestsPerSocket; - if ( - server.requireHostHeader && - (dispatchBits & DISPATCH_HAS_HOST) === 0 && - http_req.httpVersionMajor === 1 && - http_req.httpVersionMinor >= 1 - ) { - // The native parser skips its Host check for Upgrade requests; one dispatched normally still needs it. - rejectMissingHost = true; - } else if (isHttp11 && typeof maxRequestsPerSocket === "number" && maxRequestsPerSocket > 0) { - const requestCount = (socket._requestCount || 0) + 1; - socket._requestCount = requestCount; - http_res._maxRequestsPerSocket = maxRequestsPerSocket; - // <= like Node: the response that uses up the limit, and every 503 after it, advertises Connection: close. - http_res.maxRequestsOnConnectionReached = maxRequestsPerSocket <= requestCount; - reachedRequestsLimit = maxRequestsPerSocket < requestCount; - } - } + // Node's parserOnIncoming never counts an accepted upgrade or a missing-Host 400 against maxRequestsPerSocket, and routes HTTP/1.1 only. + const expectation = + is_upgrade || isAncientHTTP ? kNoExpectation : expectationFromBits(server, http_res, socket, dispatchBits); - if (reachedRequestsLimit) { - server.emit("dropRequest", http_req, socket); - http_res.writeHead(503); - http_res.end(); - if (isPipelined) { - // The 503 is queued behind the in-flight responses; the connection - // closes once it has been written, like Node.js (Connection: close). - http_res[kMustCloseConnection] = true; - } else { - socket.destroy(); - } + if (expectation === kOverRequestsLimit) { + emitRequest(server, socket, http_req, http_res, expectation); + socket.destroy(); } else if (is_upgrade) { - // Hand the raw socket over to the 'upgrade' listener, like Node.js. - // Without a body the message is already complete: bytes that arrived - // after the request head become the upgradeHead and the connection - // switches into CONNECT-style tunnel mode immediately. With a body - // (Node 26 semantics) the body keeps being parsed and delivered - // through req; the connection only switches to tunnel mode once the - // message completes, so the upgradeHead is empty and everything after - // the end of the message reaches the socket as raw data. - socketHandle.upgradeToTunnel(hasBody, handle); - socket[kHandoffResponse] = handle; - socket[kEnableStreaming](true); - detachSocketListenersForHandoff(socket); - // Node frees the parser before emitting 'upgrade' (socket.parser === null there). - releaseServerParserShim(socket, http_req); + // Like Node 26, a body is parsed and delivered through req. The connection leaves HTTP behind it, so the head is empty. if (hasBody) { - socket[kUpgradeIncoming] = http_req; - http_req.once("end", clearUpgradeIncoming.bind(undefined, socket)); - } else { - http_req.complete = true; + socketHandle.leaveHttpAfterBody(); + handle.ondata = onUpgradeBodyData.bind(http_req); + handle.notifyWhenReadParsed(); + return; } + http_req.complete = true; + handOffSocket(socket, http_req, false); const upgradeHead = !hasBody && connectHead ? connectHead : kEmptyBuffer; - socket.bytesRead += upgradeHead.length; let upgradeHandled; try { upgradeHandled = server.emit("upgrade", http_req, socket, upgradeHead); @@ -1079,46 +1235,12 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort socket.destroy(); return; } - // Like CONNECT: the connection is detached from the HTTP request - // machinery; hold the native callback open until the raw socket - // closes. - const upgradePromise = $newPromise(); - socket.once("close", resolveHandoffPromise.bind(undefined, upgradePromise)); - return upgradePromise; - } else if (rejectMissingHost) { - http_res.writeHead(400, { Connection: "close" }); - http_res.end(); + return; } else { - if (isHttp11 && (dispatchBits & DISPATCH_HAS_EXPECT) !== 0) { - // Case-insensitive, token-boundary match like Node's - // parserOnIncoming (RFC 7231 5.1.1: expectation values compare - // case-insensitively); computed natively into the bitfield. - if ((dispatchBits & DISPATCH_EXPECT_CONTINUE) !== 0) { - if (server.listenerCount("checkContinue") > 0) { - server.emit("checkContinue", http_req, http_res); - } else { - http_res.writeContinue(); - server.emit("request", http_req, http_res); - } - } else if (server.listenerCount("checkExpectation") > 0) { - server.emit("checkExpectation", http_req, http_res); - } else { - http_res.writeHead(417); - http_res.end(); - } - } else { - server.emit("request", http_req, http_res); - } + emitRequest(server, socket, http_req, http_res, expectation); } - // Node's parserOnMessageComplete: a message without a body is complete once the listener returns. EOF stays lazy (_read). - if (!hasBody) http_req.complete = true; - - if (isPipelined) { - // Completion of a queued response is tracked through the pipeline - // (advanceResponsePipeline) and its native handle, not this dispatch. - return; - } + if (!hasBody) completeWithoutBody(http_req, socket); if (handle.finished || didFinish) { handle = undefined; @@ -1128,9 +1250,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort // between the 'request' emit and here drains the tick queue. http_res[kDetachedFrom] = socket; http_res.detachSocket(socket); - if (socket[kPipelinedResponses] !== undefined) { - advanceResponsePipeline(server, socket); - } + sendNextResponseIfAny(socket); return; } @@ -1141,40 +1261,88 @@ 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, + server: this, + isUnix: !!socketPath || (fd !== undefined && handle.address === null), + tls, 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); - - if (this?._unref) { - this[serverSymbol]?.unref?.(); + // Only `bun --hot` sets an id. The next listen() on this address reuses this native server. + if (handle.id !== "") { + const owners = (nativeServerOwners ??= new (require("internal/primordials").SafeWeakMap)()); + const previous = owners.get(handle); + owners.set(handle, this); + if (previous != null) releaseListener(previous); + } + const tlsServer = this[tlsSymbol]; + if (tlsServer) { + for (const { 0: hostname, 1: context } of tlsServer[kSNIContexts]) { + addServerName(handle, hostname, context.context); + } } + if (this._unref) handle.unref(); + // A tick, not a timer, as in node: a timer never fires on its own under jest.useFakeTimers(). - process.nextTick(emitListeningNextTick, this, this[serverSymbol]?.hostname, this[serverSymbol]?.port); + process.nextTick(emitListeningNextTick, this, handle.hostname, handle.port); } }; // Pushes the per-server parser/handler options down to the native listener. -// Always sets strict method validation, for node.js compatibility. function applyServerCustomOptions(server: Server) { const handle = server[serverSymbol]; if (!handle) return; setServerCustomOptions( handle, - server.requireHostHeader, - true, serverLenientFlags(server), + !!server.httpAllowHalfOpen, typeof server.maxHeaderSize !== "undefined" ? server.maxHeaderSize : getMaxHTTPHeaderSize(), onServerClientError.bind(server, server[kListenerGeneration]), - onServerConnection.bind(server, server[kListenerGeneration]), - !!server.httpAllowHalfOpen, ); setServerMaxHeadersCount(handle, nativeMaxHeadersCount(server.maxHeadersCount)); + const SocketClass = server[kListenerGeneration].tls ? TLSSocket : NetSocket; + const { 0: handlers, 1: data } = require("node:net").Server.prototype[kForeignListener].$call(server, SocketClass); + // The native side keeps the async context it's called in for every handler. What a socket emits runs in nobody's AsyncLocalStorage store, as in Node. + const frame = AsyncContextFrame.current(); + const socketFrame = AsyncContextFrame.currentGraphFrame(); + AsyncContextFrame.run( + socketFrame, + setBunServerSocketHandlers, + undefined, + handle, + frame === socketFrame ? handlers : withServerEventsIn(frame, handlers), + server[tlsSymbol]?.ALPNProtocols ?? kEmptyBuffer, + data, + ); +} + +// 'connection', 'secureConnection', 'keylog' and the TLS callbacks are the server's: they run in the context of listen(). +function withServerEventsIn(frame, handlers) { + const table = { ...handlers }; + for (const name of ["open", "handshake", "keylog", "serverName", "alpnCallback"]) { + const handler = handlers[name]; + if (handler) table[name] = (...args) => AsyncContextFrame.run(frame, handler, undefined, ...args); + } + return table; +} + +// 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 = nativeServerOwners.get(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 @@ -1197,7 +1365,7 @@ function httpAllowHalfOpenSet(this: Server, value) { const next = !!value; if (previous === next) return; const handle = this[serverSymbol]; - if (handle) setServerAppFlags(handle, this.requireHostHeader, true, serverLenientFlags(this), next); + if (handle) setServerAppFlags(handle, serverLenientFlags(this), next); } // Node.js keeps httpAllowHalfOpen as an own enumerable property of the server. @@ -1240,65 +1408,6 @@ 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). - return; - } - const isTLS = !!this[tlsSymbol]; - const socket = new (getNodeHTTPServerSocket())(this, socketHandle, isTLS, listenerGeneration); - - // Node's net.Server accept path refuses at maxConnections and emits 'drop'; the native - // listener bypasses that, so gate it here. `>` (not Node's `>=`) because the constructor - // above already tracked this socket in the count. - const maxConnections = this.maxConnections; - const tracked = this[kTrackedConnections]; - if (maxConnections != null && (tracked?.size ?? 0) > maxConnections) { - tracked?.delete(socket); - const data = { - localAddress: socket.localAddress, - localPort: socket.localPort || socketHandle.localPort, - localFamily: socket.localFamily, - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - remoteFamily: socket.remoteFamily || "IPv4", - }; - socket.destroy(); - this.emit("drop", data); - return; - } - - // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2338-L2346 - const blockList = this.blockList; - if (blockList) { - const remoteAddress = socket.remoteAddress; - const addressType = isIP(remoteAddress); - if (addressType) { - let blocked = true; - try { - blocked = !!blockList.check(remoteAddress, `ipv${addressType}`); - } finally { - if (blocked) { - tracked?.delete(socket); - socket.destroy(); - } - } - if (blocked) return; - } - } - - // 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); - } -} - // Like Node.js: server.setTimeout only records the per-socket inactivity // timeout (and an optional 'timeout' listener); it is applied to each // connection when it is established, via socket.setTimeout. @@ -1309,15 +1418,12 @@ Server.prototype.setTimeout = function (msecs, callback) { }; type NodeHTTPResponseAbortEvent = import("internal/http").NodeHTTPResponseAbortEvent; -function onServerRequestEvent(this: NodeHTTPServerSocket, event: NodeHTTPResponseAbortEvent) { - const socket: NodeHTTPServerSocket = this; +function onServerRequestEvent(this: any, event: NodeHTTPResponseAbortEvent) { + const socket = this; switch (event) { - case NodeHTTPResponseAbortEvent.abort: { - if (!socket.destroyed) { - socket.destroy(); - } + case NodeHTTPResponseAbortEvent.abort: + // The socket's own handle hears the close right behind this, with how it closed. break; - } case NodeHTTPResponseAbortEvent.timeout: { socket.emit("timeout"); break; @@ -1330,7 +1436,6 @@ enum HttpParserError { HTTP_PARSER_ERROR_INVALID_CHUNKED_ENCODING = 1, HTTP_PARSER_ERROR_INVALID_CONTENT_LENGTH = 2, HTTP_PARSER_ERROR_INVALID_TRANSFER_ENCODING = 3, - HTTP_PARSER_ERROR_MISSING_HOST_HEADER = 4, HTTP_PARSER_ERROR_INVALID_REQUEST = 5, HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE = 6, HTTP_PARSER_ERROR_INVALID_HTTP_VERSION = 7, @@ -1345,36 +1450,17 @@ 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 // 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, -) { - 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 - // wrapper would overwrite its onclose/duplex and strand the first one in - // 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); - if (!existingDuplex) { - nodeSocket.parser = createServerParserShim(nodeSocket); - self.emit("connection", nodeSocket); - } - - if (errorCode === HttpParserError.HTTP_PARSER_ERROR_MISSING_HOST_HEADER) { - replyMissingHostHeader(nodeSocket); - return; - } +function onServerClientError(listenerGeneration, socket: unknown, errorCode: number, rawPacket: ArrayBuffer) { + const nodeSocket = (socket as any).duplex; let err; switch (errorCode) { @@ -1382,7 +1468,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,100 +1529,129 @@ 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); } -// Node answers an HTTP/1.1 request with no Host header from parserOnIncoming -// (res.writeHead(400, ['Connection', 'close']); res.end()), so no parse error -// reaches socketOnError and 'clientError' never fires for it. -function replyMissingHostHeader(socket) { - if (!socket.writable) return; - socket.write( - `HTTP/1.1 400 Bad Request\r\nConnection: close\r\nDate: ${new Date().toUTCString()}\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n`, - ); - socket.destroySoon(); -} - -// Snapshot of bytesWritten, taken when the socket releases its native handle. -const kBytesWritten = Symbol("kBytesWritten"); -// 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); -} -const kEnableStreaming = Symbol("kEnableStreaming"); -// Upgrade request whose body is still being parsed: reading the raw socket also -// resumes this request, like Node.js's UpgradeStream._read, so an unread body -// can never stall the upgrade data behind it. -const kUpgradeIncoming = Symbol("kUpgradeIncoming"); - -// Like Node.js's net.Socket onReadableStreamEnd: every socket carries one 'end' -// listener. http server connections have allowHalfOpen: true, so it is a no-op, -// but its presence is part of the listener set handed to 'connect'/'upgrade'. -function onReadableStreamEnd() {} - -function clearUpgradeIncoming(socket) { - socket[kUpgradeIncoming] = undefined; -} - -// Node's UpgradeStream._read: resume the request only while its body is incomplete. -function resumePausedUpgradeIncoming(socket) { - const req = socket[kUpgradeIncoming]; - if (req === undefined) return; - const response = socket[kHandle]?.response; - if (response && (response.hasBody & NodeHTTPBodyReadState.done) !== 0) { - socket[kUpgradeIncoming] = undefined; - } else { - req.resume(); - } -} - // Node.js hands the connection over to 'connect'/'upgrade' listeners with the // connection-listener set removed (onParserExecuteCommon removes its data/end/ // close/drain/error/timeout listeners) and only net.Socket's own 'end' listener // left in place. -function detachSocketListenersForHandoff(socket) { +const kHandedOffSockets = Symbol("kHandedOffSockets"); +function detachFromHttp(socket, req) { socket[kHandedOff] = true; socket.removeListener("error", socketOnError); - socket.removeListener("timeout", onNodeHTTPServerSocketTimeout); - socket.on("end", onReadableStreamEnd); - // Like Node's onParserExecuteCommon: a flowing socket would drop tunnel bytes that arrive before the listener attaches a reader. + socket.removeListener("timeout", socketOnTimeout); + socket.removeListener("close", nativeSocketOnClose); + socket.removeListener("close", onSocketCloseStopParsing); + socket.removeListener("end", nativeSocketOnEnd); + socket.removeListener("pause", onSocketPause); + socket.removeListener("resume", nativeSocketOnResume); + socket.removeListener("data", noopOnData); + socket.removeListener("newListener", unconsumeForListener); + delete socket._onTimeout; + // Only that function ignores the keep-alive timer, which stays armed while a request is in flight. + if (socket.timeout === 0) socket.setTimeout(0); + delete socket._final; + delete socket._destroy; + const server = socket.server; + server[kTrackedConnections].delete(socket); + // In Node each socket holds the process itself. Here unref() on the server has always let go of these sockets too. + server[kHandedOffSockets].add(socket); + if (server._unref) socket.unref(); + socket[kHandleClosed] = destroyUnreadSocket; + // Like Node's onParserExecuteCommon: a flowing socket would drop bytes that arrive before the listener attaches a reader. socket.readableFlowing = null; + // A pause of the HTTP phase ends with it. net.Socket has had a read pending since the accept, so no _read() would lift it. + socket._handle?.resume(); + // Node frees the parser before it emits 'upgrade' or 'connect'. + releaseServerParserShim(socket, req); + // A 'close' listener of a WebSocket logs the peer's address, and net.Socket only keeps one that was read while the socket was open. + socket._getpeername(); +} +// A socket whose connection has closed closes too, also with bytes that nobody reads. Node keeps those for a reader. +function destroyUnreadSocket(this: any) { + if (this.readableLength > 0 && !this.readableFlowing) this.destroy(); +} +function handOffSocket(socket, req, isPipelined) { + detachFromHttp(socket, req); + const http = socket[kHandle]; + if (isPipelined) { + // Responses ahead of this request are still owed. Until its turn the socket reads and writes through uWS. + http.detachParser(); + } else if (http.leaveHttp(true)) { + socket[kHandle] = undefined; + } +} +// ws.handleUpgrade() while uWS still owns the connection: from a 'request' listener, or before the body of the Upgrade request has ended. +Server.prototype[kLeaveHttp] = function (socket, req) { + const http = socket[kHandle]; + if (http === undefined) return; + // A framework's own request object stands for the request that is being answered. + if (req[kHandle] === undefined) req = socket.parser?.incoming ?? socket[kUpgradeStream]?.[kIncomingMessage]; + // A request that waits behind other responses doesn't own the connection. + if (req == null || http.response !== req[kHandle]) return; + const res = socket._httpMessage; + // What a paused request left in native code is its body, not the WebSocket's frames. + req[kHandle].resume(); + if (socket[kHandle] === undefined) return; + if (!socket[kHandedOff]) detachFromHttp(socket, req); + if (!http.leaveHttp(false)) return; + socket[kHandle] = undefined; + socket[kUpgradeStream] = undefined; + res?.detachSocket(socket); + // A reader that waits for the rest of the body gets 'end', like in Node 25 and older. + if (!req.complete) onDataIncomingMessage.$call(req, undefined, true, NodeHTTPResponseAbortEvent.none); +}; +// The body of an accepted Upgrade request. Node parses the whole read before it emits 'upgrade': onConnectionClosed() emits it for a body that came with its head. +function onUpgradeBodyData(this: any, chunk, isLast, event) { + const socket = this.socket; + if (event === NodeHTTPResponseAbortEvent.readParsed) { + if (!socket[kHandedOff]) emitUpgrade(socket, this, false); + return; + } + if (isLast && !socket[kHandedOff]) socket[kUpgradeRequest] = this; + onDataIncomingMessage.$call(this, chunk, isLast, event); +} +// On a socket: the Upgrade request whose body ended in the read that brought its head, until 'upgrade' is emitted. +const kUpgradeRequest = Symbol("kUpgradeRequest"); +function emitUpgrade(socket, req, complete) { + if (socket.destroyed) return; + const server = socket.server; + detachFromHttp(socket, req); + let stream = socket; + if (!complete) { + // Node's socketOnEnd stays on the socket until the body has ended. + socket.on("end", socket.end); + stream = socket[kUpgradeStream] = new UpgradeStream(socket, req); + // The stream destroyer (a `for await` that is left early) clears req.socket. + req.once("close", onUpgradeRequestClose.bind(socket)); + } + try { + if (!server.emit("upgrade", req, stream, kEmptyBuffer)) socket.destroy(); + } catch (err) { + process.nextTick(rethrowUncaught, err); + } } -function resolveHandoffPromise(promise) { - $resolvePromise(promise, undefined); +function onUpgradeRequestClose(this: any) { + this[kUpgradeStream]?.requestBodyCompleted(); } -const kSocketTimeoutTimer = Symbol("socketTimeoutTimer"); -const kStreamingEnabled = Symbol("kStreamingEnabled"); -// 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). const scratchResponseOptions = { [kHandle]: undefined, highWaterMark: 0, - [kRejectNonStandardBodyWrites]: false, + rejectNonStandardBodyWrites: false, }; // 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"); -// 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 -// is queued (undefined once it owns the socket) -const kPipelinedResponses = Symbol("kPipelinedResponses"); -const kPipelinedQueuedState = Symbol("kPipelinedQueuedState"); -// Node's `state.outgoingData`: bytes buffered across this connection's queued -// responses. Reads are paused while it is at or above the high water mark. -const kOutgoingData = Symbol("kOutgoingData"); -const kReplayingPipelinedOps = Symbol("kReplayingPipelinedOps"); +// The timers' clock 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"); const kStopParsingOnCloseListener = Symbol("kStopParsingOnCloseListener"); // The socket the dispatcher already detached a synchronously-finished response from, -// so the 'finish' listener does not detach/advance the pipeline a second time. +// so the 'finish' listener does not detach it or send the next response a second time. const kDetachedFrom = Symbol("kDetachedFrom"); // https://github.com/nodejs/node/blob/v26.3.0/lib/_http_server.js (socketOnError) @@ -1550,7 +1674,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)) { @@ -1561,7 +1686,7 @@ function socketOnError(this: any, err) { // Node checks _headerSent (bytes reached the socket), not headersSent // (writeHead called): after res.writeHead() but before write/end/flush, // no bytes are on the wire yet so the raw error response is still safe. - if (this.writable && (!message || message[headerStateSymbol] !== NodeHTTPHeaderState.sent)) { + if (this.writable && (!message || !message._headerSent)) { let response; switch (err?.code) { case "HPE_HEADER_OVERFLOW": @@ -1577,699 +1702,776 @@ function socketOnError(this: any, err) { response = badRequestResponse; break; } - // Write through the native handle so the raw error response reaches the - // wire before the destroy below, regardless of the duplex's cork state. - const handle = this[kHandle]; - if (handle && !handle.closed) { - handle.write(response); - } else { - this.write(response); - } + this.write(response); } this.destroy(err); } } function noopOnError() {} -function onSocketTimeoutTimerExpired(socket) { - // The keep-alive idle timer is left armed across the request to avoid a - // clear + setTimeout cycle per request. A fire while a request is in - // flight (kKeepAliveTimeoutSet was cleared on request arrival) is stale; - // re-arm the timer so the response-finish fast path, which relies on it - // staying live, keeps working, and bail. This runs at most once per idle - // interval per busy connection, not once per request. - if (socket.timeout === 0 && !socket[kKeepAliveTimeoutSet]) { - socket[kSocketTimeoutTimer]?.refresh(); - return; +// From here to onSocketResume(), Node's connection listener over any Duplex: https://github.com/nodejs/node/blob/v26.5.0/lib/_http_server.js#L763-L1413 +function connectionListenerInternal(server, socket) { + socket.server = server; + + const { timeout } = server; + if (timeout && typeof socket.setTimeout === "function") socket.setTimeout(timeout); + socket.on("timeout", socketOnTimeout); + + const parser = parsers.alloc(); + + const lenientFlags = calculateLenientFlags(server.httpValidation, server.insecureHTTPParser); + + // Node makes the list at 'listening'. A server that never listens tracks its connections too. + const connections = (server[kConnections] ??= new ConnectionsList()); + parser.initialize(HTTPParser.REQUEST, {}, server.maxHeaderSize || 0, lenientFlags, connections); + parser.socket = socket; + socket.parser = parser; + + // Propagate headers limit from server instance to parser + const { maxHeadersCount } = server; + if (typeof maxHeadersCount === "number") { + parser.maxHeaderPairs = maxHeadersCount << 1; + } + + const state: any = { + onData: null, + onEnd: null, + onClose: null, + onDrain: null, + outgoing: [], + incoming: [], + // About how many bytes the responses in `outgoing` hold. Above the high water mark, the socket and the parser pause. + outgoingData: 0, + onPendingData: undefined, + // Node's parser is an AsyncResource made here, so its callbacks run in this context whatever delivers the bytes. + frame: AsyncContextFrame.current(), + }; + state.onData = socketOnData.bind(undefined, server, socket, parser, state); + state.onEnd = socketOnEnd.bind(undefined, server, socket, parser, state); + state.onClose = socketOnClose.bind(undefined, socket, state); + state.onDrain = socketOnDrain.bind(undefined, socket, state); + socket.on("data", state.onData); + socket.on("error", socketOnError); + socket.on("end", state.onEnd); + socket.on("close", state.onClose); + socket.on("drain", state.onDrain); + parser.onIncoming = parserOnIncoming.bind(undefined, server, socket, state); + + socket.on("resume", onSocketResume); + socket.setEncoding = socketSetEncoding; + + socket._paused = false; + // Only the event loop feeds a socket that reads a descriptor, so no tick can hand the parser more bytes in the middle of a read. + parser._consumed = socket._handle?.fd >= 0; + // Node's parser reads from the handle, so a pause() by the socket's previous owner doesn't stop it. + socket.resume(); + process.nextTick(readStartAfterAttach, socket); +} + +// Nor does a pause() by a 'connection' listener behind ours: in Node the 'resume' event of that call starts the handle's reads. Bytes that were put back may have reached a listener by now, whose pause() stays: 'request' (incoming is set), 'upgrade' or 'connect' (no parser). +function readStartAfterAttach(socket) { + if (socket.parser?.incoming === null) readStart(socket); +} + +function socketSetEncoding(): never { + throw $ERR_HTTP_SOCKET_ENCODING(); +} + +function updateOutgoingData(socket, state, delta) { + state.outgoingData += delta; + socketOnDrain(socket, state); +} + +function socketOnDrain(socket, state) { + const needPause = state.outgoingData > socket.writableHighWaterMark; + + // If we previously paused, then start reading again. + if (socket._paused && !needPause) { + socket._paused = false; + socket.parser?.resume(); + socket.resume(); } - // 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; - return; - } + + const msg = socket._httpMessage; + // _flushOutput() calls this through updateOutgoingData() with its bytes still in the socket. Then 'drain' waits for the socket's own. + if (msg && !msg.finished && msg[kNeedDrain] && msg.writableLength === 0) { + msg[kNeedDrain] = false; + msg.emit("drain"); } - // A fired keep-alive idle timer is dead; drop the reference so the next - // response-finish re-arms via setTimeout instead of trusting a fired - // timer whose _idleTimeout still matches (a 'timeout' listener may keep - // the socket alive). Only for the keep-alive case: the server.timeout - // path relies on _unrefTimer() refreshing the fired timer in the slot, - // like net.Socket. - if (socket[kKeepAliveTimeoutSet]) { - socket[kSocketTimeoutTimer] = undefined; - } - socket._onTimeout(); -} - -// Extends net.Socket so server connection sockets satisfy Node's class -// contract (`req.socket instanceof net.Socket`); all I/O still goes through -// the native NodeHTTP handle, not a net handle. -let NodeHTTPServerSocket; -type NodeHTTPServerSocket = InstanceType>; -type NetSocketPrototypeAccessors = - | "bufferSize" - | "bytesWritten" - | "localAddress" - | "localFamily" - | "localPort" - | "pending" - | "readyState" - | "remoteAddress" - | "remoteFamily" - | "remotePort"; -type NetSocket = import("node:net").Socket; -interface NetSocketBase extends Pick, NetSocket {} -interface NetSocketConstructor { - new (options?: import("node:net").SocketConstructorOpts & import("node:stream").DuplexOptions): NetSocketBase; -} -function getNodeHTTPServerSocket() { - if (NodeHTTPServerSocket) return NodeHTTPServerSocket; - 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; - [kBoundOnAbort] = null; - [kKeepAliveIdleStart] = undefined; - [kBytesWritten] = 0; - [kClosedWithUnsentData] = undefined; - [kHandle]; - [kUpgradeIncoming]: import("node:http").IncomingMessage | undefined = undefined; - [kOnReadParsed] = undefined; - [kHandoffResponse] = undefined; - [kDestroySoon] = false; - [kHandedOff] = 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; - #pendingCallback = null; - #pendingAbortMessage; - #closeHandled = false; - #peer; - #closingHandle; - #resetSupported; - #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) { - // 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. - super( - server[kHighWaterMark] !== undefined - ? { highWaterMark: server[kHighWaterMark], allowHalfOpen: true } - : { allowHalfOpen: true }, - ); - // net.Socket's constructor wires net-handle machinery this class replaces: - // its 'end' listener installs writeAfterFIN (breaks half-open tunnels), and - // it forces emitClose/decodeStrings off while this class relies on the - // Duplex defaults (destroy() emits 'close'; _write receives decoded chunks). - this.removeAllListeners("end"); - this._writableState.emitClose = true; - this._readableState.emitClose = true; - this._writableState.decodeStrings = true; - this.server = server; - this.#resetSupported = !encrypted && !listenerGeneration?.isUnix; - this[kHandle] = handle; - this._secureEstablished = !!handle?.secureEstablished; - handle.onclose = this.#onClose.bind(this); - handle.duplex = this; - - this.encrypted = encrypted; - 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. - this.on("error", socketOnError); - server[kTrackedConnections]?.add(this); - // 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) { - this.setTimeout(serverTimeout); - } - } +} - get bytesWritten() { - const handle = this[kHandle]; - let bytes = handle ? handleBytesWritten(handle) : this[kBytesWritten]; - // Like net.Socket, include writes still queued in the Writable. - const queued = this.writableBuffer; - for (let i = 0; i < queued.length; i++) bytes += queued[i].chunk.length; - return bytes; - } - set bytesWritten(value) { - this[kBytesWritten] = value; - } +function socketOnClose(socket, state) { + freeParser(socket.parser, null, socket); + abortIncoming(state.incoming); + abortOutgoing(state.outgoing); +} - // 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. - [kRememberPeer]() { - const handle = this[kHandle]; - if (!handle) return; - this.#peer = { - remoteAddress: handle.remoteAddress, - servername: handle.servername, - peerCertVerified: handle.peerCertVerified, - authorizationError: handle.authorizationError, - }; - } +function abortIncoming(incoming) { + while (incoming.length) { + const req = incoming.shift(); + req.destroy(new ConnResetException("aborted")); + } +} - [kEnableStreaming](enable: boolean) { - // The common keep-alive request never streams: the dispatcher disables - // streaming on every request, so track the state and skip the two native - // setter crossings (writing undefined over undefined) when unchanged. - if (this[kStreamingEnabled] === enable) { - return; - } - const handle = this[kHandle]; - if (handle) { - this[kStreamingEnabled] = enable; - if (enable) { - handle.ondata = this.#onData.bind(this); - handle.ondrain = this.#onDrain.bind(this); - this[kRememberPeer](); - } else { - handle.ondata = undefined; - handle.ondrain = undefined; - } - } - } - #onDrain() { - const callback = this.#pendingCallback; - if (callback) { - this.#pendingCallback = null; - (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(); - } - } - if (last) { - const handle = this[kHandle]; - if (handle) { - handle.ondata = undefined; - this[kStreamingEnabled] = false; - // The peer finished its writable side of a CONNECT/Upgrade tunnel. The - // connection stays writable (allowHalfOpen), but - like Node, where the - // detached socket stops reading and no longer keeps the process alive - - // the never-used response for this request must not keep the event loop - // alive either. - // Not handle.response: behind a pipelined CONNECT that is the response in flight. - this[kHandoffResponse]?.unref(); - } +function abortOutgoing(outgoing) { + // The responses that waited on one connection share one error. + let error; + while (outgoing.length) { + const res = outgoing.shift(); + res[kFailHeldWrites]?.((error ??= $ERR_STREAM_DESTROYED("write"))); + res.destroy(new ConnResetException("aborted")); + } +} - this.push(null); - // Like Node's net.Socket (and its UpgradeStream): EOF on a socket with no - // pending data emits 'end' even when nothing is reading the socket. - if (this.readableLength === 0) { - this.resume(); - } - } - } - #closeHandle(handle, callback, err?: Error) { - this[kBytesWritten] = handleBytesWritten(handle); - this[kHandle] = undefined; - // Capture the in-flight response before detachSocket() can clear it: a - // synchronous res.destroy() inside the request handler runs detachSocket() - // between here and the native close delivering #onClose. The abort itself - // is deferred to #onClose so the dispatch promise resolves only after the - // native on_abort has released the pending-request ref. - this.#pendingAbortMessage = this._httpMessage; - this.#closingHandle = handle; - handle.onclose = this.#onCloseForDestroy.bind(this, callback, err, handle); - if (this.resetAndClosing) { - this.resetAndClosing = false; - handle.reset(); - } else { - handle.close(); - } +function socketOnEnd(server, socket, parser, state) { + const ret = parser.finish(); + + if (ret instanceof Error) { + // socketOnError has additional logic and will call socket.destroy(err). + socketOnError.$call(socket, ret); + } else if (!server.httpAllowHalfOpen) { + socket.end(); + } else { + const { outgoing } = state; + const { length } = outgoing; + if (length) { + outgoing[length - 1]._last = true; + } else { + const { _httpMessage } = socket; + if (_httpMessage) _httpMessage._last = true; + else socket.end(); } - #onClose(closedHandle = this[kHandle]) { - // Once: a queued response's abort can destroy the socket, and run this, before the native close calls it. - if (this.#closeHandled) return; - this.#closeHandled = true; - const errored = this.errored; - // freeParser equivalent: runs before 'close' listeners so they observe the - // released parser (free() invoked, kOnTimeout nulled). - releaseServerParserShim(this); - this[kHandle] = null; - if (closedHandle) { - this[kBytesWritten] = handleBytesWritten(closedHandle); - 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) { - this.push(null); - this.read(0); - } - // With no 'error' listener (a socket handed to 'upgrade') the read error would be an uncaught exception. - const closeError = this.listenerCount("error") > 0 ? closedHandle.closeError : undefined; - if (closeError) { - // Node's errnoException(nread, 'read'): "read ECONNRESET". - const er = new ErrnoException(closeError.errno, "read"); - this.#closeError = er.code === closeError.code ? er : closeError; - } - } - const server = this.server; - const tracked = server?.[kTrackedConnections]; - 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)); - } - } - const timer = this[kSocketTimeoutTimer]; - if (timer) { - clearTimeout(timer); - this[kSocketTimeoutTimer] = undefined; - } + } +} - // Node.js's `socketOnClose` → `abortIncoming()` only destroys requests - // that are still in `state.incoming` — i.e. requests whose response has - // not yet finished (`resOnFinish` does `incoming.shift()`). Our - // equivalent of "still in the queue" is `_httpMessage` being non-null: - // `detachSocket()` (called from `res.end()` / on `"finish"`) clears it. - // Do NOT fall back to `this[kRequest]` here — `_httpMessage` is the - // canonical "response still attached" indicator. (`detachSocket()` now - // clears `kRequest` alongside `_httpMessage`, so the two agree after a - // finished response; historically `kRequest` was never cleared and the - // fallback aborted the request on every keep-alive close even after a - // fully successful response, racing `req._dump()`'s nextTick into a - // spurious `"aborted"` — seen as flakes in the express `res.sendFile` - // suite where supertest closes the socket right after reading the body.) - // - // Gate on `!req.destroyed` rather than `!req.complete`: a body-less GET - // flips `complete` before the response is written, so an aborted - // connection would otherwise never reach `req.destroy()` → - // `emit("close")` (test-http-should-emit-close-when-connection-is-aborted). - // - // `#pendingAbortMessage` is the `_httpMessage` captured when the destroy - // was initiated from JS (`#closeHandle`). It is only honoured when the - // captured response was itself destroy()ed: a `res.end()` followed by a - // JS-initiated socket.destroy() also reaches detachSocket() via the - // dispatcher's finished branch, and Node.js does not abort the request - // once the response has finished (resOnFinish shifts state.incoming). - const pending = this.#pendingAbortMessage; - this.#pendingAbortMessage = undefined; - const message = this._httpMessage ?? (pending?.destroyed ? pending : undefined); - const writeFailure = errored ?? $ERR_STREAM_DESTROYED("write"); - if (message) { - failPendingWriteCallbacks(message, writeFailure); - } - // An ended response that was still draining finishes first, like Node.js's failed last write: its request is not aborted. - const wasDraining = message != null && message[kPendingFinish] !== undefined; - if (wasDraining) flushPendingFinish.$call(message); - const req = wasDraining ? undefined : message?.req; - - // Node emits the response 'close' from its first socket 'close' listener: a response listener cannot remove a socket listener from the current emission. - if (message && !message._closed) { - this.prependOnceListener("close", emitCloseNT.bind(undefined, message)); - } +function socketOnData(server, socket, parser, state, d) { + $assert(!socket._paused); + AsyncContextFrame.run(state.frame, executeParser, undefined, server, socket, parser, state, d); +} - if (req && !req.destroyed && !req[kHandle]?.upgraded) { - // At this point the socket is already destroyed; let's avoid UAF - req[kHandle] = undefined; - if (req.listenerCount("error") > 0) { - req.destroy(new ConnResetException("aborted")); - } else { - req.destroy(); - } - } +function executeParser(server, socket, parser, state, d) { + const ret = parser.execute(d); + onParserExecuteCommon(server, socket, parser, state, ret, d); +} - // Pipelined responses (and their requests) that were still queued behind - // the in-flight response are aborted, like Node.js's socketOnClose - // (abortIncoming + abortOutgoing). - abortQueuedPipelinedResponses(this, writeFailure); - - // Node's server connection socket emits 'close' whenever the TCP - // connection closes, even with no request in flight (this also covers - // tunneled/upgraded sockets, main's kIsTunnel case); reaching here from a - // native close without a JS-initiated destroy must still surface it. - if (!this.destroyed) { - this.destroy(this.#closeError); - } - // A tunnel write that waited for a drain fails now. After destroy(), it settles the callbacks of Writable and emits no 'error'. - const pendingCallback = this.#pendingCallback; - if (pendingCallback) { - this.#pendingCallback = null; - (pendingCallback as Function)(writeFailure); - } - } - // The close of a TLS handle waits for ciphertext that write() reported and a stalled peer does not take, but only once. - destroyNow() { - this.destroy(); - const handle = this.#closingHandle; - if (!handle || handle.closed) return; - handle.close(); - // In the middle of its own request a handle only takes note, and closes behind it, for the first time. - if (!handle.closed) setImmediate(closeHandle, handle); - } - #onCloseForDestroy(closeCallback, err: Error | undefined, handle) { - this.#closingHandle = undefined; - this.#onClose(handle); - // Thread the destroy error through to the streams machinery (like - // Node.js's net.Socket._destroy passing the exception to its callback), - // so socket.destroy(err) emits 'error' before 'close'. - if ($isCallable(closeCallback)) closeCallback(err); - } +class UpgradeStream extends Duplex { + constructor(socket, req) { + super({ + allowHalfOpen: socket.allowHalfOpen, + }); - _onTimeout() { - const handle = this[kHandle]; - const response = handle?.response; - // If there is a response, and it has pending data, - // we suppress the timeout because a write is in progress. - if (response && response.writableLength > 0) { - // Re-arm so the timeout still fires once the write actually stalls. - this._unrefTimer(); - return; - } - this.emit("timeout"); - } - _unrefTimer() { - // Socket activity: push the inactivity timeout (socket.setTimeout / - // server.timeout / keepAliveTimeout) further out, like Node.js's - // net.Socket._unrefTimer. - this[kSocketTimeoutTimer]?.refresh(); - } + this[kSocket] = socket; + this[kIncomingMessage] = req; - address() { - return (this[kHandle] ?? this.#peer)?.remoteAddress || null; - } + // Proxy error, end & closure events immediately. + socket.on("error", err => this.destroy(err)); - get bufferSize() { - return this.writableLength; - } + socket.on("close", () => this.destroy()); + this.on("close", () => socket.destroy()); - connect(_port?, _host?, _connectListener?) { - return this; - } + socket.on("end", () => { + this.push(null); - _destroy(err, callback) { - // Match net.Socket._destroy: #onClose clears this too, but the native close is async and an already-due timer would fire first. - const timer = this[kSocketTimeoutTimer]; - if (timer) { - clearTimeout(timer); - this[kSocketTimeoutTimer] = undefined; - } - const handle = this[kHandle]; - if (!handle) { - if ($isCallable(callback)) callback(err); - return; - } - handle.ondata = undefined; - if (handle.closed) { - const onclose = handle.onclose; - handle.onclose = undefined; - if ($isCallable(onclose)) { - onclose.$call(handle); - } - if ($isCallable(callback)) callback(err ?? this.#closeError); - return; + // Like a socket, which emits 'end' with no 'data' listener if it ends with nothing buffered. + if (this.readableLength === 0) { + this.resume(); } + }); + } - this.#closeHandle(handle, callback, err); - } + requestBodyCompleted(upgradeHead) { + this[kIncomingMessage] = null; - _final(callback) { - const handle = this[kHandle]; - if (!handle) { - callback(); - return; + if (upgradeHead?.length > 0) { + if (!this.push(upgradeHead)) { + this[kSocket].pause(); } - handle.end(this[kDestroySoon]); - callback(); } - // Not destroy() on 'finish' like net.Socket: 'finish' does not wait for bytes uWS still queues. - destroySoon() { - if (this[kDestroySoon]) return; - const handle = this[kHandle]; - if (this.writable && handle && !handle.closed) { - this[kDestroySoon] = true; - this.end(); - return; + this[kSocket].on("data", data => { + if (!this.push(data)) { + this[kSocket].pause(); } - super.destroySoon(); - } + }); + } - get localAddress() { - return this[kHandle]?.localAddress?.address; - } + _read(_size) { + // Otherwise a listener would have to read the whole request body before it could read this stream. + this[kIncomingMessage]?.resume(); - get localFamily() { - return this[kHandle]?.localAddress?.family; - } + this[kSocket].resume(); + } - get localPort() { - return this[kHandle]?.localAddress?.port; - } + _final(callback) { + const socket = this[kSocket]; + // The listener can have ended `req.socket` itself, and end() on a finished socket calls back with an error. + if (socket.writableEnded) callback(); + else socket.end(callback); + } - get pending() { - return this.connecting; - } + _write(chunk, encoding, callback) { + this[kSocket].write(chunk, encoding, callback); + } - #resumeSocket(readStart: boolean) { - const handle = this[kHandle]; - // A tunnel reads again: response.resume() below does nothing for it. - if (readStart && this[kStreamingEnabled]) handle?.readStart(); - const response = handle?.response; - const upgradeIncoming = this[kUpgradeIncoming]; - if (upgradeIncoming) { - // Upgrade with a body: reading the raw socket resumes the request so its - // body keeps draining (Node's UpgradeStream._read). Request-body bytes - // belong to the request stream, never to the raw upgrade stream, and the - // socket does not end when the request body does. - response?.resume(); - // Not paused: req.complete is false in the listener, so it can wait for 'end' with no reader. - if (upgradeIncoming.readableFlowing !== false) upgradeIncoming.resume(); - // The 'upgrade' listener runs before the rest of its read is parsed; that rest can complete the body. - else if (response?.notifyWhenReadParsed()) this[kOnReadParsed] = resumePausedUpgradeIncoming; - else resumePausedUpgradeIncoming(this); - return; - } - response?.resume(); - } + _destroy(err, callback) { + this[kSocket].destroy(err); + callback(err); + } +} + +function onParserExecuteCommon(server, socket, parser, state, ret, d) { + const req = parser.incoming; + if (ret instanceof Error) { + prepareError(ret, parser, d); + socketOnError.$call(socket, ret); + } else if (req?.upgrade) { + // Upgrade or CONNECT + const eventName = req.method === "CONNECT" ? "connect" : "upgrade"; + + let upgradeStream; + if (req.complete) { + socket.removeListener("data", state.onData); + socket.removeListener("end", state.onEnd); + // The responses that still wait for their turn learn of the close from it. + if (state.outgoing.length === 0) socket.removeListener("close", state.onClose); + socket.removeListener("drain", state.onDrain); + socket.removeListener("error", socketOnError); + socket.removeListener("timeout", socketOnTimeout); + socket.removeListener("resume", onSocketResume); + + parser.finish(); + freeParser(parser, req, socket); + parser = null; - _read(_size) { - // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L779-L792 - this.#resumeSocket(true); + upgradeStream = socket; + } else { + // Already emitted, with the body still arriving. + if (socket[kUpgradeStream]) return; + + // The wrapper hides what arrives until the request body has ended. + d = kEmptyBuffer; + upgradeStream = new UpgradeStream(socket, req); + socket[kUpgradeStream] = upgradeStream; } - get readyState() { - if (this.connecting) return "opening"; - if (this.readable) { - return this.writable ? "open" : "readOnly"; + if (server.listenerCount(eventName) > 0) { + const bodyHead = d.slice(ret, d.length); + + if (req.complete && socket[kUpgradeStream]) { + // Previously emitted, now completed - just activate the stream + socket[kUpgradeStream].requestBodyCompleted(bodyHead); } else { - return this.writable ? "writeOnly" : "closed"; + socket.readableFlowing = null; + server.emit(eventName, req, upgradeStream, bodyHead); } + } else { + // Got upgrade or CONNECT method, but have no handler. + socket.destroy(); } + } else if (req && req.method === "PRI") { + socket.destroy(); + } - // 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; - } + if (socket._paused) { + // onIncoming paused the socket, we should pause the parser as well + socket.parser?.pause(); + } +} - // 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; +function clearIncoming(this: any, req?, socket?) { + req ||= this; + // stream.pipeline() takes the socket off a request that it destroys. + const parser = (socket ?? req.socket)?.parser; + // Reset the .incoming property so that the request object can be gc'ed. + if (parser && parser.incoming === req) { + // A destroyed request gets no 'end'. In Node it stays, and its 'timeout' listeners can keep the idle connection open. + if (req.readableEnded || req.destroyed) { + parser.incoming = null; + } else { + req.on("end", clearIncoming); } + } +} - get authorizationError() { - if (!this.encrypted) return undefined; - if (!this.server?.[tlsSymbol]?.requestCert) return null; - return (this[kHandle] ?? this.#peer)?.authorizationError ?? null; - } +function resOnFinish(req, res, socket, state, server) { + // abortIncoming() may have emptied the array. + $assert(state.incoming.length === 0 || state.incoming[0] === req); - ref() { - return this; - } + state.incoming.shift(); - get remoteAddress() { - return this.address()?.address; - } + // Nothing read the body, so pull it off the wire. + if (!req._consuming && !req._readableState.resumeScheduled) req._dump(); - set remoteAddress(val) { - // initialize the object so that other properties wouldn't be lost - this.address().address = val; - } + // uWS learns that the response it counted for this request is out. The handle has nothing of its own to write. + const request = res[kNativeRequest]; + if (request !== undefined && !request.finished) request.end(); + res.detachSocket(socket); + clearIncoming(req, socket); + process.nextTick(emitCloseNT, res); - get remotePort() { - return this.address()?.port; + if (res._last) { + closeBehindLastWrite(socket); + } else if (state.outgoing.length === 0) { + const idleTimeout = keepAliveIdleTimeout(server); + if (idleTimeout && typeof socket.setTimeout === "function") { + socket.setTimeout(idleTimeout); + socket[kKeepAliveTimeoutSet] = true; } + } else { + sendNextResponse(socket, state); + } +} - set remotePort(val) { - // initialize the object so that other properties wouldn't be lost - this.address().port = val; - } +function closeBehindLastWrite(socket) { + if (typeof socket.destroySoon === "function") { + socket.destroySoon(); + } else { + socket.end(); + } +} + +const kDispatchThrew = Symbol("kDispatchThrew"); + +function sendNextResponse(socket, state) { + const m = state.outgoing.shift(); + // Nothing can fill a destroyed response's slot, so close (Node assigns it). + if (m === undefined) return; + if (m[kDispatchThrew] && !m.finished) m.destroy(); + if (m.destroyed) { + if (typeof socket.destroySoon === "function") socket.destroySoon(); + else socket.destroy(); + } else if (m.handedOff === undefined) giveSocket(m, socket); + // The turn of a 'connect' that came behind responses. It got the rest of its read as `head`, and the socket got every later read. + else if (socket[kHandle].startRawResponse(m.handedOff, false) && socket[kHandle].leaveHttp(true)) + socket[kHandle] = undefined; +} + +// The request of a response that waited for its turn on a connection that uWS parses. +const kNativeRequest = Symbol("kNativeRequest"); + +function giveSocket(res, socket) { + const request = res[kNativeRequest]; + if (request !== undefined && !socket[kHandle]?.startRawResponse(request, !res.shouldKeepAlive)) + return void res.destroy(); + res.assignSocket(socket); +} + +// What Node's connection listener keeps for each connection, made for one that uWS parses when its first request arrives early. +const kConnectionState = Symbol("kConnectionState"); +function connectionState(socket) { + let state = socket[kConnectionState]; + if (state === undefined) { + state = socket[kConnectionState] = { outgoing: [], incoming: [], outgoingData: 0, onPendingData: undefined }; + socket.on("drain", socketOnDrain.bind(undefined, socket, state)); + } + return state; +} + +// Longer than the timeout the response advertised, so a client that uses all of it sees fewer ECONNRESETs. 0 if there is none. +function keepAliveIdleTimeout(server) { + const { keepAliveTimeout, keepAliveTimeoutBuffer } = server; + if (!(Number.isFinite(keepAliveTimeout) && keepAliveTimeout > 0)) return 0; + return ( + keepAliveTimeout + + (Number.isFinite(keepAliveTimeoutBuffer) && keepAliveTimeoutBuffer >= 0 ? keepAliveTimeoutBuffer : 1e3) + ); +} + +function hasBodyHeaders(headers) { + return "content-length" in headers || "transfer-encoding" in headers; +} - get remoteFamily() { - return this.address()?.family; +function parserOnIncoming(server, socket, state, req, keepAlive, dispatchBits?) { + resetSocketTimeout(server, socket); + + if (req.upgrade) { + req.upgrade = req.method === "CONNECT" || !!server.shouldUpgradeCallback(req); + if (req.upgrade) { + return 0; } + } + + state.incoming.push(req); - set remoteFamily(val) { - // initialize the object so that other properties wouldn't be lost - this.address().family = val; + // Stop reading while the client doesn't take the responses, or pipelined requests could flood the connection. + if (!socket._paused) { + const ws = socket._writableState; + if (ws.needDrain || state.outgoingData >= socket.writableHighWaterMark) { + socket._paused = true; + // onParserExecuteCommon() pauses the parser, once execute() is done with this chunk. + socket.pause(); } + } - resetAndDestroy() { - if (this[kHandle]) { - if (!this.#resetSupported) { - throw $ERR_INVALID_HANDLE_TYPE(); - } - this.resetAndClosing = true; - this.destroy(); - } else { - this.destroy($ERR_SOCKET_CLOSED()); + const res = new server[kServerResponse](req, { + [kHandle]: null, + highWaterMark: socket.writableHighWaterMark, + rejectNonStandardBodyWrites: server.rejectNonStandardBodyWrites, + }); + if (req[kHandle] !== undefined) res[kNativeRequest] = req[kHandle]; + res._keepAliveTimeout = server.keepAliveTimeout; + res._maxRequestsPerSocket = server.maxRequestsPerSocket; + res._onPendingData = state.onPendingData ??= updateOutgoingData.bind(undefined, socket, state); + + res.shouldKeepAlive = keepAlive; + res[kUniqueHeaders] = server[kUniqueHeaders]; + observeRequest(req, res); + + if (server[kOptimizeEmptyRequests] === true && !hasBodyHeaders(req.headers)) { + req._dumpAndCloseReadable(); + req._read(); + } + + if (socket._httpMessage) { + // There are already pending outgoing res, append. + state.outgoing.push(res); + res[fakeSocketSymbol] = null; + } else { + giveSocket(res, socket); + } + + res.on("finish", resOnFinish.bind(undefined, req, res, socket, state, server)); + + let expectation = kNoExpectation; + if (req.httpVersionMajor === 1 && req.httpVersionMinor === 1) { + // uWS reported what its request has, so req.headers stays unbuilt until the program reads it. + if (dispatchBits === undefined) { + const { host, expect } = req.headers; + dispatchBits = host === undefined ? 0 : DISPATCH_HAS_HOST; + if (expect !== undefined) { + dispatchBits |= continueExpression.test(expect) + ? DISPATCH_HAS_EXPECT | DISPATCH_EXPECT_CONTINUE + : DISPATCH_HAS_EXPECT; } - return this; } + expectation = expectationFromBits(server, res, socket, dispatchBits); + } + emitRequest(server, socket, req, res, expectation); - setKeepAlive(_enable = false, _initialDelay = 0) { - return this; - } + return 0; // No special treatment. +} - setNoDelay(_noDelay = true) { - return this; - } +const kNoExpectation = 0; +const kMissingHost = 1; +const kOverRequestsLimit = 2; +const kExpectContinue = 3; +const kExpectOther = 4; - // Like Node.js's net.Socket#setTimeout (setStreamTimeout): an unref'd - // inactivity timer that emits 'timeout' on this socket. server.setTimeout, - // server.keepAliveTimeout, req.setTimeout and res.setTimeout all funnel here. - setTimeout(msecs, callback?) { - if (this.destroyed) { - return this; - } +function expectationFromBits(server, res, socket, dispatchBits) { + if (server.requireHostHeader && (dispatchBits & DISPATCH_HAS_HOST) === 0) return kMissingHost; + if (isOverRequestsLimit(server, res, socket)) return kOverRequestsLimit; + if ((dispatchBits & DISPATCH_HAS_EXPECT) === 0) return kNoExpectation; + return (dispatchBits & DISPATCH_EXPECT_CONTINUE) !== 0 ? kExpectContinue : kExpectOther; +} - msecs = getTimerDuration(msecs, "msecs"); - // Assigned after validation (unlike setStreamTimeout): onSocketTimeoutTimerExpired reads this.timeout. - this.timeout = msecs; +// Counts an HTTP/1.1 request against server.maxRequestsPerSocket. +function isOverRequestsLimit(server, res, socket) { + const { maxRequestsPerSocket } = server; + if (typeof maxRequestsPerSocket !== "number" || maxRequestsPerSocket <= 0) return false; + const requestsCount = (socket.requestsCount = (socket.requestsCount | 0) + 1); + res._maxRequestsPerSocket = maxRequestsPerSocket; + res.maxRequestsOnConnectionReached = maxRequestsPerSocket <= requestsCount; + return maxRequestsPerSocket < requestsCount; +} - const existingTimer = this[kSocketTimeoutTimer]; - if (msecs === 0) { - if (existingTimer) { - clearTimeout(existingTimer); - this[kSocketTimeoutTimer] = undefined; - } - if (callback !== undefined) { - validateFunction(callback, "callback"); - this.removeListener("timeout", callback); - } +// How Node's parserOnIncoming answers or announces a request, for both parsers. +function emitRequest(server, socket, req, res, expectation) { + switch (expectation) { + case kNoExpectation: + server.emit("request", req, res); + break; + case kMissingHost: + res.writeHead(400, ["Connection", "close"]); + res.end(); + break; + case kOverRequestsLimit: + server.emit("dropRequest", req, socket); + res.writeHead(503); + res.end(); + break; + case kExpectContinue: + res._expect_continue = true; + if (server.listenerCount("checkContinue") > 0) { + server.emit("checkContinue", req, res); } else { - // The keep-alive cycle re-arms the same idle interval after every - // response: refresh the existing timer instead of clear+allocate. - if (existingTimer && existingTimer._idleTimeout === msecs) { - existingTimer.refresh(); - } else { - if (existingTimer) clearTimeout(existingTimer); - const timer = setTimeout(onSocketTimeoutTimerExpired, msecs, this); - timer.unref(); - this[kSocketTimeoutTimer] = timer; - } - if (callback !== undefined) { - validateFunction(callback, "callback"); - this.once("timeout", callback); - } + res.writeContinue(); + server.emit("request", req, res); } - return this; - } + break; + default: + if (server.listenerCount("checkExpectation") > 0) { + server.emit("checkExpectation", req, res); + } else { + res.writeHead(417); + res.end(); + } + } +} - setEncoding(_encoding): never { - const err = new Error("Changing the socket encoding is not allowed per RFC7230 Section 3."); - err.code = "ERR_HTTP_SOCKET_ENCODING"; - throw err; - } +// Node's parserOnMessageComplete: a message without a body is complete once the listener returns, and the reads go on unless http paused them. EOF stays lazy (_read). +function completeWithoutBody(req, socket) { + req.complete = true; + if (!socket._paused && socket.readable && socket.isPaused()) socket.resume(); +} - unref() { - return this; - } +function resetSocketTimeout(server, socket) { + if (!socket[kKeepAliveTimeoutSet]) return; - _write(_chunk, _encoding, _callback) { - const handle = this[kHandle]; - let err; - this._unrefTimer(); - try { - if (handle) { - const flushed = handle.write(_chunk, _encoding); - if (!flushed && handle.ondrain) { - // Streaming mode (CONNECT tunnels): wait for the native drain - // callback before completing the write. - this.#pendingCallback = _callback; - return false; - } - } - } catch (e) { - err = e; - } - if (err) _callback(err); - else _callback(); - } + socket.setTimeout(server.timeout || 0); + socket[kKeepAliveTimeoutSet] = false; +} - pause() { - const handle = this[kHandle]; - const response = handle?.response; - if (response) { - response.pause(); - } +function onSocketResume(this: any) { + // 'resume' is emitted a tick after the call, and parserOnIncoming() may have paused the socket since. + if (this._paused) this.pause(); +} - return super.pause(); +function onSocketTimeoutTimerExpired(this: any) { + const socket = this; + // The keep-alive idle timer stays armed across requests. A fire while a request is in flight is stale: re-arm and bail. + if (socket.timeout === 0 && !socket[kKeepAliveTimeoutSet]) { + socket[kTimeout]?.refresh(); + return; + } + // 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 timer = socket[kTimeout]; + if (timer != null && lastWrite > timer._idleStart) { + timer.refresh(); + timer._idleStart = lastWrite; + return; } + } + NetSocket.prototype._onTimeout.$call(socket); +} - resume() { - // Like Node's onSocketResume: the pipeline read gate outranks stream flow. - if (this._paused) return this; - // A full buffer keeps the reads of a tunnel stopped: _read() starts them when the reader has made room. - this.#resumeSocket(this.readableLength < this.readableHighWaterMark); - return super.resume(); - } +let NetSocket; +let TLSSocket; +let assignablePeerAddress; +function assignablePeerMember(name, member) { + return { + configurable: true, + // As net.Socket's getter, but for an object that got this one by a descriptor and is no socket. + get() { + const getPeerName = this._getpeername; + return getPeerName === undefined ? undefined : getPeerName.$call(this)[member]; + }, + set(value) { + Object.defineProperty(this, name, { value, writable: true, configurable: true, enumerable: true }); + }, + }; +} +function loadNet(tls) { + if (NetSocket === undefined) { + NetSocket = require("node:net").Socket; + assignablePeerAddress = { + remoteAddress: assignablePeerMember("remoteAddress", "address"), + remotePort: assignablePeerMember("remotePort", "port"), + remoteFamily: assignablePeerMember("remoteFamily", "family"), + }; + } + if (tls) TLSSocket ??= require("node:tls").TLSSocket; +} - get [kInternalSocketData]() { - return this[kHandle]?.response; - } +function attachConnection(server: Server, socket, http) { + socket[kHandle] = http; + http.duplex = socket; + http.onclose = onConnectionClosed.bind(http, server[kTrackedConnections]); + if (socket.encrypted) { + // The native SNI dispatch asks the object in the handle's `data`, which is this socket. + socket._SNICallback = server[tlsSymbol]?._SNICallback; + socket.resetAndDestroy = resetAndDestroyNotTCP; + } else { + if (server[kListenerGeneration]?.isUnix) socket.resetAndDestroy = resetAndDestroyNotTCP; + nativeConnectionListener(server, socket); + } +} + +// https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L826-L829 +function resetAndDestroyNotTCP(): never { + throw $ERR_INVALID_HANDLE_TYPE(); +} + +// Node's connectionListenerInternal, for a connection that uWS parses. +function nativeConnectionListener(server: Server, socket) { + server[kTrackedConnections].add(socket); + // Keep a shorter timeout that a 'connection' listener armed. It may have fired during the handshake, so restart it. + const serverTimeout = server.timeout; + if (serverTimeout && !(socket.timeout > 0 && socket.timeout < serverTimeout)) socket.setTimeout(serverTimeout); + else socket._unrefTimer(); + socket.on("timeout", socketOnTimeout); + socket.parser = createServerParserShim(socket); + socket.on("error", socketOnError); + socket.on("close", nativeSocketOnClose); + socket.on("end", nativeSocketOnEnd); + // uWS reads for the parser. The listener keeps the socket flowing like Node's, so that 'end' comes at the peer's FIN. + socket.on("data", noopOnData); + socket.on("resume", nativeSocketOnResume); + socket.on("pause", onSocketPause); + socket.on("newListener", unconsumeForListener); + socket.setEncoding = socketSetEncoding; + socket._onTimeout = onSocketTimeoutTimerExpired; + socket._final = nativeSocketFinal; + socket._destroy = nativeSocketDestroy; + socket._paused = false; + // Declared here, so that a socket that a program seals still takes every later store. + socket[kBoundOnAbort] = undefined; + socket[kCloseCallback] = undefined; + socket[kKeepAliveTimeoutSet] = false; + socket[kLastResponseWrite] = undefined; + socket[kFinishedAtClose] = undefined; + socket[kDetachedWhileClosing] = undefined; + // In Node an assignment to one of these throws in strict mode. Here a server's socket has always kept the value. + Object.defineProperties(socket, assignablePeerAddress); +} + +// Node stores functions of its own in socket.on and its like for this: https://github.com/nodejs/node/blob/v26.5.0/lib/_http_server.js#L1432-L1448 +function unconsumeForListener(this: any, event) { + if (event === "data" || event === "readable") unconsume(this); +} +function noopOnData() {} +// From here on the socket gets every read before the HTTP parser does, like Node's parser.unconsume(). +function unconsume(socket) { + const parser = socket.parser; + if (parser == null || !parser._consumed) return; + parser._consumed = false; + socket.removeListener("pause", onSocketPause); + socket.removeListener("resume", nativeSocketOnResume); + // Gone once uWS has closed the connection, which is before the socket's 'close'. + socket[kHandle]?.setRawReads(true); +} + +function nativeSocketOnResume(this: any) { + if (this._paused) { + this.pause(); + return; + } + this._handle?.resume(); +} +function onSocketPause(this: any) { + this._handle?.pause(); +} + +// Without net.Socket's tick: what the response writes after socket.end() is dropped, as in Node, where both share the socket's Writable. +function nativeSocketFinal(this: any, callback) { + this._handle?.shutdown(); + callback(); +} + +// net.Socket queues 'close' from here. Node's comes from the handle, so it doesn't run in the AsyncLocalStorage store of the request whose end closes the connection. +function nativeSocketDestroy(this: any, err, callback) { + const scheduleClose = this[kHandle] !== undefined ? closeWithConnection : undefined; + const frame = AsyncContextFrame.currentGraphFrame(); + AsyncContextFrame.run(frame, NetSocket.prototype._destroy, this, err, callback, scheduleClose); +} - get [kHasQueuedReads]() { - return this[kHandle]?.hasQueuedTunnelReads === true; +// Like in Node, 'close' comes when the connection has closed: behind the read in progress, ahead of what a client in this process does about it. An immediate, if that is sooner. +function closeWithConnection(emitClose, socket, isException) { + const http = socket[kHandle]; + const onclose = http.onclose; + let isOwed = true; + function emit() { + if (!isOwed) return; + isOwed = false; + emitClose(socket, isException); + } + http.onclose = function (left) { + onclose.$call(this, left); + emit(); + }; + setImmediate(emit); +} + +// Node's socketOnEnd. What becomes of the connection after a clean end is uWS's decision, right behind this. +function nativeSocketOnEnd(this: any) { + if (!this[kHandle]?.finishParsing()) return; + const err: any = $HPE_INVALID_EOF_STATE("Parse Error: Invalid EOF state"); + err.reason = "Invalid EOF state"; + err.rawPacket = kEmptyBuffer; + socketOnError.$call(this, err); +} + +// uWS closed the connection. A response's writes don't pass through the socket's Writable, so they fail here, where Node's destroy() fails them. +function onConnectionClosed(this: any, tracked, left) { + const http = this; + const socket = left ?? http.duplex; + if (socket[kHandle] !== http) return; + socket[kHandle] = undefined; + if (left !== undefined) { + // Not closed: the body of an accepted Upgrade request has ended, and the connection is the socket's from here on. + socket.removeListener("end", socket.end); + const req = socket[kUpgradeRequest]; + if (req !== undefined) { + socket[kUpgradeRequest] = undefined; + emitUpgrade(socket, req, true); } - } as unknown as typeof import("node:net").Socket; - Object.defineProperty(NodeHTTPServerSocket, "name", { value: "Socket" }); - return NodeHTTPServerSocket; + return; + } + tracked.delete(socket); + const pending = socket[kDetachedWhileClosing]; + const message = socket._httpMessage ?? (pending?.destroyed ? pending : undefined); + if (message) { + failPendingWriteCallbacks(message, socket.errored ?? $ERR_STREAM_DESTROYED("write")); + // An ended response that was still draining finishes first, like Node.js's failed last write: its request is not aborted. + if (message[kPendingFinish] !== undefined) { + flushPendingFinish.$call(message); + socket[kFinishedAtClose] = message; + } + } + if (socket.destroyed || http.closeError) { + // The handle's own close, right behind this, reports the error. + } else if (!http.peerEnded) { + // This side closed it, so there's no EOF to report. + socket.destroy(); + } else if (socket.readableEnded) { + // Node's socketOnEnd. net.Socket leaves a half-open socket whose peer is gone to its owner. + socket.end(); + } else if (socket.readableFlowing) { + socket.once("end", socket.end); + } else { + // Nobody reads the EOF, and the connection is gone. + socket.destroy(); + } + // Handed to 'connect' behind responses that were still owed: its 'close' listener went with the hand-off. + if (socket[kHandedOff]) nativeSocketOnClose.$call(socket); +} +const kFinishedAtClose = Symbol("kFinishedAtClose"); + +// Node's socketOnClose: free the parser, abort what is in flight and what is queued. +function nativeSocketOnClose(this: any) { + const socket = this; + releaseServerParserShim(socket); + const pending = socket[kDetachedWhileClosing]; + socket[kDetachedWhileClosing] = undefined; + const message = socket._httpMessage ?? (pending?.destroyed ? pending : undefined); + const req = message != null && socket[kFinishedAtClose] !== message ? message.req : undefined; + if (req && !req.destroyed) { + req[kHandle] = undefined; + if (req.listenerCount("error") > 0) { + req.destroy(new ConnResetException("aborted")); + } else { + req.destroy(); + } + } + if (message && !message._closed) emitCloseNT(message); + const state = socket[kConnectionState]; + if (state !== undefined) { + abortIncoming(state.incoming); + abortOutgoing(state.outgoing); + } +} +const kDetachedWhileClosing = Symbol("kDetachedWhileClosing"); + +// The close of a TLS handle waits for ciphertext that write() reported and a stalled peer does not take, but only once. +function destroyNow(socket) { + const http = socket[kHandle]; + socket.destroy(); + if (!http || http.closed) return; + http.close(); + // In the middle of its own request a handle only takes note, and closes behind it, for the first time. + if (!http.closed) setImmediate(closeHandle, http); } // Node validates the `Trailer` header inside _storeHeader, after the body framing has @@ -2290,11 +2492,23 @@ function willBeChunked(response) { return response.useChunkedEncodingByDefault === true; } +// Returns true if _storeHeader() will frame the body with neither a length nor chunks, so that it ends where the connection does (RFC 9112 6.3). +function bodyEndsAtClose(response) { + if (response._hasBody === false) return false; + const outHeaders = response[kOutHeaders]; + if (outHeaders !== null) { + if (outHeaders["content-length"] !== undefined) return false; + const te = outHeaders["transfer-encoding"]; + if (te !== undefined) return !chunkExpression.test(String(te[1])); + if (outHeaders["trailer"] !== undefined) return response._removedTE; + } + return response._removedTE && (response._removedContLen || typeof response._contentLength !== "number"); +} + // A non-chunked message is terminated by the first empty line after the header // 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,7 +2522,25 @@ function validateStatusCode(statusCode) { return statusCode; } -function _writeHead(statusCode, reason, obj, response) { +// What Node's _implicitHeader() checks and derives. write() and end() send the head without a writeHead() call. +function validateImplicitHead(response) { + const statusCode = (response.statusCode = validateStatusCode(response.statusCode)); + const statusMessage = response.statusMessage; + if (statusMessage !== undefined && checkInvalidHeaderChar(statusMessage)) throw $ERR_INVALID_CHAR("statusMessage"); + updateHasBody(response, 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; +} + +// `explicit`: the program called writeHead(). write() and end() send a `Trailer` header as it is, whatever the request's method or version. +function _writeHead(statusCode, reason, obj, response, explicit) { statusCode = validateStatusCode(statusCode); if (typeof reason === "string") { @@ -2336,7 +2568,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 +2592,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,10 +2602,10 @@ 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)) { + if (explicit && hasInvalidTrailer(response)) { // The message is not chunk-framed, so `Trailer` is the offending header; drop it // so a caller that swallows the throw cannot put it on the wire. response.removeHeader("trailer"); @@ -2386,14 +2618,11 @@ function ServerResponse(req, options): void { if (!(this instanceof ServerResponse)) return new ServerResponse(req, options); OutgoingMessage.$call(this, options); - this.useChunkedEncodingByDefault = true; - this.req = req; this.sendDate = true; this._sent100 = false; - this[headerStateSymbol] = NodeHTTPHeaderState.none; + this._expect_continue = false; this[kPendingCallbacks] = []; - this.finished = false; // this is matching node's behaviour // https://github.com/nodejs/node/blob/cf8c6994e0f764af02da4fa70bc5962142181bf3/lib/_http_server.js#L192 @@ -2404,33 +2633,21 @@ function ServerResponse(req, options): void { this.shouldKeepAlive = false; } - if (options) { - const handle = options[kHandle]; - - if (handle) { - this[kHandle] = handle; - } else { - this[kHandle] = req[kHandle]; - } - // The in-server path passes this via the symbol key (see emitRequestEvent); - // a user calling `new ServerResponse(req, { rejectNonStandardBodyWrites: true })` - // passes the string key — which the OutgoingMessage constructor already - // applied to the same shared symbol — so don't clobber it. - const rejectNonStandardBodyWrites = options[kRejectNonStandardBodyWrites]; - if (rejectNonStandardBodyWrites !== undefined) { - this[kRejectNonStandardBodyWrites] = rejectNonStandardBodyWrites; - } - } else { - this[kHandle] = req[kHandle]; + // The native response of a request that 'upgrade' or 'connect' handed over is done. This one writes to the socket it's assigned, like in Node. + let handle = options?.[kHandle]; + if (handle === undefined && (handle = req[kHandle]) && handle.flags & NodeHTTPResponseFlags.closed_or_completed) { + handle = undefined; } + this[kHandle] = handle; this.statusCode = 200; this.statusMessage = undefined; - this.chunkedEncoding = false; } $toClass(ServerResponse, "ServerResponse", OutgoingMessage); // A response whose own statusCode was deleted sends 200, as in Node.js. ServerResponse.prototype.statusCode = 200; +// A connection that closes under the ended response it carries completes it. Node 26.12 emits no 'finish' and fails the end() callback. +ServerResponse.prototype[kFinishesWithoutLastWrite] = true; // ServerResponse stores its outgoing headers the same way Node.js does (the // kOutHeaders map inherited from OutgoingMessage). Right before the headers @@ -2469,6 +2686,10 @@ 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; +// Bits 5 and 6, no header line: how the body is framed. 0 leaves it to the native side (Content-Length, or end() against write()). +const AUTO_HEADER_BODY_CHUNKED = 1 << 5; +const AUTO_HEADER_BODY_UNTIL_CLOSE = 2 << 5; +const AUTO_HEADER_BODY_NONE = 3 << 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 @@ -2508,7 +2729,7 @@ function renderNativeHeaders(res) { // transport after 'finish' (Node.js's matchHeader sets _last, then // resOnFinish destroys the socket). if (RE_CONN_CLOSE.test($isArray(value) ? value.join(", ") : String(value))) { - res[kMustCloseConnection] = true; + res._last = true; } } else if (key === "keep-alive") hasKeepAlive = true; if ($isArray(value)) { @@ -2552,7 +2773,7 @@ function renderNativeHeaders(res) { const statusCode = res[kSnapshotStatusCode] ?? res.statusCode; if (statusCode === 204 || statusCode === 304) { defectiveNoBodyResponse = true; - res[kMustCloseConnection] = true; + res._last = true; } } @@ -2575,10 +2796,10 @@ function renderNativeHeaders(res) { } else if (!chunkedByDefault) { // Node's _storeHeader sets _last when useChunkedEncodingByDefault is false: no framing header, the body runs until the connection closes. closeDelimited = true; - res[kMustCloseConnection] = true; + res._last = true; } else if (res._removedTE) { closeDelimited = true; - res[kMustCloseConnection] = true; + res._last = true; } else if (res._removedContLen || res[kFramingFrozenChunked]) { // Node's _storeHeader falls through to chunked here. The native writer never chunk-frames HTTP/1.0, so that stays close-delimited. const req = res.req; @@ -2586,17 +2807,29 @@ function renderNativeHeaders(res) { forceChunked = true; } else { closeDelimited = true; - res[kMustCloseConnection] = true; + res._last = 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._last = 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 // still closes after 'finish' when shouldKeepAlive was cleared. if (res.shouldKeepAlive === false) { - res[kMustCloseConnection] = true; + res._last = true; } } else if (!hasConnection) { if ( @@ -2631,32 +2864,19 @@ function renderNativeHeaders(res) { // shouldKeepAlive (graceful-shutdown helpers set it on in-flight // responses) must also end the socket after 'finish'. if (res.shouldKeepAlive === false || !canPersist) { - res[kMustCloseConnection] = true; + res._last = true; } autoHeaders |= AUTO_HEADER_CONN_CLOSE; } } if (res._hasBody === false) { - // A method-based no-body response (HEAD): the native side only knows - // 204/304 from the status line, so signal no-body explicitly. Any - // user-set framing headers are still advertised, but the body framing - // itself (auto Content-Length/Transfer-Encoding and the terminating - // chunk) is suppressed, like Node.js's `_hasBody && chunkedEncoding` - // gate - a HEAD response ends at the first empty line whatever headers - // it carries (RFC 9112 6.3). - flat.push("\u0000", "2"); - } - - if (closeDelimited) { - // The NUL-named sentinel pair tells the native writeHead the body is - // close-delimited: written raw, with the connection closed after the - // response (it is not a real header). - flat.push("\u0000", "1"); + // HEAD, 204, 304, 1xx. The framing headers the user set are still sent, but the response ends at the first empty line (RFC 9112 6.3). + autoHeaders |= AUTO_HEADER_BODY_NONE; + } else if (closeDelimited) { + autoHeaders |= AUTO_HEADER_BODY_UNTIL_CLOSE; } else if (forceChunked) { - // Advertise chunked so native frames the body instead of auto-writing Content-Length. - // Not in the flat array: Node's _storeHeader emits this after Connection, flat array goes first. - autoHeaders |= AUTO_HEADER_TRANSFER_ENCODING_CHUNKED; + autoHeaders |= AUTO_HEADER_TRANSFER_ENCODING_CHUNKED | AUTO_HEADER_BODY_CHUNKED; } } catch (e) { // String(value) above can run user toString() that throws; release the @@ -2670,7 +2890,6 @@ function renderNativeHeaders(res) { return flat; } -const kMustCloseConnection = Symbol("kMustCloseConnection"); function stopServerResponsePerf(this: any) { if (this[kServerResponseStatistics] && hasObserver("http")) { stopPerf(this, kServerResponseStatistics, { @@ -2699,474 +2918,82 @@ function emitResponseFinish() { // detachSocket) still references the connection then. const detachedFrom = this[kDetachedFrom]; const socket = detachedFrom ?? this.req?.socket ?? this.socket; - // Node's clearIncoming: a request that ended before its response did. Any other one is cleared at its EOF. + // Node's clearIncoming: a request that ended before its response did, or that gets no EOF. Any other one is cleared at its EOF. const parser = socket?.parser; - if (parser != null && parser.incoming === req && req.readableEnded) parser.incoming = null; + if (parser != null && parser.incoming === req && (req.readableEnded || req.destroyed)) parser.incoming = null; onResponseFinishHandleSocket(socket?.server, socket, this); // The dispatcher detached a synchronously-finished response itself; - // advancing the pipeline again here would skip a queued response. + // sending the next response again here would skip one. if (detachedFrom !== undefined) return; if (socket != null) this.detachSocket(socket); - advanceResponsePipeline(socket?.server, socket); -} - -// Runs when a response has finished, like the transport-related half of -// Node.js's resOnFinish: either end the connection (responses that must -// close) or arm the keep-alive idle timeout so an idle kept-alive connection -// is eventually closed. -function onResponseFinishHandleSocket(server, socket, res) { - if (res[kMustCloseConnection]) { - if (socket != null) { - if (typeof socket.destroySoon === "function") { - socket.destroySoon(); - } else { - socket.end(); - } - } - return; - } - if (!socket || socket.destroyed || typeof socket.setTimeout !== "function") { - return; - } - // Another response is already in flight on this connection (pipelining): - // it is not idle, so leave its timeout alone. - if (socket._httpMessage && socket._httpMessage !== res) { - return; - } - // Pipelined responses are still queued behind this one: the connection is - // not idle, so do not arm the keep-alive idle timeout yet. - if (socket[kPipelinedResponses]?.length) { - return; - } - const rawKeepAliveTimeout = server.keepAliveTimeout; - const keepAliveTimeout = Number.isFinite(rawKeepAliveTimeout) && rawKeepAliveTimeout >= 0 ? rawKeepAliveTimeout : 0; - const rawKeepAliveBuffer = server.keepAliveTimeoutBuffer; - const keepAliveTimeoutBuffer = - Number.isFinite(rawKeepAliveBuffer) && rawKeepAliveBuffer >= 0 ? rawKeepAliveBuffer : 1000; - if (keepAliveTimeout) { - // Extend the internal timeout by the configured buffer to reduce - // the likelihood of ECONNRESET errors, like Node.js's resOnFinish. - const total = keepAliveTimeout + keepAliveTimeoutBuffer; - // Rescheduling the socket timer on every response finish was the single - // largest per-request cost of the keep-alive path. Once the timer is - // armed with this exact interval (every response after the first on a - // kept-alive connection), leave it in place and only record when this - // idle period started; onSocketTimeoutTimerExpired grants the remaining - // budget if the timer fires early, so the socket still closes after - // exactly `total` ms of idle. - const timer = socket[kSocketTimeoutTimer]; - if (timer !== undefined && timer._idleTimeout === total) { - socket.timeout = total; - } else { - socket.setTimeout(total); - } - socket[kKeepAliveIdleStart] = DateNow(); - socket[kKeepAliveTimeoutSet] = true; - } -} - -// 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. -function onSocketCloseStopParsing(this: NodeHTTPServerSocket) { - this[kHandle]?.stopParsing?.(); -} - -// The second half of Node's read gate (lib/_http_server.js): uWS already pauses -// reads when the transport has unsent bytes (Node's `ws.needDrain`); these bytes -// sit in the JS queue and never reach the socket while the response is queued. -function pausePipelineReads(socket) { - const response = socket[kHandle]?.response; - if (!response) return; - socket._paused = true; - // Not response.pause(): that is request-body flow control and no-ops once the in-flight - // response has ended (always true when the pipeline backs up). pauseReads() pauses the - // connection regardless and native stops consuming already-received pipelined requests. - response.pauseReads(); -} - -// Node's parserOnIncoming read gate, for fallback connections (the native -// sibling is the kOutgoingData check before pausePipelineReads at the -// dispatcher): stop reading when the transport or the bytes buffered on -// queued pipelined responses are backed up, so a pipelining client cannot -// flood the connection's memory. -function maybePauseFallbackReads(socket) { - if (socket._paused) return; - if (socket._writableState?.needDrain || (socket[kOutgoingData] ?? 0) >= socket.writableHighWaterMark) { - socket._paused = true; - socket.pause(); - } -} - -// Node's socketOnDrain: the transport drained, so resume reads unless queued -// response bytes still hold the gate. -function resumeFallbackReadsOnDrain(socket) { - if (socket._paused && (socket[kOutgoingData] ?? 0) <= socket.writableHighWaterMark) { - socket._paused = false; - socket.resume(); - } -} - -function addPipelineOutgoingData(queued, bytes) { - const socket = queued.socket; - socket[kOutgoingData] = (socket[kOutgoingData] ?? 0) + bytes; -} - -// Like Node's socketOnDrain: the buffered bytes are handed to the transport when -// the response is flushed, so they stop gating reads (_flushOutput calls -// `_onPendingData(-outputSize)`). -function releasePipelineOutgoingData(socket, bytes) { - const outgoing = (socket[kOutgoingData] ?? 0) - bytes; - socket[kOutgoingData] = outgoing > 0 ? outgoing : 0; - if (socket._paused && outgoing <= socket.writableHighWaterMark) { - socket._paused = false; - const response = socket[kHandle]?.response; - if (response) { - response.resume(); - } else if (!(NodeHTTPServerSocket && socket instanceof NodeHTTPServerSocket)) { - // Fallback duplex paused by maybePauseFallbackReads: plain stream flow - // control is the only way to restart it. - socket.resume(); - } - } -} - -// Like the tail of Node.js's resOnFinish: when a response finishes and -// pipelined responses are queued behind it, the next one becomes the -// connection's current response, is assigned the socket, and its buffered -// output is flushed. -const kPipelineKickScheduled = Symbol("kPipelineKickScheduled"); -function kickPipelineIfIdle(server, socket) { - if (socket._httpMessage == null && !socket[kPipelineKickScheduled]) { - socket[kPipelineKickScheduled] = true; - process.nextTick(advancePipelineIfIdleNT, server, socket); - } -} -function advancePipelineIfIdleNT(server, socket) { - socket[kPipelineKickScheduled] = false; - if (socket._httpMessage == null && socket[kPipelinedResponses]?.length) { - advanceResponsePipeline(server, socket); - } -} - -// Like the dispatcher's pipelined branch and Node.js's parserOnIncoming -// outgoing queue: park the response behind the connection's in-flight one. -// Its write()/end() buffer (kPipelinedQueuedState) until -// advanceResponsePipeline assigns it the socket and replays them. -function queuePipelinedResponse(socket, res, isAncient) { - res[kPipelinedQueuedState] = { - ops: [], - bytes: 0, - headerBytes: 0, - needDrain: false, - ended: false, - isAncient, - socket, - }; - (socket[kPipelinedResponses] ??= []).push(res); -} - -// Like Node.js's socketOnEnd: the response that gets `_last` after the client's FIN is the last one in the queue. -function lastPipelinedResponse(socket) { - const queue = socket[kPipelinedResponses]; - return queue?.[queue.length - 1]; -} - -// When the connection dies with pipelined responses still queued behind the -// in-flight one, abort them and their requests, like Node.js's socketOnClose -// (abortIncoming). Runs from the native socket's close path and from the -// http1 fallback's socket 'close' listener. -function abortQueuedPipelinedResponses(socket, error = $ERR_STREAM_DESTROYED("write")) { - const pipelined = socket[kPipelinedResponses]; - const pipelinedLength = pipelined ? pipelined.length : 0; - if (pipelinedLength) { - socket[kPipelinedResponses] = undefined; - for (let i = 0; i < pipelinedLength; i++) { - const queuedRes = pipelined[i]; - // A turn that the native handle still holds: the native close path notifies that one. - if (queuedRes[kPipelinedQueuedState] === undefined) continue; - const queuedReq = queuedRes.req; - failQueuedPipelinedWriteCallbacks(queuedRes[kPipelinedQueuedState], error); - if (queuedReq && !queuedReq.destroyed) { - queuedReq[kHandle] = undefined; - if (queuedReq.listenerCount("error") > 0) { - queuedReq.destroy(new ConnResetException("aborted")); - } else { - queuedReq.destroy(); - } - } - if (!queuedRes.destroyed) { - queuedRes.destroy(); - } else if (!queuedRes._closed) { - process.nextTick(emitCloseNT, queuedRes); - } - } - } -} - -// The head of the queue can never be sent: close once the bytes of the responses ahead of it have left. -function closeAfterLastSendableResponse(socket) { - if (NodeHTTPServerSocket && socket instanceof NodeHTTPServerSocket) { - socket[kHandle]?.closeWhenDrained(); - } else if (!socket.destroyed) { - socket.destroy(); - } -} - -function failQueuedPipelinedWriteCallbacks(queued, error) { - const ops = queued?.ops; - const length = ops?.length ?? 0; - if (length === 0) return; - queued.ops = []; - for (let i = 0; i < length; i++) { - const callback = ops[i][3]; - if (typeof callback === "function") process.nextTick(callback, error); - } -} - -function advanceResponsePipeline(server, socket) { - // The previous response on this connection closed it (Connection: close, - // HTTP/1.0, maxRequestsPerSocket): like Node.js's resOnFinish, advancing - // the pipeline is mutually exclusive with closing the socket - the queued - // responses are aborted by the socket close path instead of being replayed - // onto a half-closed connection. - if (!socket || socket.writableEnded || socket.destroyed) { - return; - } - const queue = socket[kPipelinedResponses]; - if (!queue || queue.length === 0) { - return; - } - // The current response has ended but is still draining; its 'finish' advances the pipeline. - const current = socket[kHandle]?.response; - if ( - current && - (current.flags & (NodeHTTPResponseFlags.ended | NodeHTTPResponseFlags.closed_or_completed)) === - NodeHTTPResponseFlags.ended - ) { - return; - } - const res = queue[0]; - const queued = res[kPipelinedQueuedState]; - if (queued === undefined) { - // The native handle still holds this turn: its dispatch queued no response, and after a throw none can come. - if ((res.flags & NodeHTTPResponseFlags.dispatch_threw_while_queued) !== 0) { - closeAfterLastSendableResponse(socket); - } - return; - } - const handle = res[kHandle]; - - if ( - res.destroyed || - !handle || - // Its dispatch threw and nothing ended it since: natively only the current response is answered for a throw. - (!queued.ended && (handle.flags & NodeHTTPResponseFlags.dispatch_threw_while_queued) !== 0) - ) { - failQueuedPipelinedWriteCallbacks(queued, res.errored ?? socket.errored ?? $ERR_STREAM_DESTROYED("write")); - // The queued response was destroyed before it could be sent; the - // connection cannot produce a response for this slot, so it is unusable. - // Deliberate divergence from Node v26, which assigns the destroyed - // message and wedges the connection until requestTimeout: an HTTP/1.1 - // connection cannot skip a response slot, so reset it instead. - closeAfterLastSendableResponse(socket); // it stays queued for the close path - return; - } - - queue.shift(); - res[kPipelinedQueuedState] = undefined; - releasePipelineOutgoingData(socket, queued.bytes); - - if (NodeHTTPServerSocket && socket instanceof NodeHTTPServerSocket) { - const socketHandle = socket[kHandle]; - if ( - !socketHandle || - socket.destroyed || - !socketHandle.startPipelinedResponse(handle, !!queued.isAncient, !requestShouldKeepAlive(res.req)) - ) { - // The connection is already gone; the socket close path destroys queued - // responses, but make sure this (already dequeued) one is not skipped. - failQueuedPipelinedWriteCallbacks(queued, socket.errored ?? $ERR_STREAM_DESTROYED("write")); - if (!res.destroyed) { - res.destroy(); - } - return; - } - - if (res.assignSocket === ServerResponse.prototype.assignSocket) { - assignSocketInternal(res, socket); - } else { - res.assignSocket(socket); - } - } else { - // internal/http1_server_fallback connection (a foreign duplex): the - // response's JS handle writes to the socket itself (nothing to switch - // natively), and the prototype assignSocket installs the 'close' listener - // a plain stream needs. Clear `finished` first, or assignSocket's _flush - // emits 'prefinish' before the ops replay below. - if (queued.ended) { - res.finished = false; - } - res.assignSocket(socket); - } - - // Replay the writes buffered while the response was queued. - // The buffered bytes are handed to the native handle below, so they no - // longer count as pending output (Node's _flush does the same). - res.outputSize = 0; - const ops = queued.ops; - const opsLength = ops.length; - let hitBackpressure = false; - if (opsLength) { - // `finished` was set when the user called end() on the queued response; - // clear it for the replay so the real write()/end() (end re-sets it) do - // not treat the buffered calls as write-after-end. - if (queued.ended) { - res.finished = false; - } - res[kReplayingPipelinedOps] = true; - try { - for (let i = 0; i < opsLength; i++) { - const op = ops[i]; - const kind = op[0]; - if (kind === "raw") { - // Buffered 1xx bytes: route through the same AsyncSocket buffer the - // response's own writeHead/end use so they precede the final response. - handle.writeInformational(op[1], op[2]); - if (typeof op[3] === "function") process.nextTick(op[3]); - } else if (kind === "write") { - if (ServerResponsePrototypeWrite.$call(res, op[1], op[2], op[3]) === false) hitBackpressure = true; - } else { - ServerResponsePrototypeEnd.$call(res, op[1], op[2], op[3]); - } - } - } finally { - res[kReplayingPipelinedOps] = false; - } - } - if (queued.needDrain && !queued.ended) { - // write() reported backpressure while the response was queued: the native drain callback emits 'drain' if the flush hit backpressure, else emit it now. - if (!hitBackpressure) { - process.nextTick(emitPipelinedDrainNT, res); - } - } + sendNextResponseIfAny(socket); } -function markResponseEndedNT(res) { - res._ended = true; +// After a response that the native handle wrote. +function sendNextResponseIfAny(socket) { + const state = socket?.[kConnectionState]; + if (state !== undefined && !socket.writableEnded && !socket.destroyed) sendNextResponse(socket, state); } -function emitPipelinedDrainNT(res) { - if (!res.destroyed && !res.finished) { - res.emit("drain"); - } +function sendNextResponseIfIdle(socket) { + if (socket._httpMessage == null) sendNextResponseIfAny(socket); } -// write()/end() on a response that is still queued behind an in-flight -// pipelined response: buffer the call (Node.js buffers through outputData -// while no socket is assigned) and report backpressure against the high water -// mark so 'drain' semantics match. -// Node's _storeHeader pushes the serialized header block through outputData, so a -// queued response's outputSize - and the connection's outgoingData that gates -// pipelined reads - counts header bytes, not just body chunks. Bun renders the -// block natively at replay, so account its size when the first buffered op fixes -// the headers. Without it every queued response contributes only its chunk -// length; power-of-two chunks can then land outgoingData exactly on -// writableHighWaterMark and pause reads one request earlier than Node would, -// deadlocking clients that pipeline the unblocking request behind the crossing -// one (test-http-pipeline-socket-parser-typeerror). -function accountQueuedHeaderBytes(res, queued) { - if (queued.headerBytes !== 0) { +// Holds the turn of a request that has no response. If none comes, nothing can be sent in its place. +const kNoResponse = { destroyed: true, destroy() {} }; + +// Runs when a response has finished, like the transport-related half of +// Node.js's resOnFinish: either end the connection (responses that must +// close) or arm the keep-alive idle timeout so an idle kept-alive connection +// is eventually closed. +function onResponseFinishHandleSocket(server, socket, res) { + if (res._last) { + socket?.destroySoon(); return; } - // Status line: "HTTP/1.1 NNN \r\n". - let bytes = 15 + String(res.statusMessage ?? STATUS_CODES[res.statusCode] ?? "unknown").length; - const outHeaders = res[kOutHeaders]; - if (outHeaders !== null && outHeaders !== undefined) { - for (const key in outHeaders) { - const entry = outHeaders[key]; - if (!entry) continue; - const value = entry[1]; - if ($isJSArray(value)) { - for (let i = 0; i < value.length; i++) { - bytes += entry[0].length + 4 + String(value[i]).length; - } - } else { - bytes += entry[0].length + 4 + String(value).length; - } - } - } - // Headers the native writer adds when absent, with their literal serialized - // lengths: "Date: <29-byte IMF-fixdate>\r\n" (37), "Connection: keep-alive\r\n" - // (24), "Transfer-Encoding: chunked\r\n" (28), plus the terminating "\r\n". - if (!res.hasHeader("date")) bytes += 37; - if (!res.hasHeader("connection")) bytes += 24; - if (!res.hasHeader("content-length") && !res.hasHeader("transfer-encoding")) bytes += 28; - bytes += 2; - queued.headerBytes = bytes; - queued.bytes += bytes; - res.outputSize += bytes; - addPipelineOutgoingData(queued, bytes); -} - -function bufferPipelinedWrite(res, queued, chunk, encoding, callback) { - 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]) { - throw $ERR_HTTP_BODY_NOT_ALLOWED(); - } - // Node's write_(): a response that cannot have a body discards the write, calls back and returns true. - queued.ops.push(["write", undefined, encoding, undefined]); - if (typeof callback === "function") process.nextTick(callback); - return true; + if (!socket || socket.destroyed) { + return; } - queued.ops.push(["write", chunk, encoding, callback]); - if (chunk) { - const bytes = typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.length; - queued.bytes += bytes; - // Node buffers these writes through outputData while no socket is - // assigned, so outputSize reflects them until the response is flushed. - res.outputSize += bytes; - addPipelineOutgoingData(queued, bytes); - } - if (queued.bytes >= res.writableHighWaterMark) { - queued.needDrain = true; - return false; + // Another response is already in flight on this connection (pipelining): + // it is not idle, so leave its timeout alone. + if (socket._httpMessage && socket._httpMessage !== res) { + return; } - return true; -} - -function bufferPipelinedEnd(res, queued, chunk, encoding, callback) { - callWriteHeadIfObservable(res, res[headerStateSymbol], true); - if (res[headerStateSymbol] === NodeHTTPHeaderState.none) { - updateHasBody(res, (res.statusCode = validateStatusCode(res.statusCode))); + // Pipelined responses are still queued behind this one: the connection is + // not idle, so do not arm the keep-alive idle timeout yet. + if (socket[kConnectionState]?.outgoing.length) { + return; } - accountQueuedHeaderBytes(res, queued); - if (chunk && !res._hasBody) { - if (res[kRejectNonStandardBodyWrites]) { - throw $ERR_HTTP_BODY_NOT_ALLOWED(); + const total = keepAliveIdleTimeout(server); + if (total) { + // Rescheduling the socket timer on every response finish was the single + // largest per-request cost of the keep-alive path. Once the timer is + // armed with this exact interval (every response after the first on a + // kept-alive connection), leave it in place and only record when this + // 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] = timerClockMs(); + const timer = socket[kTimeout]; + if (timer != null && !timer._destroyed && timer._idleTimeout === total) { + socket.timeout = total; + } else { + socket.setTimeout(total); } - chunk = undefined; - } - queued.ops.push(["end", chunk, encoding, callback]); - if (chunk) { - const bytes = typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.length; - queued.bytes += bytes; - // Node buffers these writes through outputData while no socket is - // assigned, so outputSize reflects them until the response is flushed. - res.outputSize += bytes; - addPipelineOutgoingData(queued, bytes); + socket[kKeepAliveTimeoutSet] = true; } - queued.ended = true; - res.finished = true; - return res; } -const RE_CONN_CLOSE = /(?:^|\W)close(?:$|\W)/i; +// 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. +function onSocketCloseStopParsing(this: any) { + this[kHandle]?.stopParsing?.(); +} + +function markResponseEndedNT(res) { + res._ended = true; +} // Native dispatch bitfield: presence/token bits for the request headers the // dispatcher consults, computed in one native pass over the raw headers @@ -3197,9 +3024,7 @@ function shouldKeepAliveForConnection(req, connection) { return !(typeof connection === "string" && RE_CONN_CLOSE.test(connection)); } -// Result of shouldKeepAliveForConnection, computed once at dispatch and -// reused by renderNativeHeaders / the pipelined-response path so neither has -// to re-read req.headers (which would materialize the lazy header object). +// shouldKeepAliveForConnection(), computed once at dispatch: reading req.headers again would materialize the lazy header object. function requestShouldKeepAlive(req) { if (!req) return true; @@ -3208,41 +3033,6 @@ function requestShouldKeepAlive(req) { return (req[kReqShouldKeepAlive] = shouldKeepAliveForConnection(req, req.headers.connection)); } -function isHTTPServerHeaderStateSentOrAssigned(state) { - return state === NodeHTTPHeaderState.sent || state === NodeHTTPHeaderState.assigned; -} - -// In Node.js writeHead() renders the header block immediately, so mutating -// headers afterwards throws ERR_HTTP_HEADERS_SENT. Our headers are rendered -// lazily right before the native writeHead, so mirror the throwing behavior -// by also checking the header state, then defer to the OutgoingMessage -// implementations. -function throwIfServerHeadersSent(self, action) { - if (isHTTPServerHeaderStateSentOrAssigned(self[headerStateSymbol])) { - throw $ERR_HTTP_HEADERS_SENT(action); - } -} - -ServerResponse.prototype.setHeader = function setHeader(name, value) { - throwIfServerHeadersSent(this, "set"); - return OutgoingMessagePrototype.setHeader.$call(this, name, value); -}; - -ServerResponse.prototype.appendHeader = function appendHeader(name, value) { - throwIfServerHeadersSent(this, "append"); - return OutgoingMessagePrototype.appendHeader.$call(this, name, value); -}; - -ServerResponse.prototype.setHeaders = function setHeaders(headers) { - throwIfServerHeadersSent(this, "set"); - return OutgoingMessagePrototype.setHeaders.$call(this, headers); -}; - -ServerResponse.prototype.removeHeader = function removeHeader(name) { - throwIfServerHeadersSent(this, "remove"); - return OutgoingMessagePrototype.removeHeader.$call(this, name); -}; - // res.headers / res.headers= are Bun-specific conveniences kept for backwards // compatibility; they are views over the Node.js-style header storage. Object.defineProperty(ServerResponse.prototype, "headers", { @@ -3250,17 +3040,17 @@ Object.defineProperty(ServerResponse.prototype, "headers", { return this.getHeaders(); }, set(value) { - throwIfServerHeadersSent(this, "set"); + if (this._header) throw $ERR_HTTP_HEADERS_SENT("set"); this[kOutHeaders] = null; if (!value) return; 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); @@ -3273,19 +3063,13 @@ Object.defineProperty(ServerResponse.prototype, "headers", { Object.defineProperty(ServerResponse.prototype, "socket", { get() { - if (this[kPipelinedQueuedState] !== undefined) { - // Queued pipelined response: like Node.js, res.socket is null until the - // response is assigned the socket. - return null; - } - return (this[fakeSocketSymbol] ??= new FakeSocket(this)); + // null while the response is queued behind a pipelined one, like Node.js. + const socket = this[fakeSocketSymbol]; + return socket !== undefined ? socket : (this[fakeSocketSymbol] = new FakeSocket(this)); }, set(value) { - this[fakeSocketSymbol] = value; - // Keep the OutgoingMessage storage in sync: the standalone no-handle path - // routes through OutgoingMessage.write/end, whose kSocket-gated checks - // (_finish()/'prefinish', _flushOutput) must see the assigned socket. - this[kSocket] = value; + this[fakeSocketSymbol] = value ?? undefined; + outgoingSocketSetter.$call(this, value); }, }); @@ -3298,76 +3082,30 @@ Object.defineProperty(ServerResponse.prototype, "connection", { }, }); -// The native-handle path ignores this flag (uWS does the chunk framing), but -// the standalone path (no kHandle, assignSocket()) goes through the upstream -// _storeHeader/write_/end machinery, which sets and reads it to frame the -// body - so it needs real storage. -Object.defineProperty(ServerResponse.prototype, "chunkedEncoding", { - get() { - return this[kChunkedEncoding] ?? false; - }, - set(value) { - this[kChunkedEncoding] = value; - }, -}); - Object.defineProperty(ServerResponse.prototype, "writableCorked", { get() { + if (!this[kHandle]) return outgoingWritableCorked!.$call(this); return this.socket?.writableCorked ?? 0; }, set(_value) {}, }); ServerResponse.prototype.cork = function cork() { - // socket is null while this response is queued behind a pipelined response. + if (!this[kHandle]) return OutgoingMessagePrototype.cork.$call(this); this.socket?.cork(); }; ServerResponse.prototype.uncork = function uncork() { + if (!this[kHandle]) return OutgoingMessagePrototype.uncork.$call(this); this.socket?.uncork(); }; -ServerResponse.prototype.setTimeout = function setTimeout(msecs, callback) { - // Like OutgoingMessage.prototype.setTimeout: the callback listens on the - // response's 'timeout' event (emitted by onNodeHTTPServerSocketTimeout); - // only msecs is delegated to the socket. - if (callback) this.on("timeout", callback); - if (!this[fakeSocketSymbol]) { - this.once("socket", function socketSetTimeoutOnConnect(socket) { - socket.setTimeout(msecs); - }); - } else { - this.socket.setTimeout(msecs); - } - - return this; -}; - -ServerResponse.prototype._removedConnection = false; - -ServerResponse.prototype._removedContLen = false; - -ServerResponse.prototype._hasBody = true; - ServerResponse.prototype._ended = false; // The end() callback (or null) while the body end() buffered is still draining. const kPendingFinish = Symbol("kPendingFinish"); ServerResponse.prototype[kPendingFinish] = undefined; -ServerResponse.prototype[kRejectNonStandardBodyWrites] = undefined; - -Object.defineProperty(ServerResponse.prototype, "headersSent", { - get() { - return ( - this[headerStateSymbol] === NodeHTTPHeaderState.sent || this[headerStateSymbol] === NodeHTTPHeaderState.assigned - ); - }, - set(value) { - this[headerStateSymbol] = value ? NodeHTTPHeaderState.sent : NodeHTTPHeaderState.none; - }, -}); - ServerResponse.prototype._writeRaw = function (chunk, encoding, callback) { if (!this[kHandle]) { // Standalone path: OutgoingMessage._writeRaw buffers to outputData while @@ -3376,23 +3114,11 @@ ServerResponse.prototype._writeRaw = function (chunk, encoding, callback) { // `socket` getter here would drop the bytes into a FakeSocket. return OutgoingMessagePrototype._writeRaw.$apply(this, arguments); } - const queued = this[kPipelinedQueuedState]; - if (queued !== undefined) { - // Queued pipelined response: like Node.js (which buffers to outputData - // while no socket is assigned and flushes on assignSocket), buffer the - // raw 1xx bytes and write them ahead of the buffered body once this - // response reaches the head of the pipeline. - queued.ops.push(["raw", chunk, encoding, callback]); - const bytes = typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.length; - queued.bytes += bytes; - this.outputSize += bytes; - addPipelineOutgoingData(queued, bytes); - return queued.bytes < this.writableHighWaterMark; - } // Write through the response handle's AsyncSocket buffer (same path as // writeHead/end) so 1xx lines share ordering with the final response bytes; // socket.write() would land in the socket handle's separate stream buffer. this[kHandle].writeInformational(chunk, encoding); + noteResponseWrite(this); if (typeof callback === "function") process.nextTick(callback); return true; }; @@ -3484,25 +3210,37 @@ ServerResponse.prototype.writeContinue = function (cb) { if (this.headersSent) { throw $ERR_HTTP_HEADERS_SENT("write"); } - if (!this[kHandle] || this[kPipelinedQueuedState] !== undefined) { - // Standalone path (no native handle) or queued pipelined response (no - // socket yet): route through writeInformation like Node.js v26.3.0 so the - // 100 Continue line is buffered and written once a socket is assigned. + if (!this[kHandle]) { + // Through writeInformation() like Node.js v26.3.0, so the line waits in outputData until a socket is assigned. this.writeInformation(100, null, cb); this._sent100 = true; return; } - const native = this.socket?.[kHandle]?.response; - if (native) native.writeContinue(); - else this[kHandle]?.writeContinue?.(); + this[kHandle].writeContinue(); + noteResponseWrite(this); this._sent100 = true; - cb?.(); + if (typeof cb === "function") process.nextTick(cb); }; -// This end method is actually on the OutgoingMessage prototype in Node.js -// But we don't want it for the fetch() response version. +// A response takes what its native handle takes, whatever carries it. OutgoingMessage takes a string or a Uint8Array. +function toOutgoingChunk(res, chunk) { + if (typeof chunk === "string" || typeof chunk === "function" || chunk == null) return chunk; + if (!res._header) updateHasBody(res, res.statusCode); + if (!res._hasBody) return ""; + if (ArrayBufferIsView(chunk)) { + return chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength); + } + const { isAnyArrayBuffer, isStringObject } = require("node:util/types"); + if (isAnyArrayBuffer(chunk)) return new Uint8Array(chunk); + return isStringObject(chunk) ? `${chunk}` : chunk; +} + ServerResponse.prototype.end = function (chunk, encoding, callback) { const handle = this[kHandle]; + // A late end() gets the same answer whatever carries the response. + if (!handle || this.finished) { + return OutgoingMessagePrototype.end.$call(this, toOutgoingChunk(this, chunk), encoding, callback); + } if ($isCallable(chunk)) { callback = chunk; @@ -3516,35 +3254,10 @@ ServerResponse.prototype.end = function (chunk, encoding, callback) { } // Node's write_() drops the chunk of a destroyed response before _implicitHeader(), and end() goes on: https://github.com/nodejs/node/blob/v26.3.0/lib/_http_outgoing.js#L951-L964 - const droppedChunk = !!chunk && this.destroyed && !this.finished; + const droppedChunk = !!chunk && this.destroyed; if (droppedChunk) chunk = undefined; - if (hasServerResponseFinished(this, chunk, callback, true)) { - return this; - } - - { - // HTTP/1.1 pipelining: this response is queued behind an in-flight - // response - buffer the end() until it is assigned the socket. - const queuedState = this[kPipelinedQueuedState]; - if (queuedState !== undefined) { - return bufferPipelinedEnd(this, queuedState, chunk, encoding, callback); - } - } - - if (!handle) { - return OutgoingMessagePrototype.end.$call(this, chunk, encoding, callback); - } - - if (this[headerStateSymbol] === NodeHTTPHeaderState.none) { - // Implicit header: Node's write_() runs _implicitHeader() (which derives - // _hasBody from the status code) unconditionally before its !_hasBody - // discard - not gated on the chunk, or an empty first write would flip - // the header state without deriving _hasBody and a later body write to - // a 204/304 would reach the wire chunk-framed with no terminator. - // updateHasBody only ever clears _hasBody, so this is idempotent. - updateHasBody(this, (this.statusCode = validateStatusCode(this.statusCode))); - } + if (!this._header) validateImplicitHead(this); if (chunk && !this._hasBody) { if (this[kRejectNonStandardBodyWrites]) { throw $ERR_HTTP_BODY_NOT_ALLOWED(); @@ -3560,73 +3273,48 @@ 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); } - const headerState = this[headerStateSymbol]; - if (!droppedChunk) callWriteHeadIfObservable(this, headerState, true); + if (!droppedChunk) callWriteHeadIfObservable(this, true); const flags = handle.flags; if (!!(flags & NodeHTTPResponseFlags.closed_or_completed)) { // Socket already gone: like Node, 'prefinish' fires but 'finish' never does, and the close of the socket aborts the request. // Node's _implicitHeader() ran unless the chunk was dropped above, so the head counts as sent like on the normal path below. - if (!droppedChunk || headerState !== NodeHTTPHeaderState.none) { - this[headerStateSymbol] = NodeHTTPHeaderState.sent; + if (!droppedChunk || this._header) { this._header = " "; + this._headerSent = true; } this.finished = true; process.nextTick(markResponseEndedNT, this); this.emit("prefinish"); return this; } - const sentState = NodeHTTPHeaderState.sent; // Native end() returns -(length + 1) while part of the body is still draining; 'finish' waits for it. let draining = false; - if (headerState !== sentState) { + // A replaced writeHead() can have sent the head itself, with write(). + if (!this._headerSent) { { const renderedHeaders = renderNativeHeaders(this); let contentLength; try { - // One native crossing for cork + writeHead + end (writeHeadAndEnd - // corks natively around both phases). + // It throws before it sends anything. contentLength = handle.writeHeadAndEnd( this[kSnapshotStatusCode] ?? this.statusCode, this[kSnapshotStatusMessage] ?? this.statusMessage, renderedHeaders, chunk, encoding, - strictContentLength(this, headerState, true), + strictContentLength(this, true, true), renderedAutoHeaders, renderedKeepAliveSecs, ); - } catch (e) { + } finally { releaseRenderedHeaders(renderedHeaders); - // Mirror the old two-call flow's headersSent semantics: errors from - // the write-head phase (the batch's upfront gate, argument validation, - // headers-already-sent) leave the state unset; anything after that - // point threw with headers already on the wire, exactly like - // handle.end throwing after handle.writeHead succeeded. - const code = (e as { code?: string } | null | undefined)?.code; - if ( - code !== "ERR_STREAM_ALREADY_FINISHED" && - code !== "ERR_HTTP_HEADERS_SENT" && - code !== "ERR_INVALID_CHAR" && - code !== "ERR_INVALID_ARG_TYPE" && - !(e instanceof RangeError) - ) { - this[headerStateSymbol] = sentState; - } - throw e; } - releaseRenderedHeaders(renderedHeaders); - this[headerStateSymbol] = sentState; + this._headerSent = true; if (contentLength < 0) { draining = true; contentLength = -contentLength - 1; @@ -3639,10 +3327,18 @@ ServerResponse.prototype.end = function (chunk, encoding, callback) { // (no native call in between can change it), so reuse its bits instead of // paying two more native getter crossings. if (!(!chunk && flags & NodeHTTPResponseFlags.ended) && !(flags & NodeHTTPResponseFlags.socket_closed)) { - draining = handle.end(chunk, encoding, undefined, strictContentLength(this, headerState, true)) < 0; + draining = handle.end(chunk, encoding, undefined, strictContentLength(this, true, true)) < 0; } } 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[kSocket]; + 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' @@ -3685,31 +3381,16 @@ function flushPendingFinish(this: any) { const callback = this[kPendingFinish]; if (callback === undefined) return; this[kPendingFinish] = undefined; + noteResponseWrite(this); queueResponseFinished(this, callback); } -// For internal/http1_server_fallback. Like Node.js's onFinish it emits 'finish' in place: a tick later, a closing socket's 'close' listeners would run first. -function finishDrainedResponse(res) { - const callback = res[kPendingFinish]; - if (callback === undefined) return; - res[kPendingFinish] = undefined; - res._callPendingCallbacks(); - emitResponseFinished(res, callback); -} - -Object.defineProperty(ServerResponse.prototype, "writable", { - // Node.js's OutgoingMessage assigns `this.writable = true` in the - // constructor and never flips it back to false - not on end(), not on - // destroy(), not when the peer aborts (see upstream - // test-http-writable-true-after-close). - get() { - return true; - }, - set(_value) {}, -}); - ServerResponse.prototype.write = function (chunk, encoding, callback) { const handle = this[kHandle]; + // So does a late write(). + if (!handle || this.finished || this.destroyed) { + return OutgoingMessagePrototype.write.$call(this, toOutgoingChunk(this, chunk) ?? "", encoding, callback); + } if ($isCallable(chunk)) { callback = chunk; @@ -3722,91 +3403,34 @@ ServerResponse.prototype.write = function (chunk, encoding, callback) { callback = undefined; } - if (hasServerResponseFinished(this, chunk, callback, false)) { - return false; - } - - { - // HTTP/1.1 pipelining: this response is queued behind an in-flight - // response - buffer the write until it is assigned the socket. - const queuedState = this[kPipelinedQueuedState]; - if (queuedState !== undefined) { - return bufferPipelinedWrite(this, queuedState, chunk, encoding, callback); - } - } - - let result = 0; - - const headerState = this[headerStateSymbol]; - callWriteHeadIfObservable(this, headerState); + const hadHeader = !!this._header; + callWriteHeadIfObservable(this); - if (!handle) { - // Route through the OutgoingMessage machinery (mirroring end()'s - // standalone fallback) so headers are rendered and chunk framing applied - // before anything reaches the assigned socket. Writing to `this.socket` - // directly would emit the raw body bytes ahead of the header block. - // The original chunk passes through untouched: write_() has its own - // !_hasBody discard, and clearing it to undefined here would trip - // write_()'s chunk-type validation instead. - return OutgoingMessagePrototype.write.$call(this, chunk, encoding, callback); - } - - if (this[headerStateSymbol] === NodeHTTPHeaderState.none) { - // Implicit header: Node's write_() runs _implicitHeader() (which derives - // _hasBody from the status code) unconditionally before its !_hasBody - // discard - not gated on the chunk, or an empty first write would flip - // the header state without deriving _hasBody and a later body write to - // a 204/304 would reach the wire chunk-framed with no terminator. - // updateHasBody only ever clears _hasBody, so this is idempotent. - updateHasBody(this, (this.statusCode = validateStatusCode(this.statusCode))); - } - if (chunk && !this._hasBody) { - if (this[kRejectNonStandardBodyWrites]) { - throw $ERR_HTTP_BODY_NOT_ALLOWED(); - } else { - // node.js just ignores the write in this case - chunk = undefined; - } - } + if (!this._header) validateImplicitHead(this); const flags = handle.flags; if (!!(flags & NodeHTTPResponseFlags.closed_or_completed)) { - // Socket already gone: like Node's _writeRaw(), report false and drop the callback. + // Socket already gone, and 'close' is still to come. + if (callback) { + this[kPendingCallbacks].push(callback); + failPendingWriteCallbacks(this, this[kSocket]?.errored); + } return false; } - const strict = strictContentLength(this, headerState, false); - // Node returns true for every write to a response that cannot have a body (write_ in lib/_http_outgoing.js). - const hasBody = this._hasBody; - const onWritable = hasBody ? allowWritesToContinue.bind(this) : undefined; - - if (this[headerStateSymbol] !== NodeHTTPHeaderState.sent) { - handle.cork(() => { - const renderedHeaders = renderNativeHeaders(this); - try { - handle.writeHead( - this[kSnapshotStatusCode] ?? this.statusCode, - this[kSnapshotStatusMessage] ?? this.statusMessage, - renderedHeaders, - renderedAutoHeaders, - renderedKeepAliveSecs, - ); - } finally { - // A throwing writeHead (status validation) must not leave the shared - // scratch array marked busy for the rest of the process. - releaseRenderedHeaders(renderedHeaders); - } - - // If handle.writeHead throws, we don't want headersSent to be set to true. - // So we set it here. - this[headerStateSymbol] = NodeHTTPHeaderState.sent; - result = handle.write(chunk, encoding, onWritable, strict); - }); - } else { - result = handle.write(chunk, encoding, onWritable, strict); + if (!this._hasBody) { + if ((chunk || chunk === "") && this[kRejectNonStandardBodyWrites]) throw $ERR_HTTP_BODY_NOT_ALLOWED(); + // Node's write_(): the chunk is dropped, and the head waits for end() or flushHeaders(). + this._implicitHeader(); + if (callback) process.nextTick(callback); + return true; } - if (result < 0 && hasBody) { + const result = writeToHandle(this, handle, chunk, encoding, strictContentLength(this, hadHeader, false)); + + if (result < 0) { + // Bound only for a write that the transport didn't take whole. + handle.onwritable = allowWritesToContinue.bind(this); if (callback) { // The write was buffered due to backpressure. // We need to defer the callback until the write actually goes through. @@ -3815,11 +3439,12 @@ ServerResponse.prototype.write = function (chunk, encoding, callback) { return false; } + 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. handle.flushHeaders(); - if (hasBody && handle.bufferedAmount > 0) { + if (handle.bufferedAmount > 0) { this[kPendingCallbacks].push(callback); handle.onwritable = allowWritesToContinue.bind(this); return false; @@ -3842,10 +3467,7 @@ ServerResponse.prototype.write = function (chunk, encoding, callback) { written += written.toString(16).length + 4; } } - // While replaying the writes a pipelined response buffered before it was - // assigned the socket, skip the per-turn accounting: 'drain' for those - // writes is handled by advanceResponsePipeline / the native drain callback. - if (written > 0 && !this[kReplayingPipelinedOps]) { + if (written > 0) { this[kBytesBuffered] = (this[kBytesBuffered] ?? 0) + written; scheduleWriteAccountingFlush(this); } @@ -3855,19 +3477,20 @@ ServerResponse.prototype.write = function (chunk, encoding, callback) { return !buffered || buffered < this.writableHighWaterMark; }; -// advanceResponsePipeline replays buffered ops through these; patched res.write/res.end (compression middleware) must not see them again. -const ServerResponsePrototypeWrite = ServerResponse.prototype.write; -const ServerResponsePrototypeEnd = ServerResponse.prototype.end; - const kBytesBuffered = Symbol("kBytesBuffered"); const kAccountingFlushScheduled = Symbol("kAccountingFlushScheduled"); 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; @@ -3891,10 +3514,6 @@ ServerResponse.prototype._callPendingCallbacks = function () { } }; -ServerResponse.prototype._finish = function () { - this.emit("prefinish"); -}; - ServerResponse.prototype.detachSocket = function (socket) { // socket can be null when the stream destroyer detached the request's // socket (req.socket = null) before the response finished. @@ -3902,34 +3521,34 @@ ServerResponse.prototype.detachSocket = function (socket) { if (socket[kCloseCallback]) socket[kCloseCallback] = undefined; socket.removeListener("close", onServerResponseClose); socket._httpMessage = null; - // Drop the request reference so a kept-alive idle connection does not - // pin the last request in memory (Node.js frees the parser's incoming - // reference when the response finishes). - if (socket[kRequest] === this.req) { - socket[kRequest] = undefined; - } + if (this.destroyed && socket.destroyed) socket[kDetachedWhileClosing] = this; } this.socket = null; }; ServerResponse.prototype._implicitHeader = function () { - if (this.headersSent) return; - // @ts-ignore - this.writeHead(this.statusCode); + if (this._header) return; + if (this.writeHead === OriginalWriteHeadFn) writeHead(this, this.statusCode, undefined, undefined, false); + else this.writeHead(this.statusCode); }; +// A response without a native handle (new ServerResponse(req) and assignSocket()) writes through OutgoingMessage, which keeps its own accounting. +const outgoingWritableCorked = Object.getOwnPropertyDescriptor(OutgoingMessagePrototype, "writableCorked")!.get; +const outgoingWritableNeedDrain = Object.getOwnPropertyDescriptor(OutgoingMessagePrototype, "writableNeedDrain")!.get; +const outgoingWritableFinished = Object.getOwnPropertyDescriptor(OutgoingMessagePrototype, "writableFinished")!.get; +const outgoingWritableLength = Object.getOwnPropertyDescriptor(OutgoingMessagePrototype, "writableLength")!.get; + 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; 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,39 +3556,21 @@ 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) - ); - }, -}); - -Object.defineProperty(ServerResponse.prototype, "writableHighWaterMark", { - get() { - // Like Node.js's OutgoingMessage: the socket's high water mark when one - // is assigned (the stream default differs by platform), otherwise the - // OutgoingMessage default set by the constructor. - return this.socket?.writableHighWaterMark ?? this[kHighWaterMark] ?? 64 * 1024; - }, -}); - -Object.defineProperty(ServerResponse.prototype, "closed", { - get() { - return this._closed; + // has buffered. + return (this[kBytesBuffered] ?? 0) + handle.bufferedAmount; }, }); @@ -3979,31 +3580,41 @@ ServerResponse.prototype._send = function (data, encoding, callback, _byteLength return OutgoingMessagePrototype._send.$apply(this, arguments); } - const strict = strictContentLength(this, this[headerStateSymbol], false); - if (this[headerStateSymbol] !== NodeHTTPHeaderState.sent) { - handle.cork(() => { - const renderedHeaders = renderNativeHeaders(this); - try { - handle.writeHead( - this[kSnapshotStatusCode] ?? this.statusCode, - this[kSnapshotStatusMessage] ?? this.statusMessage, - renderedHeaders, - renderedAutoHeaders, - renderedKeepAliveSecs, - ); - } finally { - // A throwing writeHead (status validation) must not leave the shared - // scratch array marked busy for the rest of the process. - releaseRenderedHeaders(renderedHeaders); - } - this[headerStateSymbol] = NodeHTTPHeaderState.sent; - handle.write(data, encoding, callback, strict); - }); - } else { - handle.write(data, encoding, callback, strict); + if (typeof encoding === "function") { + callback = encoding; + encoding = null; } + const hadHeader = !!this._header; + if (!hadHeader) validateImplicitHead(this); + const result = writeToHandle(this, handle, data, encoding, strictContentLength(this, hadHeader, false)); + if (result < 0 && callback) handle.onwritable = callback; }; +// The first write takes the head with it, in one native call that throws before it sends anything. +function writeToHandle(res, handle, chunk, encoding, strict) { + if (res._headerSent) return handle.write(chunk, encoding, undefined, strict); + const renderedHeaders = renderNativeHeaders(res); + let result; + try { + result = handle.writeHeadAndWrite( + res[kSnapshotStatusCode] ?? res.statusCode, + res[kSnapshotStatusMessage] ?? res.statusMessage, + renderedHeaders, + chunk, + encoding, + undefined, + strict, + renderedAutoHeaders, + renderedKeepAliveSecs, + ); + } finally { + releaseRenderedHeaders(renderedHeaders); + } + res._header = " "; + res._headerSent = true; + return result; +} + const kSnapshotStatusCode = Symbol("kSnapshotStatusCode"); const kSnapshotStatusMessage = Symbol("kSnapshotStatusMessage"); // Set by writeHead() when it froze the framing with no length/encoding of its @@ -4013,62 +3624,55 @@ const kFramingFrozenChunked = Symbol("kFramingFrozenChunked"); // length there, so that call must not freeze the framing. const kImplicitHeaderFromEnd = Symbol("kImplicitHeaderFromEnd"); ServerResponse.prototype.writeHead = function (statusCode, statusMessage, headers) { - if (this.headersSent) { + return writeHead(this, statusCode, statusMessage, headers, true); +}; + +function writeHead(self, statusCode, statusMessage, headers, explicit) { + if (self._header) { throw $ERR_HTTP_HEADERS_SENT("writeHead"); } - _writeHead(statusCode, statusMessage, headers, this); + _writeHead(statusCode, statusMessage, headers, self, explicit); // Node.js renders the header block immediately in writeHead(), so mutating // res.statusCode/statusMessage afterwards has no effect on the wire. // Headers are flushed lazily here, so snapshot the status line now. - this[kSnapshotStatusCode] = this.statusCode; - this[kSnapshotStatusMessage] = this.statusMessage; + self[kSnapshotStatusCode] = self.statusCode; + self[kSnapshotStatusMessage] = self.statusMessage; // Node's writeHead() freezes body framing: _storeHeader runs with _contentLength null and // a later end(chunk) cannot add Content-Length once _header exists. Headers render lazily // here, so record the frozen choice for renderNativeHeaders. - if (!this[kImplicitHeaderFromEnd] && !this.hasHeader("content-length") && !this.hasHeader("transfer-encoding")) { - this[kFramingFrozenChunked] = true; + if (!self[kImplicitHeaderFromEnd] && !self.hasHeader("content-length") && !self.hasHeader("transfer-encoding")) { + self[kFramingFrozenChunked] = true; } - this[headerStateSymbol] = NodeHTTPHeaderState.assigned; - - // Standalone responses (no native handle, e.g. new ServerResponse(req) + - // assignSocket(writable)) write through the OutgoingMessage machinery, so - // render the header block immediately like Node.js does. - if (!this[kHandle] && !this._header) { - const statusLine = `HTTP/1.1 ${this.statusCode} ${this.statusMessage}\r\n`; - this._storeHeader(statusLine, this[kOutHeaders]); + if (self[kHandle]) { + // The handle renders the head when it sends it. + self._header = " "; + } else { + if (self._expect_continue && !self._sent100) self.shouldKeepAlive = false; + // Node says keep-alive here, and after a Transfer-Encoding without "chunked" it keeps the connection too. + const endsAtClose = bodyEndsAtClose(self); + if (endsAtClose) self.shouldKeepAlive = false; + self._storeHeader(`HTTP/1.1 ${self.statusCode} ${self.statusMessage}\r\n`, self[kOutHeaders], true); + if (endsAtClose) self._last = true; } - return this; -}; + return self; +} ServerResponse.prototype.assignSocket = function (socket) { if (socket._httpMessage) { throw $ERR_HTTP_SOCKET_ASSIGNED(); } socket._httpMessage = this; - socket.once("close", onServerResponseClose); + socket.on("close", onServerResponseClose); this.socket = socket; this.emit("socket", socket); // Like Node.js: drain anything written before the socket was assigned. this._flush(); }; -// Backed by real storage (the native response handle exposes no such flag): -// the standalone _storeHeader path's 204/304 handling assigns it, graceful -// shutdown helpers clear it on in-flight responses, and renderNativeHeaders -// consults it for the Connection header and close-on-finish. -Object.defineProperty(ServerResponse.prototype, "shouldKeepAlive", { - get() { - return this[kShouldKeepAlive] ?? true; - }, - set(value) { - this[kShouldKeepAlive] = value; - }, -}); - ServerResponse.prototype.destroy = function (err?: Error) { if (this.destroyed) return this; const handle = this[kHandle]; @@ -4076,10 +3680,7 @@ ServerResponse.prototype.destroy = function (err?: Error) { // Like Node.js's OutgoingMessage#destroy: remember the error (even when it // is undefined) so `res.errored` reports it, and forward it to the socket. this[kErrored] = err; - // A response that is still queued behind an in-flight pipelined response - // does not own the connection: destroying it must not touch the socket - // (Node.js destroys a socket-less OutgoingMessage without side effects). - if (handle && this[kPipelinedQueuedState] === undefined) { + if (handle) { handle.abort(); } const socket = this[kSocket]; @@ -4102,52 +3703,58 @@ ServerResponse.prototype.emit = function (event) { process.nextTick(emitResponseFinishedThenClose, this, callback); return false; } + // The callbacks of what it couldn't send run ahead of 'close'. An heir of the prototype that this constructor didn't make has none. + const callbacks = this[kPendingCallbacks]; + if (callbacks !== undefined) { + const pending = callbacks.length !== 0; + if (pending) failPendingWriteCallbacks(this, undefined); + if (this[kFailHeldWrites]() || pending) { + process.nextTick(emitClose, this); + return false; + } + } callCloseCallback(this); } return Stream.prototype.emit.$apply(this, arguments); }; +function emitClose(res) { + res.emit("close"); +} + function emitResponseFinishedThenClose(res, callback) { emitResponseFinished(res, callback); res.emit("close"); } ServerResponse.prototype.flushHeaders = function () { - if (this[headerStateSymbol] === NodeHTTPHeaderState.sent) return; // Should be idempotent. - if (this[headerStateSymbol] !== NodeHTTPHeaderState.assigned) this._implicitHeader(); - - if (this[kPipelinedQueuedState] !== undefined) { - // Queued pipelined response: its headers go out when it is assigned the - // socket (advanceResponsePipeline) - nothing can be flushed before then. - return; + const handle = this[kHandle]; + if (!this._header) { + if (this._implicitHeader === OriginalImplicitHeadFn) this.writeHead(this.statusCode); + else this._implicitHeader(); } + if (!handle) return OutgoingMessagePrototype.flushHeaders.$call(this); + // Should be idempotent. + if (this._headerSent) return; - const handle = this[kHandle]; - if (handle) { - if (this[headerStateSymbol] === NodeHTTPHeaderState.assigned) { - this[headerStateSymbol] = NodeHTTPHeaderState.sent; + // 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 renderedHeaders = renderNativeHeaders(this); - try { - handle.writeHead( - this[kSnapshotStatusCode] ?? this.statusCode, - this[kSnapshotStatusMessage] ?? this.statusMessage, - renderedHeaders, - renderedAutoHeaders, - renderedKeepAliveSecs, - ); - } finally { - // A throwing writeHead (status validation) must not leave the shared - // scratch array marked busy for the rest of the process. - releaseRenderedHeaders(renderedHeaders); - } - } - handle.flushHeaders(); - } else { - // Standalone path: _storeHeader rendered this._header; _send('') pushes - // it to the assigned socket like OutgoingMessage.flushHeaders does. - this._send(""); + this._headerSent = true; + const renderedHeaders = renderNativeHeaders(this); + try { + handle.writeHead( + this[kSnapshotStatusCode] ?? this.statusCode, + this[kSnapshotStatusMessage] ?? this.statusMessage, + renderedHeaders, + renderedAutoHeaders, + renderedKeepAliveSecs, + ); + } finally { + releaseRenderedHeaders(renderedHeaders); } + handle.flushHeaders(); + noteResponseWrite(this); }; function updateHasBody(response, statusCode) { @@ -4171,11 +3778,8 @@ function updateHasBody(response, statusCode) { let OriginalWriteHeadFn, OriginalImplicitHeadFn; -function callWriteHeadIfObservable(self, headerState, fromEnd?) { - if ( - headerState === NodeHTTPHeaderState.none && - !(self.writeHead === OriginalWriteHeadFn && self._implicitHeader === OriginalImplicitHeadFn) - ) { +function callWriteHeadIfObservable(self, fromEnd?) { + if (!self._header && !(self.writeHead === OriginalWriteHeadFn && self._implicitHeader === OriginalImplicitHeadFn)) { // Node's end(chunk) assigns _contentLength before _implicitHeader reaches // _storeHeader, so this implicit call must not freeze the framing to chunked // the way an explicit writeHead() does — the body is already in hand. @@ -4188,7 +3792,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[kSocket]; + 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] = timerClockMs(); +} + function allowWritesToContinue() { + noteResponseWrite(this); this._callPendingCallbacks(); this.emit("drain"); } @@ -4236,12 +3850,6 @@ function storeHTTPOptions(options) { this.httpValidation = httpValidation; } - // Node passes options.highWaterMark through to net.Server, which applies it - // to every accepted connection socket (and from there to req/res streams). - this[kHighWaterMark] = options.highWaterMark; - - this[kUniqueHeaders] = parseUniqueHeadersOption(options.uniqueHeaders); - const requestTimeout = options.requestTimeout; if (requestTimeout !== undefined) { validateInteger(requestTimeout, "requestTimeout", 0); @@ -4323,19 +3931,21 @@ function storeHTTPOptions(options) { this[kOptimizeEmptyRequests] = optimizeEmptyRequests || false; } -// Share the pipelining machinery with internal/http1_server_fallback through -// internal/http instead of the user-visible module exports. -http1ServerPipeline.queuePipelinedResponse = queuePipelinedResponse; -http1ServerPipeline.advanceResponsePipeline = advanceResponsePipeline; -http1ServerPipeline.abortQueuedPipelinedResponses = abortQueuedPipelinedResponses; -http1ServerPipeline.lastPipelinedResponse = lastPipelinedResponse; -http1ServerPipeline.maybePauseFallbackReads = maybePauseFallbackReads; -http1ServerPipeline.resumeFallbackReadsOnDrain = resumeFallbackReadsOnDrain; -http1ServerPipeline.finishDrainedResponse = finishDrainedResponse; -http1ServerPipeline.kMustCloseConnection = kMustCloseConnection; +// Own properties of https.Server.prototype, captured before user code can patch them (node: lib/https.js#L115-L122). +const serverPrototypeDescriptors: PropertyDescriptorMap = Object.create(null); +for (const key of Reflect.ownKeys(Server.prototype)) { + if (key === "constructor") continue; + serverPrototypeDescriptors[key] = { __proto__: null, ...Object.getOwnPropertyDescriptor(Server.prototype, key) }; +} +httpServerParts.initHttpServer = initHttpServer; +httpServerParts.serverPrototypeDescriptors = serverPrototypeDescriptors; export default { Server, ServerResponse, + setupConnectionsTracking, + storeHTTPOptions, + _connectionListener: connectionListener, + httpServerPreClose, kConnectionsCheckingInterval, }; diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 3b0faef5b83d..b1723504cff2 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -69,15 +69,6 @@ interface SpawnSyncResult { const kFromNode = Symbol("kFromNode"); -// Pass DEBUG_CHILD_PROCESS=1 to enable debug output -if ($debug) { - $debug("child_process: debug mode on"); - globalThis.__lastId = null; - globalThis.__getId = () => { - return globalThis.__lastId !== null ? globalThis.__lastId++ : 0; - }; -} - // Sections: // 1. Exported child_process functions // 2. child_process helpers @@ -1512,6 +1503,8 @@ class ChildProcess extends EventEmitter { }) as unknown as $Bindgen.Subprocess; this.pid = this.#handle!.pid; + if ($isJSArray(stdio)) stopReadingSharedStdio(stdio); + $debug("ChildProcess: spawn", this.pid, spawnargs); process.nextTick(() => { @@ -1616,7 +1609,8 @@ class ChildProcess extends EventEmitter { // we don't need to because the native IPC layer calls the send callback on nextTick if (callback) { callback(err); - } else if (err) { + } else if (err && (err.code !== "EPIPE" || this.listenerCount("error") > 0)) { + // Unlike node, no 'error' that nobody listens for when the peer never took a message: it would end a program that only lost one. this.emit("error", err); } }); @@ -1794,6 +1788,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/dgram.ts b/src/js/node/dgram.ts index 48fb4620bf39..5b1541eee86b 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -144,7 +144,6 @@ function isInt32(value): value is number { // COMPILING HOST's value, so on Windows it reports -9/-22/-105 // where Node/libuv report the synthetic -4083/-4071/-4081. const UV_EBADF = process.platform === "win32" ? -4083 : -9; -const UV_EEXIST = process.platform === "win32" ? -4075 : -17; const UV_EINVAL = process.platform === "win32" ? -4071 : -22; // Unlike the codes above, ECANCELED's number differs across the POSIX // platforms (Linux 125, Darwin 89, FreeBSD 85) and is a libuv-synthetic @@ -156,10 +155,6 @@ const UV_ECANCELED: number = $bindgen("node_util_binding.bind.ts", "ecanceledErr const kNoTrySend = Array.prototype.indexOf.$call(process.execArgv, "--test-udp-no-try-send") !== -1; const getFdFn = $bindgen("udp_socket.bind.ts", "getFd") as (socket: $Node.UDPSocketHandle) => number; -// Read-only query of the process-wide DGRAM_FDS registry the native layer -// already maintains, so bind({ fd }) can synchronously report EEXIST like -// libuv's uv_udp_open() without keeping a second per-VM copy of the same fact. -const isFdAdoptedFn: (fd: number) => boolean = $bindgen("udp_socket.bind.ts", "dgramIsFdAdopted"); // Fresh getsockname on a live socket's descriptor: the cached `.address` // getter can be stale for an adopted-but-unbound fd once the kernel // auto-binds on first sendto(); Node calls getsockname on every address(). @@ -528,13 +523,10 @@ function handleGetSendQueueCount(this: UDPHandle) { function onMessage(nread: number, handle: UDPHandle, buf: Buffer, rinfo: RemoteInfo) { const self = handle[kOwnerSymbol]!; if (nread < 0) { - return self.emit( - "error", - Object.assign(new Error("recvmsg"), { - syscall: "recvmsg", - errno: nread, - }), - ); + const error: NodeJS.ErrnoException = new Error("recvmsg"); + error.syscall = "recvmsg"; + error.errno = nread; + return self.emit("error", error); } rinfo.size = buf.length; // compatibility self.emit("message", buf, rinfo); @@ -726,11 +718,6 @@ Socket.prototype.bind = function (this: $Node.DgramSocket, port_, address_ /* , if (type !== "UDP") { throw $ERR_INVALID_FD_TYPE(type); } - // Query the native registry so bind({ fd }) throws synchronously like - // uv_udp_open (startBunSocket routes the same failure to emit('error')). - if (isFdAdoptedFn(fd)) { - throw new ErrnoException(UV_EEXIST, "open"); - } startBunSocket(this, state, { fd }); return this; } @@ -860,7 +847,9 @@ function releaseSharedHandle(state: DgramState, handle: SharedHandle) { function startBunSocket( self: $Node.DgramSocket, state: DgramState, - createOptions: { fd: number | undefined } | { hostname: string; port: number; flags: number }, + createOptions: + | { fd: number | undefined; hostname?: undefined; port?: undefined } + | { fd?: undefined; hostname: string; port: number; flags: number }, sharedHandle?: SharedHandle, ) { try { @@ -941,12 +930,18 @@ function startBunSocket( err => { state.bindState = BIND_STATE_UNBOUND; if (sharedHandle) releaseSharedHandle(state, sharedHandle); - self.emit("error", err); + self.emit( + "error", + require("internal/net/errors").bindException(err, createOptions.hostname, createOptions.port), + ); }, ); - } catch (err) { + } catch (nativeError) { + const err = require("internal/net/errors").bindException(nativeError, createOptions.hostname, createOptions.port); state.bindState = BIND_STATE_UNBOUND; if (sharedHandle) releaseSharedHandle(state, sharedHandle); + // bind({ fd }) is the caller, and node throws what handle.open(fd) returns. + else if (createOptions.fd !== undefined) throw err; self.emit("error", err); } } @@ -1347,17 +1342,20 @@ Socket.prototype[SymbolAsyncDispose] = async function (this: $Node.DgramSocket) return; } const promise = $newPromise(); - this.close(FunctionPrototypeBind.$call(onAsyncDisposeClosed, undefined, promise)); + this.close(createAsyncDisposeClosedCallback(promise)); return promise; }; -function onAsyncDisposeClosed(promise: Promise, err?: unknown) { - 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: Promise) { + return (err?: unknown) => { + if (err) { + $rejectPromiseWithFirstResolvingFunctionCallCheck(promise, err); + } else { + $resolvePromiseWithFirstResolvingFunctionCallCheck(promise, undefined); + } + }; } function socketCloseNT(self: $Node.DgramSocket) { diff --git a/src/js/node/dns.ts b/src/js/node/dns.ts index 2c136ed2128b..3982a97e948c 100644 --- a/src/js/node/dns.ts +++ b/src/js/node/dns.ts @@ -100,6 +100,7 @@ const getRuntimeDefaultResultOrderOption: () => string = $bindgen( "dns_internals.bind.ts", "getRuntimeDefaultResultOrderOption", ); +const setConnectResultOrder: (order: string) => void = $bindgen("dns_internals.bind.ts", "setConnectResultOrder"); function newResolver(options: { timeout: number; tries: number }) { if (!newResolver.native) { @@ -125,6 +126,8 @@ declare namespace defaultResultOrder { function setDefaultResultOrder(order: string) { validateOrder(order); defaultResultOrder.value = order; + // fetch() and Bun.connect() resolve through the native DNS cache, not dns.lookup(). No-op in a Worker. + setConnectResultOrder(order); } function getDefaultResultOrder() { diff --git a/src/js/node/http.ts b/src/js/node/http.ts index f36dfdc03c3b..6ef4e6fca3b7 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"); @@ -40,6 +40,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 eb3de2a8243d..643016fa6dd5 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -37,7 +37,6 @@ const { $data } = require("node:fs/promises"); const FileHandle = $data.FileHandle; const bunSocketServerOptions = Symbol.for("::bunnetserveroptions::"); const kInfoHeaders = Symbol("sent-info-headers"); -const kStrictSingleValueFields = Symbol("strictSingleValueFields"); const kDeferWriteCallback = Symbol("deferWriteCallback"); const kTransportClosed = Symbol("transportClosed"); const kProxySocket = Symbol("proxySocket"); @@ -72,8 +71,8 @@ type Http2ConnectOptions = { ALPNProtocols?: string[]; maxConcurrentStreams?: number; maxOutstandingSettings?: number; - maxReservedRemoteStreams?: number; maxOriginSetSize?: number; + maxReservedRemoteStreams?: number; remoteCustomSettings?: number[]; strictFieldWhitespaceValidation?: boolean; strictSingleValueFields?: boolean; @@ -90,8 +89,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,11 +143,15 @@ function throwSettingTypeError(name: string, value: any) { throw err; } +// The one check of SETTINGS values. The native session trusts it. function validateSettings(settings: any) { + if (settings === undefined) return; if (typeof settings !== "object" || settings === null || $isArray(settings)) { throw $ERR_INVALID_ARG_TYPE("settings", "object", settings); } - + validateSettingValues(settings); +} +function validateSettingValues(settings: any) { if (settings.headerTableSize !== undefined) { const v = settings.headerTableSize; if (typeof v !== "number" || v < 0 || v > kMaxInt || Number.isNaN(v)) { @@ -217,7 +222,8 @@ function validateSettings(settings: any) { } for (const key of keys) { const id = Number(key); - if (!Number.isInteger(id) || id < 0 || id > 0xffff) { + // Only a plain decimal id, which is what the native session parses. + if (!Number.isInteger(id) || id < 0 || id > 0xffff || `${id}` !== key) { throwSettingRangeError(key, cs[key]); } const val = cs[key]; @@ -228,10 +234,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); @@ -360,6 +362,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; @@ -379,6 +382,7 @@ 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. // 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,7 +404,10 @@ let priorityDeprecationWarned = false; 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. @@ -409,6 +416,8 @@ const kGoawayCode = Symbol("goawayCode"); const kGoawayLastStreamID = Symbol("goawayLastStreamID"); const kReleaseUnannouncedStream = Symbol("releaseUnannouncedStream"); const kGoawaySent = Symbol("goawaySent"); +// Set by destroySessionInternally(). +const kInternalDestroy = Symbol("internalDestroy"); // Node's socketOnError: once a GOAWAY has been received the peer is fully // within its rights to drop the connection, so an ECONNRESET behind it is @@ -449,18 +458,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()); } @@ -583,9 +580,6 @@ function isPseudoHeader(name) { } } -function isConnectionHeaderAllowed(name, value) { - return name !== HTTP2_HEADER_CONNECTION || value === "trailers"; -} let statusConnectionHeaderWarned = false; let statusMessageWarned = false; function statusMessageWarn() { @@ -617,7 +611,7 @@ function assertValidHeader(name, value) { if (value === undefined || value === null) { throw $ERR_HTTP2_INVALID_HEADER_VALUE(value, name); } - if (!isConnectionHeaderAllowed(name, value)) { + if (isConnectionSpecificHeader(name, value)) { connectionHeaderMessageWarn(); } } @@ -882,6 +876,7 @@ class Http2ServerResponse extends Stream { validateString(name, "name"); name = StringPrototypeToLowerCase.$call(StringPrototypeTrim.$call(name)); assertValidHeader(name, value); + if (isConnectionSpecificHeader(name, value)) return; this[kTrailers][name] = value; } @@ -941,7 +936,8 @@ class Http2ServerResponse extends Stream { name = StringPrototypeToLowerCase.$call(StringPrototypeTrim.$call(name)); assertValidHeader(name, value); - if (!isConnectionHeaderAllowed(name, value)) { + // Node throws from respond() for all of these but `connection`. They often come from an upstream's HTTP/1.1 response, so drop them all. + if (isConnectionSpecificHeader(name, value)) { return; } @@ -962,7 +958,8 @@ class Http2ServerResponse extends Stream { name = StringPrototypeToLowerCase.$call(StringPrototypeTrim.$call(name)); assertValidHeader(name, value); - if (!isConnectionHeaderAllowed(name, value)) { + // Node throws from respond() for all of these but `connection`. They often come from an upstream's HTTP/1.1 response, so drop them all. + if (isConnectionSpecificHeader(name, value)) { return; } @@ -1215,10 +1212,15 @@ class Http2ServerResponse extends Stream { } const stream = this[kStream]; if (stream.headersSent || this[kState].closed) return false; - stream.additionalHeaders({ - ...(headers || {}), - [HTTP2_HEADER_STATUS]: statusCode, - }); + const outHeaders = { __proto__: null }; + const keys = ObjectKeys(headers || {}); + for (let i = 0; i < keys.length; i++) { + const value = headers[keys[i]]; + if (isConnectionSpecificHeader(StringPrototypeToLowerCase.$call(keys[i]), value)) connectionHeaderMessageWarn(); + else outHeaders[keys[i]] = value; + } + outHeaders[HTTP2_HEADER_STATUS] = statusCode; + stream.additionalHeaders(outHeaders); return true; } @@ -1228,20 +1230,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 = StringPrototypeToLowerCase.$call(StringPrototypeTrim.$call(key)); + 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); } } @@ -1709,44 +1710,12 @@ const { HTTP2_HEADER_SCHEME, HTTP2_HEADER_PATH, HTTP2_HEADER_PROTOCOL, - HTTP2_HEADER_ACCESS_CONTROL_ALLOW_CREDENTIALS, - HTTP2_HEADER_ACCESS_CONTROL_REQUEST_METHOD, - HTTP2_HEADER_AGE, - HTTP2_HEADER_AUTHORIZATION, - HTTP2_HEADER_CONNECTION, - HTTP2_HEADER_CONTENT_ENCODING, HTTP2_HEADER_CONTENT_LENGTH, - HTTP2_HEADER_CONTENT_TYPE, HTTP2_HEADER_COOKIE, HTTP2_HEADER_DATE, - HTTP2_HEADER_ETAG, HTTP2_HEADER_HOST, - HTTP2_HEADER_IF_MODIFIED_SINCE, - HTTP2_HEADER_IF_NONE_MATCH, - HTTP2_HEADER_IF_RANGE, - HTTP2_HEADER_LAST_MODIFIED, - HTTP2_HEADER_LOCATION, - HTTP2_HEADER_RANGE, - HTTP2_HEADER_REFERER, HTTP2_HEADER_SET_COOKIE, - HTTP2_HEADER_UPGRADE_INSECURE_REQUESTS, - HTTP2_HEADER_USER_AGENT, - HTTP2_HEADER_X_CONTENT_TYPE_OPTIONS, HTTP2_HEADER_ORIGIN, - HTTP2_HEADER_ACCESS_CONTROL_MAX_AGE, - HTTP2_HEADER_CONTENT_LANGUAGE, - HTTP2_HEADER_CONTENT_LOCATION, - HTTP2_HEADER_CONTENT_MD5, - HTTP2_HEADER_CONTENT_RANGE, - HTTP2_HEADER_DNT, - HTTP2_HEADER_EXPIRES, - HTTP2_HEADER_FROM, - HTTP2_HEADER_IF_MATCH, - HTTP2_HEADER_IF_UNMODIFIED_SINCE, - HTTP2_HEADER_MAX_FORWARDS, - HTTP2_HEADER_PROXY_AUTHORIZATION, - HTTP2_HEADER_RETRY_AFTER, - HTTP2_HEADER_TK, HTTP2_METHOD_CONNECT, HTTP2_METHOD_DELETE, HTTP2_METHOD_GET, @@ -1767,63 +1736,7 @@ const { // This set is defined strictly by the HTTP/2 specification. Only // :-prefixed headers defined by that specification may be added to // this set. -const kValidPseudoHeaders = new SafeSet([ - HTTP2_HEADER_STATUS, - HTTP2_HEADER_METHOD, - HTTP2_HEADER_AUTHORITY, - HTTP2_HEADER_SCHEME, - HTTP2_HEADER_PATH, - HTTP2_HEADER_PROTOCOL, -]); -const kSingleValueHeaders = new SafeSet([ - HTTP2_HEADER_STATUS, - HTTP2_HEADER_METHOD, - HTTP2_HEADER_AUTHORITY, - HTTP2_HEADER_SCHEME, - HTTP2_HEADER_PATH, - HTTP2_HEADER_PROTOCOL, - HTTP2_HEADER_ACCESS_CONTROL_ALLOW_CREDENTIALS, - HTTP2_HEADER_ACCESS_CONTROL_MAX_AGE, - HTTP2_HEADER_ACCESS_CONTROL_REQUEST_METHOD, - HTTP2_HEADER_AGE, - HTTP2_HEADER_AUTHORIZATION, - HTTP2_HEADER_CONTENT_ENCODING, - HTTP2_HEADER_CONTENT_LANGUAGE, - HTTP2_HEADER_CONTENT_LENGTH, - HTTP2_HEADER_CONTENT_LOCATION, - HTTP2_HEADER_CONTENT_MD5, - HTTP2_HEADER_CONTENT_RANGE, - HTTP2_HEADER_CONTENT_TYPE, - HTTP2_HEADER_DATE, - HTTP2_HEADER_DNT, - HTTP2_HEADER_ETAG, - HTTP2_HEADER_EXPIRES, - HTTP2_HEADER_FROM, - HTTP2_HEADER_HOST, - HTTP2_HEADER_IF_MATCH, - HTTP2_HEADER_IF_MODIFIED_SINCE, - HTTP2_HEADER_IF_NONE_MATCH, - HTTP2_HEADER_IF_RANGE, - HTTP2_HEADER_IF_UNMODIFIED_SINCE, - HTTP2_HEADER_LAST_MODIFIED, - HTTP2_HEADER_LOCATION, - HTTP2_HEADER_MAX_FORWARDS, - HTTP2_HEADER_PROXY_AUTHORIZATION, - HTTP2_HEADER_RANGE, - HTTP2_HEADER_REFERER, - HTTP2_HEADER_RETRY_AFTER, - HTTP2_HEADER_TK, - HTTP2_HEADER_UPGRADE_INSECURE_REQUESTS, - HTTP2_HEADER_USER_AGENT, - HTTP2_HEADER_X_CONTENT_TYPE_OPTIONS, -]); - -function assertValidPseudoHeader(key) { - if (!kValidPseudoHeaders.has(key)) { - throw $ERR_HTTP2_INVALID_PSEUDOHEADER(key); - } -} -hideFromStack(assertValidPseudoHeader); +const { kSingleValueFields: kSingleValueHeaders, assertValidPseudoHeader } = require("internal/quic/http2util"); // node's internal/http2/util sessionName() — used for debug output and exposed to the ported // node test suite through the internals object below. @@ -1855,12 +1768,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 { @@ -1924,6 +1839,10 @@ abstract class Http2Session extends EventEmitter { get goawayLastStreamID() { return this[kGoawayLastStreamID] || 0; } + goaway(code = NGHTTP2_NO_ERROR, lastStreamID = 0, opaqueData?) { + if (this.destroyed) throw $ERR_HTTP2_INVALID_SESSION(); + sendGoaway(this, code, lastStreamID, opaqueData); + } [EventEmitter.captureRejectionSymbol](err, event, ...args) { switch (event) { case "stream": { @@ -1937,13 +1856,54 @@ abstract class Http2Session extends EventEmitter { } } -function streamErrorFromCode(code: number) { - if (code === 0xe) { - return $ERR_HTTP2_MAX_PENDING_SETTINGS_ACK(); +// goaway() without its check for a destroyed session, which the GOAWAY of destroy() wouldn't pass. +function sendGoaway(session: Http2Session, code: number, lastStreamID: number, opaqueData?: Buffer) { + if (opaqueData !== undefined) { + validateBuffer(opaqueData, "opaqueData"); } + validateInteger(code, "code", 0, kMaxUint32); + validateNumber(lastStreamID, "lastStreamID"); + session[bunHTTP2Native]?.goaway(code, lastStreamID, opaqueData ?? kNoOpaqueData); +} + +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). @@ -1951,6 +1911,7 @@ hideFromStack(streamErrorFromCode); // asserts init/before/after/destroy fire for type 'HTTP2PING'. const { AsyncResource } = require("node:async_hooks"); const kEmptyPingPayload = Buffer.alloc(8); +const kNoOpaqueData = Buffer.alloc(0); function makeHttp2Ping(callback) { const resource = new AsyncResource("HTTP2PING"); return function done(err, duration, payload) { @@ -1989,16 +1950,23 @@ function createPendingStreamCancelError(cause?: any) { // The native settings object for a server session: session options + the user's settings, with // enablePush forced off only when the caller explicitly provided it (see the RFC 9113 §6.5.2 note // at the construction site). Only explicitly-present settings are serialized by the native layer. +// What the native session reads: the session's options and its initial settings. An option named like a setting counts as one. +function nativeSessionSettings(options) { + const settings = options?.settings; + if (settings !== undefined && (typeof settings !== "object" || settings === null || $isArray(settings))) { + throw $ERR_INVALID_ARG_TYPE("settings", "object", settings); + } + const merged = { ...options, ...settings }; + validateSettingValues(merged); + return merged; +} function serverNativeSettings(options) { - const merged = { ...options, ...options?.settings }; + const merged = nativeSessionSettings(options); if (merged.enablePush !== undefined) merged.enablePush = false; return merged; } function sessionErrorFromCode(code: number) { - if (code === 0xe) { - return $ERR_HTTP2_MAX_PENDING_SETTINGS_ACK(); - } return $ERR_HTTP2_SESSION_ERROR(code); } hideFromStack(sessionErrorFromCode); @@ -2027,7 +1995,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() @@ -2042,6 +2011,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); } @@ -2053,31 +2025,55 @@ function endInboundHalf(stream: Http2Stream) { pushToStream(stream, null); } +function endReadableAtHalfClose(stream: Http2Stream) { + if (!stream.readable || stream.closed) return; + if (stream._readableState.ended) { + // streamHeaders already pushed this EOF. It waits for a late reader, unless an 'end' listener with no reader is already waiting for it. + if (stream.readableFlowing === null && stream.readableLength === 0 && stream.listenerCount("end") > 0) { + stream.resume(); + } + return; + } + endInboundHalf(stream); + stream.read(0); +} +function endReadableAtClose(stream: Http2Stream) { + endInboundHalf(stream); + if (stream instanceof ServerHttp2Stream && !stream.readableDidRead && stream.readableFlowing === null) { + // Like node's onStreamClose, dump an unread request 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); + } +} + +// What the `Http2Stream` object has done. The state of the stream on the wire is in the native stream table. enum StreamState { - EndedCalled = 1 << 0, // 00001 = 1 - WantTrailer = 1 << 1, // 00010 = 2 - FinalCalled = 1 << 2, // 00100 = 4 - Closed = 1 << 3, // 01000 = 8 - StreamResponded = 1 << 4, // 10000 = 16 - WritableClosed = 1 << 5, // 100000 = 32 - // The native side fully closed and freed the stream (state 7 delivered): there is - // nothing left to send on the wire for it. - NativeClosed = 1 << 6, // 1000000 = 64 + EndedCalled = 1 << 0, + WantTrailer = 1 << 1, + Closed = 1 << 2, + StreamResponded = 1 << 3, + WritableClosed = 1 << 4, // END_STREAM already rode the final DATA frame from _write/_writev; _final must not // emit the empty END_STREAM frame on top of it. - EndStreamSent = 1 << 7, // 10000000 = 128 + EndStreamSent = 1 << 5, // The server stream has been handed to user code (the 'stream' event or the pushStream // 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() has scheduled the RST_STREAM. Once the native side has forgotten the stream, a - // second rstStream() with an error code would write the frame again. - ResetScheduled = 1 << 9, // 1000000000 = 512 - EndedByClose = 1 << 10, // 10000000000 = 1024 + Delivered = 1 << 6, + // close() ends the stream with an RST_STREAM, so a write still in flight doesn't get END_STREAM behind it. + RstPending = 1 << 7, + // The request HEADERS that opened this server stream carried END_STREAM. + EndAfterHeaders = 1 << 8, + // User code didn't end the writable side. respond() did (HEAD, 204, 205, 304, endStream), or the peer's RST_STREAM with NO_ERROR or CANCEL. + EndedForUser = 1 << 9, + // The stream closed on the wire and waits for a reader before it emits 'end' and 'close'. Session teardown destroys it. + AwaitsReader = 1 << 10, // The server ended the stream before respond(), which then puts END_STREAM on the HEADERS frame. - EndedBeforeHeaders = 1 << 11, // 100000000000 = 2048 - // The native side closed the stream (the peer's RST_STREAM, a protocol error), not destroy(). - ClosedByNative = 1 << 12, // 1000000000000 = 4096 + EndedBeforeHeaders = 1 << 11, } // 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 @@ -2104,7 +2100,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 ); } @@ -2113,10 +2110,9 @@ function markWritableDone(stream: Http2Stream) { if (typeof _final === "function") { stream[bunHTTP2StreamFinal] = null; _final(); - stream[bunHTTP2StreamStatus] |= StreamState.WritableClosed | StreamState.FinalCalled; - return; } stream[bunHTTP2StreamStatus] |= StreamState.WritableClosed; + if (stream instanceof ServerHttp2Stream) maybeCloseUnreadServerStream(stream); } const kCloseChannelPublished = Symbol("closeChannelPublished"); function publishStreamCloseChannel(stream: Http2Stream) { @@ -2224,20 +2220,55 @@ function emitHttp2SessionPerf(session, parser, socket) { ); } -function markStreamClosed(stream: Http2Stream) { +// Returns false if the stream was closed already. +function closeStreamOnce(stream: Http2Stream) { const status = stream[bunHTTP2StreamStatus]; - - if ((status & StreamState.Closed) === 0) { - stream[bunHTTP2StreamStatus] = status | StreamState.Closed; - publishStreamCloseChannel(stream); - - markWritableDone(stream); + if ((status & StreamState.Closed) !== 0) return false; + stream[bunHTTP2StreamStatus] = status | StreamState.Closed; + if (stream[kTimeout]) clearTimeout(stream[kTimeout]); + stream[kTimeout] = null; + stream.removeAllListeners("timeout"); + publishStreamCloseChannel(stream); + return true; +} +function markStreamClosed(stream: Http2Stream) { + if (closeStreamOnce(stream)) markWritableDone(stream); +} +// 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; } -function rstNextTick(id: number, rstCode: number) { - const session = this as Http2Session; +// Ends the writable side of a stream the peer reset, if it's still open. write() drops what user code writes afterwards. +function abortAndEndWritable(stream: Http2Stream) { + if (!abortIfWritableOpen(stream) || stream.destroyed) return; + stream[bunHTTP2StreamStatus] |= StreamState.EndedForUser; + stream.end(); +} +// Like node, the code of close(code) stays when the peer's RST_STREAM arrives before ours is written. +function keepsOwnRstCode(stream: Http2Stream) { + return (stream[bunHTTP2StreamStatus] & StreamState.Closed) !== 0 && !!stream.rstCode; +} +// 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.WritableClosed; + // Without an 'error' listener the stream's own code would be an uncaught error that only this timing raises. + if (!keepsOwnRstCode(stream) || stream.listenerCount("error") === 0) stream.rstCode = NGHTTP2_CANCEL; + abortAndEndWritable(stream); +} +// Does nothing for a stream that is closed by now. +function rstNextTick(session: Http2Session, id: number, rstCode: number) { session[bunHTTP2Native]?.rstStream(id, rstCode); } +// A session without a parser has closed all of its streams. +function isClosedOnWire(stream: Http2Stream) { + const native = stream[bunHTTP2Session]?.[bunHTTP2Native]; + return !native || native.isStreamClosed(stream.id); +} // node streamOnPause/streamOnResume (lib/internal/http2/core.js): the readable's flow state // drives the native receive window. While paused, the stream's window is not replenished; on // resume the deferred WINDOW_UPDATE is sent. A pending stream (no id yet) has nothing on the @@ -2258,11 +2289,15 @@ 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, session, id, code); } function uncorkNT(stream: Http2Stream) { stream.uncork(); } +function readNothing(this: Http2Stream) { + this.read(0); +} +function ignoreError() {} // 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) { @@ -2282,22 +2317,16 @@ function setupRequestEndAndSignal(req: Http2Stream, options: any, signal: AbortS // node destroys every open stream synchronously from inside session.destroy(): a write() issued // right after destroy() must already observe an ended + destroyed stream (write-after-end without // an 'error' event), and stream teardown must not wait a tick. -function destroyStreamForSessionDestroy(error: Error | undefined, rstCode: number, stream: Http2Stream) { +function destroyStreamForSessionDestroy( + error: Error | undefined, + rstCode: number, + stream: Http2Stream, + closedOnWire: boolean, +) { if (stream.destroyed) return; if (rstCode && !stream.rstCode) stream.rstCode = rstCode; - // A clean session teardown can reach here while a stream's already-received request data is - // still being drained by its consumer: the native side is done with the stream (state 7, - // which is what allowed the session to start destroying), but the JS side is not. Destroying - // now discards that data and emits 'close' without 'end' (a piped request body never - // finishes). Node's session waits for the JS stream, so finish delivering and destroy on - // 'end'. Only for natively-closed streams: a still-open one must keep node's contract that - // session.destroy() destroys it synchronously (and the engine error sweep follows for it). - if ( - error === undefined && - (stream[bunHTTP2StreamStatus] & StreamState.NativeClosed) !== 0 && - stream.readable && - stream.readableFlowing !== null - ) { + // The session is destroyed from inside the handler of this stream's close. Its reader still gets what has arrived, and 'end'. + if (error === undefined && closedOnWire && stream.readable && stream.readableFlowing !== null) { stream.push(null); stream.once("end", destroySelfOnEnd); return; @@ -2309,8 +2338,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, false); +} interface Http2StreamReadableState { destroyed: boolean; + ended: boolean; } interface Http2StreamWritableState { ending: boolean; @@ -2321,7 +2355,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; @@ -2338,17 +2374,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; @@ -2362,7 +2402,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"; } @@ -2436,6 +2476,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 = {}; @@ -2452,26 +2493,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; @@ -2480,10 +2512,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() { @@ -2511,11 +2541,24 @@ 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) ?? {}; + if (session && !this.destroyed && typeof this.#id === "number") { + const native = session[bunHTTP2Native]; + if (!native) return {}; + // Like Node, where nghttp2 has dropped the id of a stream that only lives on for its unread data, and Http2Stream::RefreshState then reports an idle stream. + const awaitsReader = (this[bunHTTP2StreamStatus] & StreamState.AwaitsReader) !== 0; + return ( + (awaitsReader ? undefined : 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. + // stream has been destroyed. return {}; } @@ -2537,11 +2580,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() { @@ -2549,9 +2588,9 @@ class Http2Stream extends (Duplex as Http2StreamBase) { } get session() { - // node detaches the session reference once the session has been destroyed. + // The listeners that the session's teardown runs still get it, as in Node, though `session.destroyed` is already true. const session = this[bunHTTP2Session]; - if (session == null || session.destroyed) return undefined; + if (session == null || session[bunHTTP2Socket] === null) return undefined; return session; } @@ -2561,7 +2600,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?) { @@ -2581,33 +2625,33 @@ 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"); - } + const endWritable = abortIfWritableOpen(this); + if (endWritable) { // Set first: with nothing left to write, end() calls _final() before it returns. - this[bunHTTP2StreamStatus] |= StreamState.EndedByClose; + if (!this.pending) this[bunHTTP2StreamStatus] |= StreamState.RstPending; 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) { - this[bunHTTP2StreamStatus] |= StreamState.ResetScheduled; - setImmediate(rstNextTick.bind(session, this.#id, code)); + this[bunHTTP2StreamStatus] |= StreamState.RstPending; + setImmediate(rstNextTick, session, this.#id, code); } else { - this.once("finish", rstNextTick.bind(session, this.#id, code)); + this.once("finish", rstNextTick.bind(null, session, this.#id, code)); } // node destroys the stream once both halves have finished; without this a stream closed // while idle never emits 'close'. @@ -2623,30 +2667,22 @@ 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; + 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); + const id = this.#id; + const native = session[bunHTTP2Native]; + // The table forgets a closed stream once its Http2Stream is destroyed. A session without a parser has closed all of its streams. + const closedOnWire = typeof id === "number" && (!native || native.releaseStream(id)); let rstCode = this.rstCode; if (!rstCode) { - if (err != null) { + // Like Node, a stream that already closed cleanly keeps its code. + if (err != null && !closedOnWire) { if (err.code === "ABORT_ERR") { // Enables using AbortController to cancel requests with RST code 8. rstCode = NGHTTP2_CANCEL; @@ -2659,10 +2695,7 @@ class Http2Stream extends (Duplex as Http2StreamBase) { } this.rstCode = rstCode; emitHttp2StreamPerf(this); - // node closes the stream from inside _destroy, so the close-channel publish observes - // closed === true and destroyed === true with the final rstCode. The non-error close path - // (streamEnd state=7) calls markStreamClosed BEFORE destroy(), where the channel observes - // destroyed === false instead. + // Like Node, a local destroy() closes the stream here, so its close channel sees `destroyed` and the final rstCode. A close on the wire has published already. markStreamClosed(this); // RST code 8 not emitted as an error as its used by clients to signify // abort and is already covered by aborted event, also allows more @@ -2683,6 +2716,8 @@ class Http2Stream extends (Duplex as Http2StreamBase) { ) { err = null; } + // The client didn't ask for a pushed stream, so its 'error' is never uncaught. It stays errored, or a body the peer cut short would emit 'end'. + if (err != null && this[kPush] && this.listenerCount("error") === 0) this.on("error", ignoreError); this[bunHTTP2Session] = null; // This notifies the session that this stream has been destroyed and @@ -2690,16 +2725,8 @@ class Http2Stream extends (Duplex as Http2StreamBase) { // will destroy if it has been closed and there are no other open or // pending streams. Delay with setImmediate so we don't do it on the // nghttp2 stack. - if ( - session && - typeof this.#id === "number" && - !this[kNeverAnnounced] && - (this[bunHTTP2StreamStatus] & StreamState.ResetScheduled) === 0 && - // 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) - ) { - setImmediate(rstNextTick.bind(session, this.#id, rstCode)); + if (typeof id === "number" && !closedOnWire) { + setImmediate(rstNextTick, session, id, rstCode); } // Diagnostics channels: published after the stream is closed and destroyed, with the same error @@ -2726,7 +2753,6 @@ class Http2Stream extends (Duplex as Http2StreamBase) { onClientStreamBodySentChannel.publish({ stream: this }); } if ((status & StreamState.EndStreamSent) !== 0) { - this[bunHTTP2StreamStatus] = status | StreamState.FinalCalled; if ((status & (StreamState.WritableClosed | StreamState.Closed)) !== 0) { callback(); } else { @@ -2736,39 +2762,26 @@ class Http2Stream extends (Duplex as Http2StreamBase) { } return; } + // Like node, close() with nothing left to write still sends END_STREAM ahead of its RST_STREAM, unless trailers would carry it. + if ( + (status & StreamState.RstPending) !== 0 && + ((status & StreamState.Closed) !== 0 || this[bunHTTP2WaitForTrailers]) + ) { + this[bunHTTP2WaitForTrailers] = false; + this[bunHTTP2StreamStatus] |= StreamState.WritableClosed; + callback(); + return; + } const session = this[bunHTTP2Session]; if (session) { const native = session[bunHTTP2Native]; if (native) { - if (this instanceof ServerHttp2Stream && !this.headersSent) { - // An empty DATA frame would go out with no HEADERS frame before it, and the peer would - // answer with a connection error. Like node, send nothing and emit 'finish'. - this[bunHTTP2StreamStatus] = status | StreamState.EndedBeforeHeaders; - if (this._readableState.destroyed && (status & StreamState.ClosedByNative) === 0) { - // Called from _destroy() after the user's destroy(). Its markStreamClosed() completes this once - // the writable is marked destroyed again, so there's no 'finish'. Node's onStreamClose ends the - // writable before it destroys the stream, so a stream that the peer reset does emit it. - this[bunHTTP2StreamFinal] = callback; - return; - } - this[bunHTTP2StreamStatus] |= StreamState.FinalCalled; + if (this instanceof ServerHttp2Stream && !this.headersSent && !native.isStreamClosed(this.#id)) { + // An empty DATA frame ahead of the response HEADERS is a connection error for the peer. Like node, send nothing and emit 'finish'. + this[bunHTTP2StreamStatus] |= StreamState.EndedBeforeHeaders | StreamState.WritableClosed; callback(); return; } - if ((status & StreamState.EndedByClose) !== 0) { - // Like node, RST_STREAM ends these streams, not END_STREAM, which would tell the peer that the body is - // complete: one that waits for trailers (node's onStreamTrailers returns on a closed stream), and one with - // a write still in flight when close() returned, unless the code is CANCEL. - const closed = (status & StreamState.Closed) !== 0; - if (this[bunHTTP2WaitForTrailers] || (closed && this.rstCode !== NGHTTP2_CANCEL)) { - this[bunHTTP2WaitForTrailers] = false; - // Called from inside close(), whose markStreamClosed() completes it. - if (closed) callback(); - else this[bunHTTP2StreamFinal] = callback; - return; - } - } - this[bunHTTP2StreamStatus] |= StreamState.FinalCalled; // When waitForTrailers is active, writing an empty DATA frame with // close=true emits a bare empty DATA frame (flags=0) to the wire // before the trailer/noTrailers path runs, which then emits ANOTHER @@ -2786,7 +2799,12 @@ class Http2Stream extends (Duplex as Http2StreamBase) { // markWritableDone before control returns here: stash the callback there so the // writable finishes ('finish' before 'close') instead of being torn down mid-final. this[bunHTTP2StreamFinal] = callback; - if ((this[bunHTTP2StreamStatus] & StreamState.Closed) !== 0 || this.listenerCount("wantTrailers") === 0) { + if ( + (this[bunHTTP2StreamStatus] & StreamState.Closed) !== 0 || + // Nobody is left to send trailers to. + session[kTransportClosed] || + this.listenerCount("wantTrailers") === 0 + ) { // No 'wantTrailers' listener — or the stream was close()d while the last write // was still in flight, in which case node never asks for trailers (the compat // onStreamTrailersReady would throw ERR_HTTP2_INVALID_STREAM on a closed @@ -2830,7 +2848,6 @@ class Http2Stream extends (Duplex as Http2StreamBase) { } if ((status & StreamState.WritableClosed) !== 0 || (status & StreamState.Closed) !== 0) { callback(); - this[bunHTTP2StreamStatus] |= StreamState.FinalCalled; } else { this[bunHTTP2StreamFinal] = callback; } @@ -2854,6 +2871,17 @@ class Http2Stream extends (Duplex as Http2StreamBase) { } } + write(chunk, encoding?, callback?) { + // respond() (HEAD, 204, 304) and the peer's reset end the writable side themselves. A write() after that is dropped, unless the stream is open and has an 'error' listener. + const status = this[bunHTTP2StreamStatus]; + if ((status & StreamState.EndedForUser) !== 0 && (this.listenerCount("error") === 0 || isClosedOnWire(this))) { + if (typeof encoding === "function") callback = encoding; + if (typeof callback === "function") process.nextTick(callback, $ERR_STREAM_WRITE_AFTER_END()); + return false; + } + return super.write(chunk, encoding, callback); + } + end(chunk?, encoding?, callback?) { const status = this[bunHTTP2StreamStatus]; if (typeof callback === "undefined") { @@ -2867,9 +2895,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 @@ -2936,8 +2963,11 @@ 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); + // The stream is closed, and native has called the callback. + if (status === false) return; if (status & kWriteFlushedWithoutCallback) session[kDeferWriteCallback](callback); if (endStream) { this[bunHTTP2StreamStatus] |= StreamState.EndStreamSent; @@ -2983,8 +3013,11 @@ 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); + // The stream is closed, and native has called the callback. + if (status === false) return; if (status & kWriteFlushedWithoutCallback) session[kDeferWriteCallback](callback); if (endStream) { this[bunHTTP2StreamStatus] |= StreamState.EndStreamSent; @@ -3016,6 +3049,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 @@ -3046,6 +3088,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); @@ -3053,7 +3101,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; @@ -3072,7 +3120,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; @@ -3141,7 +3190,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. @@ -3171,7 +3220,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, }); @@ -3182,6 +3232,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); }, @@ -3240,41 +3291,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. 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; @@ -3286,7 +3322,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 @@ -3312,7 +3348,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(); } } @@ -3333,10 +3369,9 @@ 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. - if (session[kStrictSingleValueFields] !== false) assertSingleValueHeaders(headers); const sensitives = headers[sensitiveHeaders]; // Note: the sensitiveHeaders symbol stays on the object — the native header walk skips // symbol keys, and deleting it here would flip the object into dictionary mode, @@ -3346,14 +3381,22 @@ 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); + } + // Like node, a repeated single-value field throws. The callback gets every other error. Neither reserves a stream id. + try { + parser.validateRequest(headers); + } catch (err: any) { + if (err.code === "ERR_HTTP2_HEADER_SINGLE_VALUE") throw err; + process.nextTick(callback, err); + return; } const pushId = parser.getNextStream(); if (pushId === -1) { @@ -3364,6 +3407,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) { @@ -3383,7 +3427,6 @@ class ServerHttp2Stream extends Http2Stream { // The PUSH_PROMISE never reached the wire; sending RST_STREAM for the reserved id would // be a protocol violation (the peer sees an idle stream). The skipped reset dispatch is // also what releases the session's bookkeeping - do that explicitly. - pushedStream[kNeverAnnounced] = true; session[kReleaseUnannouncedStream](pushId); pushedStream.destroy(); } @@ -3396,7 +3439,6 @@ class ServerHttp2Stream extends Http2Stream { // the session error must reach its 'error' listener). PUSH_PROMISE never reached the wire, // so teardown must not RST the reserved id. if (pushedStream) { - pushedStream[kNeverAnnounced] = true; pushedStream[bunHTTP2StreamStatus] |= StreamState.Delivered; } process.nextTick(callback, null, pushedStream, headers); @@ -3420,7 +3462,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(); @@ -3441,7 +3483,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 @@ -3480,7 +3525,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(); @@ -3501,7 +3546,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 ( @@ -3548,6 +3596,7 @@ class ServerHttp2Stream extends Http2Stream { if (this.headersSent) { throw $ERR_HTTP2_HEADERS_AFTER_RESPOND(); } + if (this[kTimeout]) this[kTimeout].refresh(); if (headers == undefined) { headers = {}; @@ -3571,16 +3620,9 @@ class ServerHttp2Stream extends Http2Stream { throw $ERR_INVALID_ARG_VALUE("headers[http2.neverIndex]", sensitives); } const sensitiveNames = buildSensitiveNames(headers, sensitives); - // 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); @@ -3594,10 +3636,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(); } @@ -3607,6 +3650,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 @@ -3645,15 +3690,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]; } @@ -3672,9 +3709,7 @@ class ServerHttp2Stream extends Http2Stream { throw $ERR_INVALID_ARG_VALUE("headers[http2.neverIndex]", sensitives); } const sensitiveNames = buildSensitiveNames(headers, sensitives); - // 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; @@ -3689,12 +3724,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; @@ -3706,18 +3737,11 @@ class ServerHttp2Stream extends Http2Stream { this.headRequest === true || (this[bunHTTP2StreamStatus] & StreamState.EndedBeforeHeaders) !== 0 ) { - // 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; } + // Read before the native call, which can close the stream and end the writable side. + const endedByRespond = endStream && !this._writableState.ending; + const waitForTrailers = !endStream && !!options?.waitForTrailers; const sendDate = options?.sendDate; if (rawHeadersList === null && (sendDate == null || sendDate)) { const current_date = headers["date"]; @@ -3727,21 +3751,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) { @@ -3749,6 +3769,7 @@ class ServerHttp2Stream extends Http2Stream { } this[bunHTTP2Headers] = headers; if (endStream) { + if (endedByRespond) this[bunHTTP2StreamStatus] |= StreamState.EndedForUser; this.end(); } @@ -3758,10 +3779,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) { @@ -3781,11 +3806,16 @@ function destroyWithInvalidSessionNT(stream) { // teardown by the grace, never past it. function scheduleSettingsAckGraceNT(session) { if (session[kSettingsAckGraceTimer] !== undefined) return; - const timer = setTimeout(destroyIfNotDestroyedNT, 250, session); + const timer = setTimeout(destroySessionInternally, 250, session); // Never keeps the event loop alive: if nothing else does, the process is exiting anyway. timer.unref?.(); session[kSettingsAckGraceTimer] = timer; } +// Every destroy() with no error that the session decides on itself goes through here, so destroy() can tell it from the program's. +function destroySessionInternally(session: Http2Session) { + session[kInternalDestroy] = true; + session.destroy(); +} function destroyIfNotDestroyedNT(target) { if (!target.destroyed) target.destroy(); } @@ -3801,10 +3831,23 @@ function rejectRequestNT(session, req) { return; } req.rstCode = constants.NGHTTP2_PROTOCOL_ERROR; - emitStreamErrorNT(session, req, constants.NGHTTP2_PROTOCOL_ERROR, true, false); + emitStreamErrorNT(session, req, constants.NGHTTP2_PROTOCOL_ERROR, false); } -function emitStreamErrorNT(self, stream, error, destroy, destroy_self) { +// Like node's onStreamClose, RST_STREAM(NO_ERROR) closes the stream but leaves buffered data for its reader. +function closeStreamResetWithNoError(session: Http2Session, stream: Http2Stream) { + if (!stream.closed) { + stream.rstCode = NGHTTP2_NO_ERROR; + abortAndEndWritable(stream); + markStreamClosed(stream); + } + // An 'aborted' listener or a close-channel subscriber can destroy the stream. + if (stream.destroyed) return; + endReadableAtClose(stream); + destroyClosedStream(session, stream); +} + +function emitStreamErrorNT(self, stream, error, destroy_self) { if (stream) { if (stream.destroyed && stream.listenerCount("error") === 0) { // Already destroyed with no consumer listening: re-destroying with an @@ -3813,7 +3856,7 @@ function emitStreamErrorNT(self, stream, error, destroy, destroy_self) { // A destroyed stream that IS listened keeps the delivery below - // node's errorEmitted de-dupes the re-emission, and grpc's status // codes ride exactly this deferred path. - if (destroy_self) self.destroy(); + if (destroy_self) destroySessionInternally(self); return; } if (typeof error === "number" && self != null && self[kSessionDestroyError] != null) { @@ -3823,6 +3866,7 @@ function emitStreamErrorNT(self, stream, error, destroy, destroy_self) { if (error !== 0 && !stream.rstCode) stream.rstCode = error; error = self[kSessionDestroyError]; } + if (typeof error === "number" && keepsOwnRstCode(stream)) error = stream.rstCode; let error_instance: Error | number | undefined = undefined; if (stream.listenerCount("error") > 0) { if (typeof error === "number") { @@ -3834,33 +3878,21 @@ function emitStreamErrorNT(self, stream, error, destroy, destroy_self) { error_instance = error; } } - if (destroy && error === NGHTTP2_NO_ERROR && stream.readable) { - // Like node's onStreamClose: the readable ends first, and 'end' destroys the stream. - stream.once("end", destroySelfOnEnd); - endInboundHalf(stream); - if (stream instanceof ServerHttp2Stream && !stream.readableDidRead && stream.readableFlowing === null) - stream.resume(); - else stream.read(0); - if (destroy_self) self!.destroy(); - return; - } - if (stream.readable) { - stream.resume(); // we have a error we consume and close - pushToStream(stream, null); - } - 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[bunHTTP2StreamStatus] |= StreamState.ClosedByNative; - stream.destroy(error_instance, stream.rstCode); + // A stream that already emitted 'end' goes this way too while its writable side is open, so end() still emits 'finish'. + if (error === NGHTTP2_NO_ERROR && !self.destroyed && (stream.readable || !stream._writableState.ending)) { + closeStreamResetWithNoError(self, stream); } else { - markStreamClosed(stream); - if (error_instance) { - stream.emit("error", error_instance); + // Like Node's onStreamClose, a reset on the wire closes the stream and publishes its close channel before destroy(). Only a local destroy() publishes from _destroy. + let closedHere = false; + if (typeof error !== "object" && !stream.destroyed) { + abortIfWritableOpen(stream); + closedHere = closeStreamOnce(stream); } + stream.destroy(error_instance, stream.rstCode); + if (closedHere) markWritableDone(stream); } - if (destroy_self) self.destroy(); + if (destroy_self) destroySessionInternally(self); } } // Outbound guard: header values carrying code points above 0xFF (or raw CR/LF/NUL) cannot be @@ -3874,13 +3906,21 @@ const kForbiddenConnectionHeaders = new SafeSet([ "proxy-connection", "transfer-encoding", ]); +// `name` is lowercase. Only a `te` value is stringified here, so a user toString() on any other field runs once, in the native walk. +function isConnectionSpecificHeader(name: string, value): boolean { + if (kForbiddenConnectionHeaders.has(name)) return true; + if (name !== "te") return false; + if ($isArray(value) && value.length === 1) value = value[0]; + return `${value}` !== "trailers"; +} 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++) { + const value = headers[keys[i]]; + if (value === undefined || ($isArray(value) && value.length === 0)) continue; + const lower = keys[i].toLowerCase(); + if (isConnectionSpecificHeader(lower, value)) { + throw $ERR_HTTP2_INVALID_CONNECTION_HEADERS(`HTTP/1 Connection specific headers are forbidden: "${lower}"`); } } } @@ -3927,25 +3967,6 @@ function stripInvalidWhitespaceFields(rawheaders: string[]): string[] { // the user's sensitive-headers list matches case-insensitively, and `authorization` (always) // plus short `cookie` values (< 20 chars) are never indexed even when not listed. -// node validates header constraints in JS before anything reaches the native encoder, so a -// throwing request leaves no partial state in the shared HPACK table. Mirror the single-value -// rule here: duplicated single-value fields (across case variants) and multi-element arrays for -// them throw before encoding starts. -function assertSingleValueHeaders(headers) { - let seen: Set | null = null; - const keys = Object.keys(headers); - for (let i = 0; i < keys.length; i++) { - const lower = keys[i].toLowerCase(); - if (!kSingleValueHeaders.has(lower)) continue; - const value = headers[keys[i]]; - if (($isArray(value) && value.length > 1) || (seen !== null && seen.has(lower))) { - throw $ERR_HTTP2_HEADER_SINGLE_VALUE(`Header field "${lower}" must only have a single value`); - } - if (seen === null) seen = new SafeSet(); - seen.add(lower); - } -} - // Renders a received value the way node's determineSpecificType does for error messages. function receivedValueLabel(value) { if (value === null) return "null"; @@ -3980,6 +4001,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) { @@ -4084,45 +4119,21 @@ class ServerHttp2Session extends Http2Session { #closeCalled: boolean = false; /// connected indicates that the connection/socket is connected #connected: boolean = false; - #connections: number = 0; #socket_proxy: import("node:net").Socket | import("node:tls").TLSSocket | undefined; #parser: $Bindgen.H2FrameParser | null; #alpnProtocol: string | undefined = undefined; #localSettings: Settings | null = null; #encrypted: boolean = false; - #pendingSettingsAck: boolean = true; - // Count of SETTINGS frames sent that the peer has not yet ACKed (the initial connection - // SETTINGS counts as the first). node destroys the session with - // ERR_HTTP2_MAX_PENDING_SETTINGS_ACK when this exceeds maxOutstandingSettings. - #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]; + // node's Http2Settings queue: one entry per SETTINGS frame the peer hasn't ACKed yet (null for the initial one). + #pendingSettingsCallbacks: Array = [null]; #remoteSettings: Settings | null = null; #pingCallbacks: Array<[Function, number]> | null = null; #strictFieldWhitespaceValidation: boolean = true; - // The SETTINGS_MAX_CONCURRENT_STREAMS value this session advertised (enforced from the moment it - // is submitted, like nghttp2's pending local settings — not only after the peer ACKs). - #advertisedMaxConcurrentStreams: number = Infinity; - // Client-initiated (odd-id) streams currently open: RFC 9113 5.1.2 - only these count against - // the limit this server advertised; its own pushed streams count against the client's setting. - #peerInitiatedStreams: number = 0; - static #Handlers = { - binaryType: "buffer", streamStart(self: ServerHttp2Session, stream_id: number) { if (!self) return; - // RFC 9113 §5.1.2: refuse peer-initiated streams that would exceed the advertised - // 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; - } - 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; @@ -4134,24 +4145,16 @@ 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"); - } - self.#connections--; - if (stream.id % 2 === 1) self.#peerInitiatedStreams--; - process.nextTick(emitStreamErrorNT, self, stream, error, true, self.#connections === 0 && self.#closed); + abortOnPeerCancel(stream); + process.nextTick(emitStreamErrorNT, self, stream, error, self.#isClosedAndIdle()); }, streamError(self: ServerHttp2Session, stream: ServerHttp2Stream, error: number) { if (!self || typeof stream !== "object") return; - self.#connections--; - if (stream.id % 2 === 1) self.#peerInitiatedStreams--; - process.nextTick(emitStreamErrorNT, self, stream, error, true, self.#connections === 0 && self.#closed); + process.nextTick(emitStreamErrorNT, self, stream, error, self.#isClosedAndIdle()); }, streamEnd(self: ServerHttp2Session, stream: ServerHttp2Stream, state: number) { if (!self || typeof stream !== "object") return; @@ -4162,49 +4165,25 @@ 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. - // After close(code) the stream is about to be destroyed, with no 'end' before that. - if ((state == 7 || (stream.readableLength === 0 && !stream.rstCode)) && 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], endReadableAtClose, undefined, stream); } // 7 = closed, in this case we already send everything and received everything if (state === 7) { - stream[bunHTTP2StreamStatus] |= StreamState.NativeClosed; 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(); - } - if (self.#connections === 0 && self.#closed) { - if (self.#pendingSettingsAckCount > 0 || (self.#pingCallbacks !== null && self.#pingCallbacks.length > 0)) { + destroyClosedStream(self, stream); + if (self.#isClosedAndIdle()) { + if ( + self.#pendingSettingsCallbacks.length > 0 || + (self.#pingCallbacks !== null && self.#pingCallbacks.length > 0) + ) { scheduleSettingsAckGraceNT(self); } else { captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); - setImmediate(destroyIfNotDestroyedNT, self); + setImmediate(destroySessionInternally, self); } } } else if (state === 5) { @@ -4215,9 +4194,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, @@ -4225,7 +4201,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; @@ -4241,15 +4223,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; } @@ -4259,14 +4236,9 @@ class ServerHttp2Session extends Http2Session { if (endOfStream) endInboundHalf(stream); stream.emit("trailers", headers, flags, rawheaders); } else { - // Set the StreamResponded bit BEFORE dispatching the 'stream' event - // synchronously to user code. The user handler may call - // stream.respond()/stream.end() which set other bits (WantTrailer, - // FinalCalled, EndedCalled, WritableClosed). If we captured `status` - // and wrote it back AFTER the emit, we'd clobber any bits set by the - // user handler — in particular, losing WantTrailer/FinalCalled breaks - // any later `sendTrailers()` with ERR_HTTP2_TRAILERS_NOT_READY. + // Set before the 'stream' event. A write-back afterwards would lose the bits the handler's respond() and end() set. stream[bunHTTP2StreamStatus] |= StreamState.StreamResponded | StreamState.Delivered; + if (endOfStream) stream[bunHTTP2StreamStatus] |= StreamState.EndAfterHeaders; if (onServerStreamCreatedChannel.hasSubscribers) { onServerStreamCreatedChannel.publish({ stream, headers }); } @@ -4282,38 +4254,29 @@ class ServerHttp2Session extends Http2Session { localSettings(self: ServerHttp2Session, settings: Settings) { if (!self) return; self.#localSettings = settings; - self.#pendingSettingsAck = false; - if (self.#pendingSettingsAckCount > 0) self.#pendingSettingsAckCount--; + const acked = self.#pendingSettingsCallbacks.shift(); // This ACK may be the only thing a gracefully closed, idle session was waiting for. const pingsDrained = self.#pingCallbacks === null || self.#pingCallbacks.length === 0; - if (self.#pendingSettingsAckCount === 0 && pingsDrained && self[kSettingsAckGraceTimer] !== undefined) { + if (self.#pendingSettingsCallbacks.length === 0 && pingsDrained && self[kSettingsAckGraceTimer] !== undefined) { clearTimeout(self[kSettingsAckGraceTimer]); self[kSettingsAckGraceTimer] = undefined; } - if ( - self.#closed && - self.#connections === 0 && - self.#pendingSettingsAckCount === 0 && - pingsDrained && - !self.destroyed - ) { + if (self.#isClosedAndIdle() && self.#pendingSettingsCallbacks.length === 0 && pingsDrained && !self.destroyed) { captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); - setImmediate(destroyIfNotDestroyedNT, self); + setImmediate(destroySessionInternally, 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, acked); }, 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); } @@ -4327,28 +4290,22 @@ class ServerHttp2Session extends Http2Session { const stats = self[kPerfStats]; if (stats !== undefined) stats.pingRTT = rtt; callback(null, rtt, payload); - if (callbacks.length === 0 && self.#pendingSettingsAckCount === 0) { + if (callbacks.length === 0 && self.#pendingSettingsCallbacks.length === 0) { if (self[kSettingsAckGraceTimer] !== undefined) { clearTimeout(self[kSettingsAckGraceTimer]); self[kSettingsAckGraceTimer] = undefined; } // A server session has no pending-request queue. - if (self.#closed && self.#connections === 0 && !self.destroyed) { + if (self.#isClosedAndIdle() && !self.destroyed) { captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); - setImmediate(destroyIfNotDestroyedNT, self); + setImmediate(destroySessionInternally, self); } } } } } }, - error( - self: ServerHttp2Session, - errorCode: number | string, - lastStreamId: number, - opaqueData: Buffer, - clientIsGone?: boolean, - ) { + error(self: ServerHttp2Session, errorCode: number | string, lastStreamId: number, opaqueData: Buffer) { if (!self) return; if (errorCode === "ERR_HTTP2_TOO_MANY_INVALID_FRAMES") { self.destroy($ERR_HTTP2_TOO_MANY_INVALID_FRAMES()); @@ -4358,8 +4315,8 @@ class ServerHttp2Session extends Http2Session { // detected; node surfaces those as NghttpError (code ERR_HTTP2_ERROR) via // onSessionInternalError. if (typeof errorCode === "number" && errorCode < 0) { - // Only the session reports the error. Its streams end as they do when the socket closes. - if (clientIsGone) self.#parser?.forEachStream(streamSocketClosed); + // The responses of a client that is gone are still queued. Only the session reports the error, and its streams end as they do when the socket closes. + if (self.#parser?.hasRejectedSend()) self.#parser.forEachStream(streamSocketClosed); self.destroy(new NghttpError(errorCode)); return; } @@ -4387,7 +4344,7 @@ class ServerHttp2Session extends Http2Session { if (errorCode === constants.NGHTTP2_NO_ERROR) { // Graceful shutdown: no new streams, existing ones may finish. self.close(); - } else if (self.closed && self.#connections === 0) { + } else if (self.closed && self.#isClosedAndIdle()) { // Mutual teardown: this side already sent its own graceful GOAWAY // (close()) and every stream is done, so the peer's error code cannot // affect anything user-visible - both ends agreed the connection is @@ -4396,9 +4353,8 @@ class ServerHttp2Session extends Http2Session { // peer's late GOAWAY could be read - grpc-js forceShutdown sends // GOAWAY(NGHTTP2_CANCEL) at a client that just close()d, and on the // Windows agents the frame deterministically arrived first). - self.destroy(); + destroySessionInternally(self); } 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); @@ -4406,7 +4362,7 @@ class ServerHttp2Session extends Http2Session { }, end(self: ServerHttp2Session, errorCode: number, lastStreamId: number, opaqueData: Buffer) { if (!self) return; - self.destroy(); + destroySessionInternally(self); }, write(self: ServerHttp2Session, buffer: Buffer, transportClosed: boolean) { if (!self) return -1; @@ -4425,15 +4381,21 @@ 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.#isClosedAndIdle(); + const unread = unreadClosedStreams(parser); 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(unread, 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 @@ -4441,7 +4403,7 @@ class ServerHttp2Session extends Http2Session { // never comes once the peer is gone — leaving the session (and the // server's open-connection count) alive forever. this.close(); - this.destroy(); + destroySessionInternally(this); } #onError(error: Error) { // Node's socketOnError reads `const session = this[kSession]` and does @@ -4453,7 +4415,7 @@ class ServerHttp2Session extends Http2Session { return; } if (isEconnresetAfterGoaway(this, error)) { - this.destroy(); + destroySessionInternally(this); return; } if (this.listenerCount("error") === 0 && (error as NodeJS.ErrnoException)?.code === "ECONNRESET") { @@ -4464,18 +4426,11 @@ class ServerHttp2Session extends Http2Session { // attaches sessionOnError at accept time, so this branch only matters // for standalone sessions.) Anything that is not teardown noise keeps // Node's EventEmitter contract and surfaces when unobserved. - this.destroy(); + destroySessionInternally(this); return; } this.destroy(error); } - #onTimeout() { - const parser = this.#parser; - if (parser) { - parser.forEachStream(emitTimeout); - } - this.emit("timeout"); - } #onDrain() { const parser = this.#parser; if (parser) { @@ -4541,10 +4496,13 @@ class ServerHttp2Session extends Http2Session { parser.origin(validOrigins); } + // close() or destroy() was called and no stream is open on the wire. + #isClosedAndIdle() { + return this.#closed && !this.#parser?.hasOpenStreams(); + } + [kReleaseUnannouncedStream](streamId: number) { - // A pushed stream torn down before its PUSH_PROMISE left: no reset dispatch will arrive, so - // release the connection slot and the native stream context here. - this.#connections--; + // A pushed stream that is torn down before its PUSH_PROMISE left was never on the wire, so it's closed without a reset. this.#parser?.setStreamContext(streamId, undefined); } @@ -4562,7 +4520,6 @@ class ServerHttp2Session extends Http2Session { if (options?.strictFieldWhitespaceValidation === false) { this.#strictFieldWhitespaceValidation = false; } - this[kStrictSingleValueFields] = options?.strictSingleValueFields !== false; if (server) { server[kSessions].add(this); } @@ -4578,16 +4535,8 @@ class ServerHttp2Session extends Http2Session { this[kDeferWriteCallback] = deferWriteCallbackForSocket(nativeSocket); this.#encrypted = socket instanceof TLSSocket; - if (typeof options?.maxOutstandingSettings === "number" && options.maxOutstandingSettings >= 1) { - this.#maxOutstandingSettings = options.maxOutstandingSettings; - } - const advertisedMaxConcurrentStreams = options?.settings?.maxConcurrentStreams ?? options?.maxConcurrentStreams; - if (typeof advertisedMaxConcurrentStreams === "number") { - this.#advertisedMaxConcurrentStreams = advertisedMaxConcurrentStreams; - } - - if (options?.settings !== undefined) { - validateSettings(options.settings); + if (typeof options?.maxOutstandingSettings === "number") { + this.#maxOutstandingSettings = Math.max(1, options.maxOutstandingSettings); } const nativeSettings = serverNativeSettings(options); this.#localSettings = initialLocalSettings(nativeSettings); @@ -4606,7 +4555,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)); @@ -4640,7 +4589,8 @@ class ServerHttp2Session extends Http2Session { return this[bunHTTP2Socket]?.connecting === false; } get destroyed() { - return this[bunHTTP2Socket] === null; + // Like Node's flag, true before a teardown reaches the first stream. The socket slot is cleared last. + return this.#destroying || this[bunHTTP2Socket] === null; } get encrypted() { return this.#encrypted; @@ -4650,17 +4600,17 @@ class ServerHttp2Session extends Http2Session { } get remoteSettings() { - if (this.destroyed || this.connecting) return {}; + if (this[bunHTTP2Socket] === null || this.connecting) return {}; return (this.#remoteSettings ??= getDefaultSettings()); } get localSettings() { - if (this.destroyed || this.connecting) return {}; + if (this[bunHTTP2Socket] === null || this.connecting) return {}; return (this.#localSettings ??= getDefaultSettings()); } get pendingSettingsAck() { - return this.#pendingSettingsAck; + return this.#pendingSettingsCallbacks.length > 0; } get type() { @@ -4677,7 +4627,7 @@ class ServerHttp2Session extends Http2Session { return this.#socket_proxy; } get state() { - return this.#parser?.getCurrentState(); + return this.#parser?.getCurrentState() ?? {}; } get [bunHTTP2Native]() { @@ -4696,7 +4646,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) { @@ -4728,16 +4678,6 @@ class ServerHttp2Session extends Http2Session { } return true; } - goaway(code = NGHTTP2_NO_ERROR, lastStreamID = 0, opaqueData) { - if (this.destroyed) throw $ERR_HTTP2_INVALID_SESSION(); - - if (opaqueData !== undefined) { - validateBuffer(opaqueData, "opaqueData"); - } - validateInteger(code, "code", 0, kMaxUint32); - validateNumber(lastStreamID, "lastStreamID"); - return this.#parser?.goaway(code, lastStreamID, opaqueData); - } setLocalWindowSize(windowSize) { if (this.destroyed) throw $ERR_HTTP2_INVALID_SESSION(); @@ -4748,42 +4688,26 @@ 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 // in ServerHttp2Session's constructor). Clients still accept `enablePush` // via their own `settings()` method. settings = { ...settings, enablePush: false }; - if (typeof settings.maxConcurrentStreams === "number") { - this.#advertisedMaxConcurrentStreams = settings.maxConcurrentStreams; - } // node: enforce maxOutstandingSettings - the session is destroyed with // ERR_HTTP2_MAX_PENDING_SETTINGS_ACK when too many SETTINGS are un-ACKed. - this.#pendingSettingsAckCount++; - if (this.#pendingSettingsAckCount > this.#maxOutstandingSettings) { + if (this.#pendingSettingsCallbacks.length >= this.#maxOutstandingSettings) { this.destroy($ERR_HTTP2_MAX_PENDING_SETTINGS_ACK(), constants.NGHTTP2_INTERNAL_ERROR); 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. @@ -4799,30 +4723,26 @@ class ServerHttp2Session extends Http2Session { // node submits a graceful GOAWAY as soon as close() is called; the peer observes the shutdown // ('goaway' event) while in-flight streams are still allowed to finish. The session is only // destroyed once there is nothing in flight, and never before the GOAWAY had a chance to leave. - this.goaway(constants.NGHTTP2_NO_ERROR, 0, Buffer.alloc(0)); + this.goaway(); this[kGoawaySent] = true; this.#parser?.flush?.(); - if (this.#connections === 0) { + if (this.#isClosedAndIdle()) { // Same bounded grace the client applies: an unACKed SETTINGS or ping still // reaches its callback/'localSettings' before the session is destroyed. - if (this.#pendingSettingsAckCount > 0 || (this.#pingCallbacks !== null && this.#pingCallbacks.length > 0)) { + if ( + this.#pendingSettingsCallbacks.length > 0 || + (this.#pingCallbacks !== null && this.#pingCallbacks.length > 0) + ) { scheduleSettingsAckGraceNT(this); } else { captureHttp2PerfFrameSnapshot(this, this.#parser); - setImmediate(destroyIfNotDestroyedNT, this); + setImmediate(destroySessionInternally, this); } } } 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. `destroyed` reads this latch. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js (Http2Session#destroy) if (this.#destroying) { return; @@ -4855,35 +4775,13 @@ class ServerHttp2Session extends Http2Session { } if (!this.#connected) return; - // Like node's finishSessionClose, which only leaves the socket to the peer after close(). - destroysSocket = !!error || !this.#closed; + // Only a teardown the session starts itself ends a graceful close(). A destroy() that the program calls destroys every stream. + const endsGracefulClose = this[kInternalDestroy] === true && this.#isClosedAndIdle(); + const unread = unreadClosedStreams(this.#parser); 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 (destroysSocket) { - // 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. + sendGoaway(this, code || constants.NGHTTP2_NO_ERROR, 0); } const parser = this.#parser; if (parser) { @@ -4897,8 +4795,22 @@ 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(unread, error, endsGracefulClose, !socket || socket.destroyed); + this.#connected = false; + if (socket) { + destroysSocket = !!error || !this.#closeCalled; + if (destroysSocket) { + 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 @@ -4908,6 +4820,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. `pendingSettingsAck` stays as it is. + this.#pendingSettingsCallbacks.fill(null); // 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. @@ -4927,20 +4841,21 @@ class ServerHttp2Session extends Http2Session { } } } -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"); @@ -4956,9 +4871,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); @@ -4967,7 +4881,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 @@ -4981,6 +4895,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 @@ -4997,8 +4920,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(); @@ -5017,13 +4939,75 @@ 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); + // A flowing stream with nothing buffered emits 'end' within a few ticks, whatever happens to its session. + if (!stream.readableFlowing || stream.readableLength > 0) { + stream[bunHTTP2StreamStatus] |= StreamState.AwaitsReader; + } + } else if (!stream.writableFinished && !stream.destroyed && stream.errored == null) { + // END_STREAM closed the stream from inside end(), or from respond() just before its end(). Destroying now would swallow 'finish'. An errored stream never emits it. + if (!stream[kEndingWithChunk]) stream.end(); + stream.once("finish", destroySelfOnEnd); + } else { + stream.destroy(); + } +} +// Read before the parser is detached, which forgets them. +function unreadClosedStreams(parser: $Bindgen.H2FrameParser | null) { + const streams: Http2Stream[] = []; + parser?.forEachStream(stream => { + if ((stream[bunHTTP2StreamStatus] & StreamState.AwaitsReader) !== 0) streams.push(stream); + }, true); + return streams; +} +// Session teardown destroys them here or they never emit 'close'. +function settleUnreadClosedStreams( + streams: Http2Stream[], + error: Error | null | undefined, + endsGracefulClose: boolean, + transportGone: boolean, +) { + // A graceful close finishes without waiting for unread streams, so they stay readable. + if (error == null && endsGracefulClose) 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); } @@ -5048,7 +5032,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); } } @@ -5067,29 +5052,24 @@ class ClientHttp2Session extends Http2Session { #closed: boolean = false; // One-shot destroy latch (Node: "if (this.destroyed) return;" opens destroy()). #destroying: boolean = false; + // An error GOAWAY has arrived. destroy() follows once the streams that the peer left out are rejected. + #failedByGoaway: boolean = false; /// closeCalled tracks whether close() specifically was called: `session.closed` only reports a /// graceful close() in node — destroy() leaves it false while `session.destroyed` flips to true. #closeCalled: boolean = false; /// connected indicates that the connection/socket is connected #connected: boolean = false; - #connections: number = 0; #socket_proxy: import("node:net").Socket | import("node:tls").TLSSocket | undefined; #parser: $Bindgen.H2FrameParser | null; - #url: URL; + #defaultScheme: string; #authority: string; #alpnProtocol: string | undefined = undefined; #localSettings: Settings | null = null; #encrypted: boolean = false; - #pendingSettingsAck: boolean = true; - // Count of SETTINGS frames sent that the peer has not yet ACKed (the initial connection - // SETTINGS counts as the first). node destroys the session with - // ERR_HTTP2_MAX_PENDING_SETTINGS_ACK when this exceeds maxOutstandingSettings. - #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]; + // node's Http2Settings queue: one entry per SETTINGS frame the peer hasn't ACKed yet (null for the initial one). + #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). @@ -5097,30 +5077,10 @@ class ClientHttp2Session extends Http2Session { #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; + // Requests without an id yet, like node's: they wait, in order, for the socket to connect or for a slot under the peer's SETTINGS_MAX_CONCURRENT_STREAMS. + #pendingRequests: Set | null = null; static #Handlers = { - binaryType: "buffer", - streamStart(self: ClientHttp2Session, stream_id: number) { - 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, pushId: number, @@ -5128,7 +5088,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); @@ -5148,12 +5109,13 @@ 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--; }); - self.#connections++; self.#parser?.setStreamContext(pushId, pushedStream); if (onClientStreamCreatedChannel.hasSubscribers) { onClientStreamCreatedChannel.publish({ stream: pushedStream, headers }); @@ -5168,24 +5130,19 @@ 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"); - } - self.#connections--; - process.nextTick(emitStreamErrorNT, self, stream, error, true, self.#connections === 0 && self.#closed); + abortOnPeerCancel(stream); + self.#flushPendingRequests(); + process.nextTick(emitStreamErrorNT, self, stream, error, self.#isClosedAndIdle()); }), streamError: withStreamFrame((self: ClientHttp2Session, stream: ClientHttp2Stream, error: number) => { if (!self || typeof stream !== "object") return; - - self.#connections--; - process.nextTick(emitStreamErrorNT, self, stream, error, true, self.#connections === 0 && self.#closed); + self.#flushPendingRequests(); + process.nextTick(emitStreamErrorNT, self, stream, error, self.#isClosedAndIdle()); }), streamEnd: withStreamFrame((self: ClientHttp2Session, stream: ClientHttp2Stream, state: number) => { if (!self || typeof stream !== "object") return; @@ -5196,46 +5153,30 @@ 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 if (state === 7) { - 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(); - } - if (self.#connections === 0 && self.#closed) { + self.#flushPendingRequests(); + destroyClosedStream(self, stream); + if (self.#isClosedAndIdle()) { // 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 // ping gets bounded grace (scheduleSettingsAckGraceNT); its arrival completes destroy. - if (self.#pendingSettingsAckCount > 0 || (self.#pingCallbacks !== null && self.#pingCallbacks.length > 0)) { + if ( + self.#pendingSettingsCallbacks.length > 0 || + (self.#pingCallbacks !== null && self.#pingCallbacks.length > 0) + ) { scheduleSettingsAckGraceNT(self); } else { captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); - setImmediate(destroyIfNotDestroyedNT, self); + setImmediate(destroySessionInternally, self); } } } else if (state === 5) { @@ -5254,7 +5195,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) { @@ -5313,32 +5256,22 @@ class ClientHttp2Session extends Http2Session { localSettings(self: ClientHttp2Session, settings: Settings) { if (!self) return; self.#localSettings = settings; - self.#pendingSettingsAck = false; - if (self.#pendingSettingsAckCount > 0) self.#pendingSettingsAckCount--; + const acked = self.#pendingSettingsCallbacks.shift(); // This ACK may be the only thing a gracefully closed, idle session was waiting for. const pingsDrained = self.#pingCallbacks === null || self.#pingCallbacks.length === 0; - if (self.#pendingSettingsAckCount === 0 && pingsDrained && self[kSettingsAckGraceTimer] !== undefined) { + if (self.#pendingSettingsCallbacks.length === 0 && pingsDrained && self[kSettingsAckGraceTimer] !== undefined) { clearTimeout(self[kSettingsAckGraceTimer]); self[kSettingsAckGraceTimer] = undefined; } - if ( - self.#closed && - self.#connections === 0 && - self.#pendingSettingsAckCount === 0 && - pingsDrained && - !self.destroyed - ) { + if (self.#isClosedAndIdle() && self.#pendingSettingsCallbacks.length === 0 && pingsDrained && !self.destroyed) { captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); - setImmediate(destroyIfNotDestroyedNT, self); + setImmediate(destroySessionInternally, 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, acked); }, 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. @@ -5347,6 +5280,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); } @@ -5360,20 +5294,12 @@ class ClientHttp2Session extends Http2Session { const stats = self[kPerfStats]; if (stats !== undefined) stats.pingRTT = rtt; callback(null, rtt, payload); - if (callbacks.length === 0 && self.#pendingSettingsAckCount === 0) { + if (callbacks.length === 0 && self.#pendingSettingsCallbacks.length === 0) { if (self[kSettingsAckGraceTimer] !== undefined) { 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(); } } } @@ -5415,26 +5341,23 @@ class ClientHttp2Session extends Http2Session { // fail over to a fresh connection). self[kReceivedGoaway] = true; self.emit("goaway", errorCode, lastStreamId, opaqueData || Buffer.allocUnsafe(0)); + if (errorCode !== constants.NGHTTP2_NO_ERROR) self.#failedByGoaway = true; // node: streams the peer did not process (id above the GOAWAY's lastStreamId) are // destroyed with ERR_HTTP2_GOAWAY_SESSION - clients (grpc) rely on that error class to // retry on a fresh connection instead of reporting a cancellation. 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(); - } else if (self.closed && self.#connections === 0) { + // 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.#isClosedAndIdle()) { // Mutual teardown: this side already sent its own graceful GOAWAY // (close()) and every stream is done, so the peer's error code cannot // affect anything user-visible - both ends agreed the connection is @@ -5443,32 +5366,32 @@ class ClientHttp2Session extends Http2Session { // peer's late GOAWAY could be read - grpc-js forceShutdown sends // GOAWAY(NGHTTP2_CANCEL) at a client that just close()d, and on the // Windows agents the frame deterministically arrived first). - self.destroy(); + destroySessionInternally(self); } else { self.destroy(sessionErrorFromCode(errorCode), constants.NGHTTP2_NO_ERROR); } }, end(self: ClientHttp2Session, errorCode: number, lastStreamId: number, opaqueData: Buffer) { if (!self) return; - self.destroy(); + destroySessionInternally(self); }, altsvc(self: ClientHttp2Session, origin: string, value: string, streamId: number) { if (!self) return; // 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 && origin) { const originSet = initOriginSet(self, socket); const origins = $isArray(origin) ? origin : [origin]; - for (const item of origins) { + for (let i = 0; i < origins.length; i++) { if (originSet.size >= self.#maxOriginSetSize) { self.destroy($ERR_HTTP2_TOO_MANY_ORIGINS(self.#maxOriginSetSize)); return; } - originSet.add(item); + originSet.add(origins[i]); } self.emit("origin", origins); } @@ -5520,37 +5443,39 @@ class ClientHttp2Session extends Http2Session { this.#parser!.setNativeSocket(nativeSocket); this[kDeferWriteCallback] = deferWriteCallbackForSocket(nativeSocket); } - process.nextTick(emitConnectNT, this, socket); this.#parser!.flush(); - 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(); - return; - } - // Requests made while the socket was still connecting were queued; submit them now. - this.#flushPendingRequests(); + } + + // Requests made while connecting go out after the 'connect' listeners, which can still destroy() or close() the session. + #emitConnect(socket, wasConnecting: boolean) { + // On a transport that was already connected, Node sends a request from inside request(). So it goes out first, and close() waits for it. + if (!wasConnecting) this.#flushPendingRequests(); + this.emit("connect", this, socket); + // Like Node, close() rejects the requests that waited for the socket to connect. + if (this.#closed && wasConnecting) this.#rejectPendingRequests(); + else this.#flushPendingRequests(); + // A 'connect' listener can open a request and then close(). That request still completes. + if (this.#isClosedAndIdle()) destroySessionInternally(this); } #onClose() { 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.#isClosedAndIdle(); + const unread = unreadClosedStreams(parser); if (parser) { parser.forEachStream(streamCancel); parser.forEachStream(streamSocketClosed); parser.detach(); this.#parser = null; } + settleUnreadClosedStreams(unread, err, closedAndIdle, true); + // Node's socketOnClose calls close() on a session that's still attached to the socket. + if (!this.destroyed) this.#closeCalled = true; + // Like Node's socketOnClose, before the session counts as destroyed. + this.#cancelPendingRequests(err, NGHTTP2_NO_ERROR); this.destroy(err, NGHTTP2_NO_ERROR); this[bunHTTP2Socket] = null; } @@ -5561,13 +5486,12 @@ class ClientHttp2Session extends Http2Session { if (this.destroyed) { return; } - this[bunHTTP2Socket] = null; if (this.#closed) { - this.destroy(); + destroySessionInternally(this); return; } if (isEconnresetAfterGoaway(this, error)) { - this.destroy(); + destroySessionInternally(this); return; } if (this.listenerCount("error") === 0 && (error as NodeJS.ErrnoException)?.code === "ECONNRESET") { @@ -5576,18 +5500,11 @@ class ClientHttp2Session extends Http2Session { // still errors any remaining streams. Anything else (handshake // failure, ECONNREFUSED, ...) keeps Node's EventEmitter contract and // surfaces when unobserved. - this.destroy(); + destroySessionInternally(this); return; } this.destroy(error); } - #onTimeout() { - const parser = this.#parser; - if (parser) { - parser.forEachStream(emitTimeout); - } - this.emit("timeout"); - } #onDrain() { const parser = this.#parser; if (parser) { @@ -5605,7 +5522,8 @@ class ClientHttp2Session extends Http2Session { return this[bunHTTP2Socket]?.connecting === false; } get destroyed() { - return this[bunHTTP2Socket] === null; + // Like Node's flag, true before a teardown reaches the first stream. The socket slot is cleared last. + return this.#destroying || this.#failedByGoaway || this[bunHTTP2Socket] === null; } get encrypted() { return this.#encrypted; @@ -5615,17 +5533,17 @@ class ClientHttp2Session extends Http2Session { } get remoteSettings() { - if (this.destroyed || this.connecting) return {}; + if (this[bunHTTP2Socket] === null || this.connecting) return {}; return (this.#remoteSettings ??= getDefaultSettings()); } get localSettings() { - if (this.destroyed || this.connecting) return {}; + if (this[bunHTTP2Socket] === null || this.connecting) return {}; return (this.#localSettings ??= getDefaultSettings()); } get pendingSettingsAck() { - return this.#pendingSettingsAck; + return this.#pendingSettingsCallbacks.length > 0; } get type() { @@ -5650,7 +5568,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(); @@ -5681,10 +5599,6 @@ class ClientHttp2Session extends Http2Session { } return true; } - goaway(errorCode = constants.NGHTTP2_NO_ERROR, lastStreamId = 0, opaqueData) { - if (this.destroyed) throw $ERR_HTTP2_INVALID_SESSION(); - return this.#parser?.goaway(errorCode, lastStreamId, opaqueData); - } setLocalWindowSize(windowSize) { if (this.destroyed) throw $ERR_HTTP2_INVALID_SESSION(); @@ -5702,38 +5616,25 @@ 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++; - if (this.#pendingSettingsAckCount > this.#maxOutstandingSettings) { + if (this.#pendingSettingsCallbacks.length >= this.#maxOutstandingSettings) { this.destroy($ERR_HTTP2_MAX_PENDING_SETTINGS_ACK(), constants.NGHTTP2_INTERNAL_ERROR); 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") { @@ -5772,17 +5673,9 @@ class ClientHttp2Session extends Http2Session { if (options.strictFieldWhitespaceValidation === false) { 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"; @@ -5807,7 +5700,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) { @@ -5815,17 +5710,19 @@ 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, false); + else process.nextTick(() => this.#emitConnect(socket, true)); } // h2 with ALPNProtocols let socket; - if (typeof options?.maxOutstandingSettings === "number" && options.maxOutstandingSettings >= 1) { - this.#maxOutstandingSettings = options.maxOutstandingSettings; + if (typeof options?.maxOutstandingSettings === "number") { + this.#maxOutstandingSettings = Math.max(1, options.maxOutstandingSettings); } let connectOnNextTick = false; if (typeof options?.createConnection === "function") { @@ -5863,22 +5760,27 @@ class ClientHttp2Session extends Http2Session { const nativeSocket = socket._handle; this[kDeferWriteCallback] = deferWriteCallbackForSocket(nativeSocket); - if (options?.settings !== undefined) { - validateSettings(options.settings); + try { + const nativeSettings = nativeSessionSettings(options); + 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 @@ -5886,6 +5788,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. @@ -5901,7 +5804,7 @@ class ClientHttp2Session extends Http2Session { // node submits a graceful GOAWAY as soon as close() is called; the peer observes the shutdown // ('goaway' event) while in-flight streams are still allowed to finish. The session is only // destroyed once there is nothing in flight, and never before the GOAWAY had a chance to leave. - this.goaway(constants.NGHTTP2_NO_ERROR, 0, Buffer.alloc(0)); + this.goaway(); this[kGoawaySent] = true; this.#parser?.flush?.(); // Requests queued while the socket is still connecting count as in-flight too: they are @@ -5915,27 +5818,62 @@ 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.#isClosedAndIdle() || (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.#pendingSettingsCallbacks.length > 0 || (this.#pingCallbacks !== null && this.#pingCallbacks.length > 0)) { + scheduleSettingsAckGraceNT(this); + } else { + captureHttp2PerfFrameSnapshot(this, this.#parser); + setImmediate(destroySessionInternally, 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); + } + } + + // Requests that wait for the connect or for a stream slot never reached the wire. Like Node, cancel them with ERR_HTTP2_STREAM_CANCEL, whose cause is the session's error. + #cancelPendingRequests(error: Error | null | undefined, code: number | undefined) { + const pendingRequests = this.#pendingRequests; + this.#pendingRequests = null; + if (pendingRequests === null || pendingRequests.size === 0) return; + const cancelError = createPendingStreamCancelError(error); + const sessionCode = this[kGoawayCode] || code; + for (const req of pendingRequests) { + if (!req.destroyed) { + // 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); } } } 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. `destroyed` reads this latch. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js (Http2Session#destroy) if (this.#destroying) { return; @@ -5974,46 +5912,14 @@ class ClientHttp2Session extends Http2Session { // Streams torn down by this destroy surface the same session error (node semantics). this[kSessionDestroyError] = error; } - // Like node's finishSessionClose, which only leaves the socket to the peer after close(). - destroysSocket = !!error || !this.#closed; + // Only a teardown the session starts itself ends a graceful close(). A destroy() that the program calls destroys every stream. + const endsGracefulClose = this[kInternalDestroy] === true && this.#isClosedAndIdle(); + const unread = unreadClosedStreams(this.#parser); 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) { - const cancelError = createPendingStreamCancelError(error); - for (let i = 0; i < pendingRequests.length; i++) { - const req = pendingRequests[i].req; - if (!req.destroyed) { - req.rstCode = code !== undefined ? code : constants.NGHTTP2_CANCEL; - 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 (destroysSocket) { - // See the client session: end first, destroy a tick later (node's - // finishSessionClose Windows-ECONNRESET avoidance). - endThenDestroySessionSocket(socket, error ?? undefined); - } 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(); - } + this.#cancelPendingRequests(error, code); + if (socket && (!this[kGoawaySent] || code)) { + // close() already sent GOAWAY(NO_ERROR), so only an error code is worth a second frame. + sendGoaway(this, code || constants.NGHTTP2_NO_ERROR, 0); } const parser = this.#parser; if (parser) { @@ -6025,13 +5931,34 @@ 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(unread, error, endsGracefulClose, !socket || socket.destroyed); + this.#connected = false; + if (socket) { + destroysSocket = !!error || !this.#closeCalled; + if (destroysSocket) { + 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 @@ -6042,6 +5969,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. `pendingSettingsAck` stays as it is. + this.#pendingSettingsCallbacks.fill(null); // 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. @@ -6062,16 +5991,15 @@ class ClientHttp2Session extends Http2Session { } request(headers?: HeadersObject | any[] | null, options?: ClientRequestOptions) { - // Set once a stream id was allocated (streamStart incremented #connections); validation - // throws before that point must not decrement. - let connectionsCounted = false; + // Set once a stream id was allocated. + let allocated = false; 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"); } @@ -6135,8 +6063,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) { @@ -6148,15 +6075,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]; } @@ -6176,7 +6095,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( @@ -6185,7 +6104,6 @@ class ClientHttp2Session extends Http2Session { "DEP0194", ); } - delete options.weight; } const sensitives = headers[sensitiveHeaders]; @@ -6196,33 +6114,10 @@ class ClientHttp2Session extends Http2Session { throw $ERR_INVALID_ARG_VALUE("headers[http2.neverIndex]", sensitives); } const sensitiveNames = buildSensitiveNames(headers, sensitives); - // Validate header names in JS like node's buildNgHeaderString does: request() must throw - // synchronously for an invalid name even while the session is still connecting (the native - // encoder only sees the headers once a queued request is actually submitted). Empty names - // keep going to the native validator, matching its acceptance. - { - const headerNames = ObjectKeys(headers); - for (let i = 0; i < headerNames.length; i++) { - const name = headerNames[i]; - if (name === "") 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. - assertValidPseudoHeader(name); - continue; - } - if (!checkIsHttpToken(name)) { - throw $ERR_INVALID_HTTP_TOKEN("Header name", name); - } - } - } - // 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. @@ -6255,20 +6150,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) { @@ -6286,14 +6169,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; + } } } } @@ -6360,30 +6244,31 @@ class ClientHttp2Session extends Http2Session { // a "pending" stream (no id) and sends its HEADERS frame once the session connects / a slot // frees, keeping stream id allocation in submission order. Deferring until connect is what // lets a stream destroyed before connect never reach the wire. - const maxConcurrentStreams = this.#remoteSettings?.maxConcurrentStreams; - if ( - !this.#connected || - (this.#pendingRequests !== null && this.#pendingRequests.length > 0) || - (typeof maxConcurrentStreams === "number" && this.#activeRequestCount >= maxConcurrentStreams) - ) { + // 0 if the limit leaves no slot. + const stream_id: number = + this.#connected && (this.#pendingRequests === null || this.#pendingRequests.size === 0) + ? this.#parser!.getNextStream() + : 0; + if (stream_id === 0) { + const wireHeaders = rawHeadersList !== null ? rawHeadersList : headers; + // The encoder only sees the headers when the request is submitted, and request() has to throw now. + this.#parser!.validateRequest(wireHeaders); 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, + wireHeaders, 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(); @@ -6392,8 +6277,6 @@ class ClientHttp2Session extends Http2Session { return req; } - connectionsCounted = true; - let stream_id: number = this.#parser!.getNextStream(); if (stream_id < 0) { const req = new ClientHttp2Stream(undefined, this, headers); process.nextTick(emitOutofStreamErrorNT, req); @@ -6406,94 +6289,88 @@ class ClientHttp2Session extends Http2Session { onClientStreamCreatedChannel.publish({ stream: req, headers }); } const wireHeaders = rawHeadersList !== null ? rawHeadersList : headers; + allocated = true; this.#parser!.request(stream_id, req, wireHeaders, sensitiveNames, options); + allocated = false; 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) { - if (connectionsCounted) { - this.#connections--; - process.nextTick(emitErrorNT, this, e, this.#connections === 0 && this.#closed); + // Nothing reached the wire, so the session is still usable. Node only throws here. + if (allocated) { + this.#flushPendingRequests(); + if (this.#isClosedAndIdle()) setImmediate(destroySessionInternally, 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) { - this.#activeRequestCount++; - req.once("close", () => { - this.#activeRequestCount--; - this.#flushPendingRequests(); - }); + // close() or destroy() was called and no stream is open on the wire. + #isClosedAndIdle() { + return this.#closed && !this.#parser?.hasOpenStreams(); } // 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(); - } - 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); + req.rstCode = constants.NGHTTP2_CANCEL; + req.destroy(); 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 }); + const stream_id = parser.getNextStream(); + if (stream_id === 0) break; + 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, stream_id, queued); + } + // A closed session's last queued request can fail above without ever opening a stream. + this.#maybeDestroy(); + } + + #submitQueuedRequest( + parser: $Bindgen.H2FrameParser, + req: ClientHttp2Stream, + stream_id: number, + { headers, wireHeaders, sensitiveNames, options }: NonNullable, + ) { + if (stream_id < 0) { + process.nextTick(emitOutofStreamErrorNT, req); + return; + } + 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); } - this.#trackActiveRequest(req); - process.nextTick(emitEventNT, req, "ready"); + } catch (err) { + // Native rejected the headers and closed the stream, which the peer never heard of. + 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); } @@ -6502,7 +6379,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); } @@ -6523,7 +6400,7 @@ function sessionOnTimeout() { if (this.destroyed || this.closed) return; const server = this[kServer]; if (!server.emit("timeout", this)) { - this.destroy(); + destroySessionInternally(this); } } /** @@ -6539,18 +6416,12 @@ function closeAllSessions(server: Http2Server | Http2SecureServer) { } } -const { - connectionListenerHTTP1, - closeIdleHttp1Connections, - kHttp1Connections, -} = require("internal/http1_server_fallback"); - function connectionListener(socket: TLSSocket | Socket) { const options = this[bunSocketServerOptions] || {}; if (socket.alpnProtocol === false || socket.alpnProtocol === "http/1.1") { if (options.allowHTTP1 === true) { // Fallback to HTTP/1.1 - return connectionListenerHTTP1(this, socket, options); + return require("node:_http_server")._connectionListener.$call(this, socket); } // Let event handler deal with the socket @@ -6687,7 +6558,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 }; @@ -6705,9 +6576,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 }); @@ -6744,18 +6613,33 @@ 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(); +} +const kNumericHTTP1Options = [ + "keepAliveTimeout", + "keepAliveTimeoutBuffer", + "headersTimeout", + "requestTimeout", + "connectionsCheckingInterval", + "maxHeaderSize", +]; 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) { @@ -6770,21 +6654,19 @@ class Http2SecureServer extends (tls.Server as unknown as Http2SecureServerBase) this.setMaxListeners(0); this.on("newListener", setupCompat); if (options.allowHTTP1 === true) { - this[kHttp1Connections] = new SafeSet(); + const { storeHTTPOptions, setupConnectionsTracking } = require("node:_http_server"); const http1Options = { ...options, ...options.http1Options }; - this.keepAliveTimeout = http1Options.keepAliveTimeout ?? 5000; - this.headersTimeout = http1Options.headersTimeout ?? 60000; - this.requestTimeout = http1Options.requestTimeout ?? 300000; + // Bun 1.4 took a numeric string (an environment variable) for these, and any number for the flag. + for (const name of kNumericHTTP1Options) { + const value = http1Options[name]; + if (typeof value === "string" && value.trim() !== "" && Number.isFinite(+value)) http1Options[name] = +value; + } + const { insecureHTTPParser } = http1Options; + if (typeof insecureHTTPParser === "number") http1Options.insecureHTTPParser = insecureHTTPParser !== 0; + storeHTTPOptions.$call(this, http1Options); this.maxHeadersCount = http1Options.maxHeadersCount ?? null; this.maxRequestsPerSocket = http1Options.maxRequestsPerSocket ?? 0; - const joinDuplicateHeaders = http1Options.joinDuplicateHeaders; - if (joinDuplicateHeaders !== undefined) validateBoolean(joinDuplicateHeaders, "options.joinDuplicateHeaders"); - this.joinDuplicateHeaders = joinDuplicateHeaders; - // connectionListenerHTTP1 reads these off the server when initializing - // the per-connection parser, matching Node's storeHTTPOptions. - this.maxHeaderSize = http1Options.maxHeaderSize; - this.insecureHTTPParser = http1Options.insecureHTTPParser; - this.httpValidation = http1Options.httpValidation; + this.on("listening", setupConnectionsTracking); } if (typeof onRequestHandler === "function") { this.on("request", onRequestHandler); @@ -6802,7 +6684,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 }; @@ -6812,11 +6694,15 @@ 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) { + require("node:_http_server").httpServerPreClose(this); + } closeAllSessions(this); } closeIdleConnections() { - if (this[bunSocketServerOptions]?.allowHTTP1 === true) closeIdleHttp1Connections(this); + if (this[bunSocketServerOptions]?.allowHTTP1 === true) { + require("node:_http_server").Server.prototype.closeIdleConnections.$call(this); + } } } function createServer(options, onRequestHandler) { diff --git a/src/js/node/https.ts b/src/js/node/https.ts index 9c379ae28754..ef194fc350aa 100644 --- a/src/js/node/https.ts +++ b/src/js/node/https.ts @@ -11,8 +11,16 @@ const { checkShouldUseProxy, kWaitForProxyTunnel, kPerRequestCheckServerIdentity, + getMaxHTTPHeaderSize, isTlsSymbol, + httpServerParts, + serverSymbol, + tlsSymbol, } = require("internal/http"); +const { kSNIContexts } = require("internal/net/symbols"); +const buntls = Symbol.for("::buntls::"); +const setListenerSecureContext = $bindgen("Listener.bind.ts", "setSecureContext"); +const addServerName = $bindgen("Listener.bind.ts", "addServerName"); const { validateHeaderValue } = require("node:_http_common"); const { isAnyArrayBuffer, isDataView } = require("node:util/types"); @@ -20,6 +28,9 @@ const ArrayPrototypeShift = Array.prototype.shift; const ArrayPrototypeJoin = Array.prototype.join; const ArrayPrototypeMap = Array.prototype.map; const ObjectAssign = Object.assign; +const ObjectDefineProperties = Object.defineProperties; +const ObjectSetPrototypeOf = Object.setPrototypeOf; +const ReflectDefineProperty = Reflect.defineProperty; const ArrayPrototypeUnshift = Array.prototype.unshift; const JSONStringify = JSON.stringify; const WeakMapPrototypeGet = WeakMap.prototype.get; @@ -106,6 +117,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, @@ -120,8 +132,8 @@ function getTunnelConfigForProxiedHttps(agent, reqOptions) { return result; } -function establishTunnel(agent, socket, options, tunnelConfig, afterSocket) { - const { proxyTunnelPayload } = tunnelConfig; +function establishTunnel(agent, socket, tunnelConfig, afterSocket) { + 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 +170,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`); @@ -340,7 +358,7 @@ function createConnection(...args) { }); function onProxyConnection() { socket.removeListener("error", onError); - establishTunnel(agent, socket, options, tunnelConfig, cleanupAndPropagate); + establishTunnel(agent, socket, tunnelConfig, cleanupAndPropagate); } if (this[kProxyConfig].protocol === "http:") { socket = require("node:net").connect(connectOptions, onProxyConnection); @@ -583,6 +601,7 @@ const { shouldUseEnvProxy } = require("node:_http_agent"); // https://github.com/nodejs/node/blob/v26.3.0/lib/https.js#L82-L97 function Server(options, requestListener): void { if (!(this instanceof Server)) return new Server(options, requestListener); + if (!serverClassLinked) linkServerClass(); if (typeof options === "function") { requestListener = options; options = {}; @@ -590,14 +609,15 @@ function Server(options, requestListener): void { options = {}; } else { validateObject(options, "options"); - options = { ...options }; + // tls.Server gets the caller's own enumerable options, as in Node. What Node reads by name is still found on the caller's object. + options = { __proto__: options, ...options }; } if (!options.ALPNProtocols && !options.ALPNCallback) { // http/1.0 is not defined as a Protocol ID in the IANA registry, so // ALPN requests are always answered with http/1.1. options.ALPNProtocols = ["http/1.1"]; } - http.Server.$call(this, options, requestListener); + httpServerParts.initHttpServer!.$call(this, options, requestListener); const optionsALPNProtocols = options.ALPNProtocols; if (optionsALPNProtocols) { require("node:tls").convertALPNProtocols(optionsALPNProtocols, this); @@ -612,10 +632,50 @@ function Server(options, requestListener): void { } return this; } -$toClass(Server, "Server", http.Server); +// https://github.com/nodejs/node/blob/v26.3.0/lib/https.js#L115-L122, with the tls.Server link deferred to first use. +$toClass(Server, "Server", require("node:events")); +ObjectDefineProperties(Server.prototype, httpServerParts.serverPrototypeDescriptors!); // A listener of this class speaks TLS whatever its options hold. Server.prototype[isTlsSymbol] = true; +let serverClassLinked = false; +function linkServerClass() { + const TLSServer = require("node:tls").Server; + ObjectSetPrototypeOf(Server.prototype, TLSServer.prototype); + ObjectSetPrototypeOf(Server, TLSServer); + serverClassLinked = true; +} + +// Replaces the accessor with a data property. On a frozen `exports` defineProperty returns false and the accessor stays. +function materializeServerExport(value) { + ReflectDefineProperty(https, "Server", { value, writable: true, configurable: true }); + return value; +} + +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]; + // Inherits, because tls.Server reads each option by name: one that is inherited, a getter or non-enumerable counts, as in Node. + tlsServer.setSecureContext({ __proto__: options, ...require("internal/tls").ignoredByHttpsServer }); + const handle = this[serverSymbol]; + if (handle) setListenerSecureContext(handle, tlsServer[buntls](null)[0].secureContext); +}; + +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); } @@ -628,7 +688,13 @@ var https = { timeout: 5000, proxyEnv: shouldUseEnvProxy() ? process.env : undefined, }), - Server, + get Server() { + if (!serverClassLinked) linkServerClass(); + return materializeServerExport(Server); + }, + set Server(value) { + materializeServerExport(value); + }, createServer, get, request, diff --git a/src/js/node/net.ts b/src/js/node/net.ts index ffdb7437da6f..338118ded6c3 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"); @@ -43,20 +45,26 @@ 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, + kForeignListener, + kHandleClosed, kPreHandshakeWrite, + kReadStop, kSecureConnectDone, + kSNIError, kStandaloneWrap, kUpgradeClientTLS, kVerifyError, } = require("internal/net/symbols"); const { addServerAbortSignalOption } = require("internal/net/server_abort_signal"); +const AsyncContextFrame = require("internal/async_context_frame"); -type SocketHandle = Socket & { +type SocketHandle = Socket & { setTypeOfService?(tos: number): number | undefined; + readonly listener?: ListenerHandle; }; -type AcceptedSocketHandle = Socket & { readonly listener?: ListenerHandle }; type NativeSocketError = Error & { errno?: number }; type NativeConnectError = Error & { errno: number }; type ConnectException = InstanceType & { localAddress?: string; localPort?: number }; @@ -67,9 +75,9 @@ interface ListenerHandle extends SocketListener { interface SocketInternals { bytesRead: number; + _bytesDispatched: number; connecting: boolean; isServer: boolean; - data?: null; server: ServerInstance | null | undefined; _server: ServerInstance | undefined; _handle: SocketHandle | null; @@ -140,23 +148,10 @@ interface ConnectReq { errno?: number; } -interface ConnectData { - self: Self; - req: ConnectReq | undefined; -} - -interface PendingConnectReq extends ConnectReq { - oncomplete: NonNullable; -} - -interface PendingConnectData extends ConnectData { - get req(): PendingConnectReq; - set req(value: ConnectReq | undefined); -} - interface SNIState { server: ServerInstance | TLSSocketInstance; socketHandle: Socket | undefined; + socket: SocketInstance | undefined; selected: unknown; failed: Error | undefined; settled: boolean; @@ -175,11 +170,9 @@ interface TLSConnectOptions { } interface InternalSocketHandler extends SocketHandler { + writeComplete?(socket: Socket, status: number): void; session?(socket: Socket, session: Buffer): void; keylog?(socket: Socket, line: Buffer): void; -} - -interface InternalServerHandler extends InternalSocketHandler { alpnCallback?( socket: Socket, servername: string | undefined, @@ -189,6 +182,7 @@ interface InternalServerHandler extends InternalSocketHandler { server: ServerInstance | TLSSocketInstance, servername: string, socketHandle: Socket | undefined, + socket?: Data, ): unknown; } @@ -205,16 +199,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, @@ -279,7 +288,7 @@ const checkServerIdentityInHandshake = $bindgen("socket_body.bind.ts", "checkSer // destroy() in the handshake callback: drops the handshake flight that the native layer holds. 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::"); @@ -287,7 +296,7 @@ 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 bunTLSConnectOptions = Symbol.for("::buntlsconnectoptions::"); // tls.Server exposes its native SecureContext constructor through this key so @@ -301,7 +310,6 @@ const kRealListen = Symbol("kRealListen"); const kSetNoDelay = Symbol("kSetNoDelay"); const kSetTOS = Symbol("kSetTOS"); const kSetKeepAlive = Symbol("kSetKeepAlive"); -const kSyncWriteFd = Symbol("kSyncWriteFd"); const kSetKeepAliveInitialDelay = Symbol("kSetKeepAliveInitialDelay"); const kConnectOptions = Symbol("connect-options"); const kAttach = Symbol("kAttach"); @@ -318,10 +326,12 @@ const kReadError = Symbol("kReadError"); const kSessionAccepted = Symbol("kSessionAccepted"); const ksocket = Symbol("ksocket"); const khandlers = Symbol("khandlers"); +const kConnectReq = Symbol("kConnectReq"); const kclosed = Symbol("closed"); const kended = Symbol("ended"); +// The handle closed behind the peer's EOF: the peer hung up, and the loop closed the descriptor. +const kHungUp = Symbol("kHungUp"); const kpendingSession = Symbol("pendingSession"); -const kSNIError = Symbol("kSNIError"); const kALPNError = Symbol("kALPNError"); const kPerfHooksNetConnectContext = Symbol("kPerfHooksNetConnectContext"); const khandshakeTimer = Symbol("khandshakeTimer"); @@ -339,9 +349,6 @@ function onUpgradeAttachedWrite(chunk, encoding, callback, onClose) { } function onUpgradeWriteClose(callback) { - // ServerHandlers.error may have already failed this write. - if (this[kwriteCallback] !== callback) return; - this[kwriteCallback] = null; callback($ERR_SOCKET_CLOSED()); } const kUpgradeAttached = Symbol("kUpgradeAttached"); @@ -351,95 +358,33 @@ const kOnreadBuffer = Symbol("kOnreadBuffer"); const kOnreadPendingEnd = Symbol("kOnreadPendingEnd"); const kOnreadReadRequested = Symbol("kOnreadReadRequested"); const kOnreadEmptyTail = Buffer.alloc(0); -const kwriteCallback = Symbol("writeCallback"); -const kLastWriteQueueSize = Symbol("lastWriteQueueSize"); -const kRejectedWrite = Symbol("kRejectedWrite"); +// The write the handle queued, like Node's WriteWrap. A member named `handle` here would be one more callee of every untyped `x.handle()` in a compiled program. +const kWriteReq = Symbol("kWriteReq"); +// How many bytes the kernel had taken when the timer last found a write queue that moved. +const kLastBytesSent = Symbol("kLastBytesSent"); const kSocketClass = Symbol("kSocketClass"); +const kServerSocket = Symbol("kServerSocket"); -// A completed write whose status is a negative errno: Node hands it to the write -// callback as errnoException(status, 'write') and destroys the stream when no -// callback is pending. https://github.com/nodejs/node/blob/v26.3.0/lib/internal/stream_base_commons.js#L81-L92 -function failWrite(self, negErrno, callback) { - failWriteWith(self, writeErrnoException(negErrno), callback); -} -// The TLS engine ended the write side only. It reads what the peer sent, then closes, and the write fails with that close. -function holdRejectedWrite(self, handle, negErrno, callback) { - if (!self.encrypted || self[kclosed]) return false; - if (self[kupgraded] && !(self[kupgraded] instanceof Socket)) return false; - self._pendingData = null; - self[kwriteCallback] = callback; - self[kRejectedWrite] = writeErrnoException(negErrno); - // It holds the loop as any pending write does (_write). - if ((self[kended] || self[kPausedUnref]) && !self[kUserUnrefed]) handle.ref?.(); - if (self[kPausedUnref]) setImmediate(failRejectedWriteOfStoppedReader, self); - return true; -} -// A reader that stopped may be waiting for this very write, while the engine waits for it to read. It has one turn of the -// loop to go on, which is when Node reports a failed write. -function failRejectedWriteOfStoppedReader(self) { - if (self[kPausedUnref]) failRejectedWrite(self); -} -function failRejectedWrite(self) { - const er = self[kRejectedWrite]; - if (er === undefined) return false; - self[kRejectedWrite] = undefined; - failWriteWith(self, er, self[kwriteCallback]); - return true; -} -function failWriteWith(self, er, callback) { - self._pendingData = null; - self[kwriteCallback] = null; - if (callback) { - // Node delivers a failed write to BOTH the write callback and the socket: - // onWriteComplete calls errorOrDestroy(self, ...) in addition to the - // chained callback (lib/internal/stream_base_commons.js), so 'error' and - // 'close' still fire even when every write had a callback. Only handing - // the error to a callback that ignores it left the socket alive forever - // (test-net-stream's server never observed its peer vanish). - callback(er); - if (!self.destroyed && !self._hadError) { - // _hadError is the cross-path once-guard: the native error dispatch - // (SocketHandlers.error) can deliver the same failure, and node emits - // a socket error exactly once. - self._hadError = true; - self.destroy(er); - } +// https://github.com/nodejs/node/blob/v26.5.0/lib/internal/stream_base_commons.js#L81-L107 +function onWriteComplete(self, handle, status) { + const req = self[kWriteReq]; + // connect() settled the write of the handle it replaced. + if (req?.writeHandle !== handle) return; + self[kWriteReq] = undefined; + let err: Error | null = null; + if (status < 0) { + // https://github.com/nodejs/node/blob/v26.5.0/src/crypto/crypto_tls.cc#L1318 + const reason = self.encrypted && status === uv().UV_ECANCELED ? "Canceled because of SSL destruction" : undefined; + err = new ErrnoException(status, "write", reason); } else if (!self.destroyed) { - if (self.listenerCount("error") > 0) { - // The consumer can detach its listener between now and destroy()'s - // deferred 'error' emission - the same last-resort guard - // SocketEmitEndNT uses for read errors. - self.once("error", () => {}); - self.destroy(er); - } else { - // No write callback and no 'error' listener: a failed flush on an - // orphaned socket (an h2 teardown racing the peer's reset - routine on - // Windows, where the reset completes the send first) is teardown noise. - // Same silent-close policy as SocketEmitEndNT's no-listener case. - self.destroy(); - } + self._unrefTimer(); + self[kLastBytesSent] = 0; + unrefAfterDrain(self, handle); } -} -// 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) { - const callback = self[kwriteCallback]; - if (!callback || !self[kclosed]) return; - self._pendingData = self[kwriteCallback] = null; - const reason = self.encrypted ? "Canceled because of SSL destruction" : undefined; - callback(new ErrnoException(uv().UV_ECANCELED, "write", reason)); -} -// Node's errnoException(status, 'write') for the negative errno $write returns. -function writeErrnoException(negErrno) { - let er = new ErrnoException(negErrno, "write") as Error & { code?: string; errno?: number; syscall?: string }; - if (typeof er.code !== "string" || !/^E[A-Z0-9]+$/.test(er.code)) { - // A raw WSA value the errno table cannot name (Windows delivers fatal - // send errors this way): shape it like SocketEmitEndNT shapes reads, - // keeping the original errno. - er = new ConnResetException("write ECONNRESET") as Error & { code: string; errno?: number; syscall?: string }; - er.errno = negErrno; - er.syscall = "write"; - } - return er; + // Node's WriteWrap is an AsyncResource that write() creates. + if (err && endsWithoutError(self)) + process.nextTick(AsyncContextFrame.run, req.writeFrame, req.writeCallback, undefined, err); + else AsyncContextFrame.run(req.writeFrame, req.writeCallback, undefined, err); } function endNT(self, socket, callback) { // Node's _final half-closes the writable side (sends FIN) and leaves the @@ -459,9 +404,19 @@ 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); +} +// `bytesRead` and `bytesWritten` stay readable after the socket let go of its handle. function detachSocket(self) { - if (!self) self = this; + const handle = self._handle; + if (!handle) return; + self[kBytesRead] = handleBytesRead(self, handle); + self[kBytesWritten] = handle.bytesWritten; + handle.onread = noop; self._handle = null; + self._sockname = null; } function destroyNT(self, err) { self.destroy(err); @@ -491,9 +446,17 @@ function onUpgradedError(self, err) { // TLSWrap owns the reads of the handle it wraps: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L723-L727 function onUpgradedReadError(self, err) { self._closeAfterHandlingError = true; - SocketEmitEndNT(self, err); + onReadEnd(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; @@ -501,11 +464,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"); } @@ -517,8 +480,7 @@ 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(); @@ -535,7 +497,7 @@ function adoptTLSPair(self, connection, pair) { function attachClientTLS(self, connection, tls, upgradeDuplex) { const handle = connection._handle; if (self.destroyed || (!upgradeDuplex && !handle)) return; - const options = { data: { self, req: {} }, tls, socket: self[khandlers], isServer: false }; + const options = { data: self, tls, socket: self[khandlers], isServer: false }; try { if (upgradeDuplex || isNamedPipeSocket(handle) || hasUnflushedWrites(connection)) { attachTLSEngine(self, connection, options); @@ -687,6 +649,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 @@ -737,7 +701,7 @@ function onClientHandshake(self, socket, success, verifyError) { // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1697-L1698 self.secureConnecting = false; // pauseOnConnect stopped the handle as the handshake completed, whatever resume() did before that. - if (!self.isPaused()) self.read(0); + if (!self.isPaused()) readStart(self, socket); self.emit(kSecureConnectDone); self.emit("secureConnect", verifyError); const pendingSession = self[kpendingSession]; @@ -796,176 +760,12 @@ function establishedTLSError(self, error) { } } -// Node reports a throwing 'data' listener as uncaughtException and keeps reading. -function pushDataToSocket(self, socket, buffer) { - // TLS took over the fd; the wrapped socket reads nothing: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L723-L727 - if (socket[kAdoptedTLSRaw]) return; - if (self[kDestroyOnRead]) { - $debug("DATA on a socket that must receive nothing - destroying it"); - self.destroy(); - return; - } - let full; - try { - full = self.push(buffer) === false; - } catch (e) { - reportError(e); - return; - } - if (full) readStop(self, socket); -} - -const SocketHandlers = { - close(socket, err) { - const self = socket.data; - if (!self || self[kclosed]) return; - self[kclosed] = true; - //socket cannot be used after close - detachSocket(self); - if (!closeWithTLSSocket(self, socket)) SocketEmitEndNT(self, err); - self.data = null; - }, - data(socket, buffer) { - const { data: self } = socket; - 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. - drain(socket, rejected?: number) { - const self = socket.data; - if (!self) return; - const callback = self[kwriteCallback]; - self.connecting = false; - if (callback && self[kRejectedWrite] === undefined) { - const writeChunk = self._pendingData; - 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. - if (!holdRejectedWrite(self, socket, res, callback)) failWrite(self, res, callback); - } else if (res) { - self._pendingData = self[kwriteCallback] = null; - self._unrefTimer(); - self[kLastWriteQueueSize] = 0; - unrefAfterDrain(self, socket); - callback(null); - } else { - self._pendingData = null; - } - - self[kBytesWritten] = socket.bytesWritten; - } - }, - end(socket) { - const self = socket.data; - if (!self) return; - - // we just reuse the same code but we can push null or enqueue right away - SocketEmitEndNT(self); - }, - // A new resumable TLS session arrived (the peer's NewSessionTicket was just - // processed). Mirrors Node's onnewsessionclient: emit once the handshake has - // been verified, otherwise park it and emit from the handshake handler. - session(socket: Socket, session) { - const self = socket.data; - if (!self) return; - if (self._secureEstablished) { - self.emit("session", session); - } else { - self[kpendingSession] = session; - } - }, - keylog(socket, line) { - const self = socket.data; - if (!self) return; - self.emit("keylog", line); - if (tlsKeylogPath !== undefined) appendTlsKeylog(tlsKeylogPath, line); - self.server?.emit?.("keylog", line, self); - }, - error(socket, error) { - const self = socket.data; - if (!self) return; - if (self._hadError) return; - self._hadError = true; - - const callback = self[kwriteCallback]; - if (callback) { - self[kwriteCallback] = null; - callback(error); - } - - self.emit("error", error); - }, - open(socket) { - const self = socket.data; - if (!self) return; - // make sure to disable timeout on usocket and handle on TS side - socket.timeout(0); - const selfTimeout = self.timeout; - if (selfTimeout) { - self.setTimeout(selfTimeout); - } - self._handle = socket; - self.connecting = false; - const options = self[bunTLSConnectOptions]; - - if (options) { - const { session } = options; - if (session) { - (self as TLSSocketInstance).setSession(session); - } - } - startNameCheckInHandshake(self, socket); - - if (self[kSetNoDelay]) { - socket.setNoDelay(true); - } - - if (self[kSetKeepAlive]) { - socket.setKeepAlive(true, self[kSetKeepAliveInitialDelay]); - } - - // A TOS value set before the connection existed (setTypeOfService before - // connect) is applied to the live handle now. - let handle; - if (self[kSetTOS] !== undefined && (handle = self._handle)?.setTypeOfService) { - handle.setTypeOfService(self[kSetTOS]); - } - - 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"); - } - - SocketHandlers.drain(socket); - }, - handshake(socket: Socket, success, verifyError) { - const { data: self } = socket; - if (!self) return; - onClientHandshake(self, socket, success, verifyError); - }, - timeout(socket) { - const self = socket.data; - if (!self) return; - - self.emit("timeout", self); - }, - binaryType: "buffer", -} as const satisfies InternalSocketHandler; - -// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/stream_base_commons.js#L191-L198; a stopped handle does not hold the loop, a pending write still does. function readStop(self, handle) { handle?.pause?.(); // A socket over a generic duplex has no fd and never held the loop. if (self[kupgraded] && !(self[kupgraded] instanceof Socket)) return; self[kPausedUnref] = true; - if (self[kRejectedWrite] !== undefined) setImmediate(failRejectedWriteOfStoppedReader, self); - if (!self[kwriteCallback]) handle?.unref?.(); + if (!self[kWriteReq]) handle?.unref?.(); } // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L494-L498 @@ -974,9 +774,9 @@ function pauseOnCreate(self, handle) { self.readableFlowing = false; } -// Reads are flowing again: give back the hold readStop dropped. Cleared even when -// the user unref'd, so a later ref() is not undone by unrefAfterDrain. -function restorePausedHold(self, handle) { +// Gives back the hold readStop dropped. Cleared even when the user unref'd, so a later ref() is not undone by unrefAfterDrain. +function readStart(self, handle) { + handle?.resume?.(); if (!self[kPausedUnref]) return; self[kPausedUnref] = false; if (!self[kUserUnrefed]) handle?.ref?.(); @@ -989,7 +789,8 @@ function unrefAfterDrain(self, handle) { } 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); @@ -1001,103 +802,64 @@ function finishSocketEnd(self) { // forget any readStop-time unref so a later read()/resume() does not pin the loop again. // A subsequent buffered write re-refs (see _write) so its callback can still fire. const socket = self._handle; - if (socket && !self[kwriteCallback]) { + // No _read() follows the end, and a TLS handle that backpressure stopped still has the peer's FIN to read. + socket?.resume?.(); + if (socket && !self[kWriteReq]) { socket.unref?.(); self[kPausedUnref] = false; } } +// A socket that node:http handed to 'upgrade' or 'connect' ends and closes when its connection fails, and emits no 'error' for it. The failed write's callback follows 'end'. +function endsWithoutError(self) { + if (self[kHandleClosed] === undefined) return false; + finishSocketEnd(self); + self.destroy(); + return true; +} + function deferEndForOnreadTail(self) { if (self[kOnreadTail] === undefined || self.destroyed) return false; self[kOnreadPendingEnd] = true; return true; } -function SocketEmitEndNT(self, _err?) { - if (self[kclosed] && failRejectedWrite(self)) return; - // A read error delivered with the close (e.g. a received RST surfacing as - // ECONNRESET) is not a clean EOF — Node destroys the socket with the error - // ("read ECONNRESET") instead of emitting a graceful 'end'. Guard on - // !destroyed so an already-torn-down socket isn't re-destroyed, and on an - // 'error' listener so callers that opted into error handling get Node's - // behavior while those that did not keep the previous silent EOF (a server - // hard-closing after a clean response would otherwise surface here as an - // unhandled error across the proxy/http2/fetch suites under ASAN/baseline - // timing). - // A reset that lands after the exchange already finished in BOTH - // directions (clean EOF delivered and nothing left being written) is - // teardown noise - a peer hard-closing once the exchange completed - not - // data loss; Node would have destroyed the socket on 'end' for these - // non-keepalive flows before the RST could ever be observed. Surfacing it - // produced unhandled errors between tests across the fetch/http2 suites on - // Windows, where loopback RSTs at teardown are routine. A reset while the - // socket is still writing (the peer aborted mid-transfer) is real and is - // surfaced (test-net-error-twice). - // writableFinished (everything actually flushed) - NOT writableEnded (end() - // merely called): a peer reset while queued data is still unflushed is the - // peer aborting mid-transfer and must surface (test-net-error-twice). +// EOF, or the read error the handle closed with: https://github.com/nodejs/node/blob/v26.5.0/lib/internal/stream_base_commons.js#L207-L234 +function onReadEnd(self, err?) { + if (self.destroyed || (err && endsWithoutError(self))) return; + // A reset after both directions finished. Node stops reading at EOF, so it never sees one. 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; + // Without a listener a peer's reset would be an uncaught exception, so the socket closes quietly. A write in flight fails it anyway. + const reportsReadError = hasErrorListener || (self[kWriteReq] !== undefined && !self[kended]); + if (err && !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 - // callback and destroy()'s deferred 'error' emission (a request that - // 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", () => {}); - let errErrno; - if (_err.code === undefined && typeof (errErrno = _err.errno) === "number" && errErrno !== 0) { - // A codeless close error that still carries the errno (Windows IOCP - // delivers some this way): derive the proper code from it, like Node's - // errnoException(nread, 'read'). Raw WSA values (-10054, ...) that the - // errno table cannot name fall through to the reset shape below instead - // of surfacing "Unknown system error N". - const er = new ErrnoException(errErrno, "read") as Error & { code?: string }; - if (typeof er.code === "string" && /^E[A-Z0-9]+$/.test(er.code)) { - self.destroy(er); - return; - } - } - if (_err.code === undefined || _err.code === "ECONNRESET") { - // Shape a reset (or a fully bare close error) like Node's - // errnoException(UV_ECONNRESET, 'read'). - const er = new ConnResetException("read ECONNRESET") as Error & { - code: string; - errno?: number; - syscall?: string; - }; - er.errno = _err.errno ?? (process.platform === "win32" ? -4077 : process.platform === "linux" ? -104 : -54); - er.syscall = "read"; - self.destroy(er); - } else { - // Any other coded error (ETIMEDOUT, EPIPE, ...) keeps its identity. - self.destroy(_err); - } - return; + if (self.emit(kReadError, err)) return void self.destroy(); + // The listener can be gone by the tick that emits 'error' (a request that finished as the reset arrived). + if (hasErrorListener) self.once("error", noop); + return void self.destroy(new ErrnoException(err.errno, err.syscall)); } if (!self[kended]) { - finishSocketEnd(self); - } else if (_err && !self.destroyed && !(teardownNoise && !self.readableEnded)) { - // An error excluded from the synthesis above (teardown noise, or no - // listener attached): nothing more is coming, but the socket still has to - // finish its lifecycle - close it quietly instead of leaving it open with - // no further events. What a finished exchange left unread is still the - // reader's: its 'end' closes the socket. + if (!deferEndForOnreadTail(self)) finishSocketEnd(self); + } else if (err && !(teardownNoise && !self.readableEnded)) { + // What a finished exchange left unread is still the reader's: its 'end' closes the socket. self.destroy(); } - // 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()); - } +} + +function onHandleClose(self, handle, err) { + if (self[kclosed]) return; + self[kclosed] = true; + self[kHungUp] = self[kended]; + if (closeWithTLSSocket(self, handle)) return; + // A connection attempt that lost the race and a handle that a TLS upgrade replaced end quietly. + if (handle !== self._handle) err = undefined; + onReadEnd(self, err); + self[kHandleClosed]?.(); + // Node destroys the socket with this error. Where it isn't reported, a half-open socket ends its writable side at 'end' like any other. + if (!err || !self.allowHalfOpen || self.destroyed) return; + if (self.readableEnded) self.end(); + else self.once("end", self.end); } // --- SNICallback dispatch helpers (hoisted: no per-handshake closures) --- @@ -1111,7 +873,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) { @@ -1119,23 +881,22 @@ 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"); } } -// Stash per-connection (socketHandle.data is this connection's TLSSocket): +// Stash per-connection (state.socket is this connection's TLSSocket): // with concurrent handshakes a per-server stash could hand one connection's // error to another's failure handler. The server is the legacy fallback when // no handle was available at dispatch time. function stashSNIError(state) { - const target = state.socketHandle?.data ?? state.server; + const target = state.socket ?? state.server; if (target) target[kSNIError] = state.failed; } @@ -1155,18 +916,48 @@ function onSNIResolution(state, err, context) { } } -const ServerHandlers = { +// `socket.data` is the net.Socket that owns the handle. Until 'open' hands an accepted handle to one, it's the net.Server. +const SocketHandlers = { data(socket, buffer) { - const { data: self } = socket; - if (!self) return; - + const self = socket.data; self._unrefTimer(); - self.bytesRead += buffer.length; - pushDataToSocket(self, socket, buffer); + // TLS took over the fd; the wrapped socket reads nothing: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L723-L727 + if (socket[kAdoptedTLSRaw]) return; + if (self[kDestroyOnRead]) { + $debug("DATA on a socket that must receive nothing - destroying it"); + self.destroy(); + return; + } + let full; + try { + full = self.push(buffer) === false; + } catch (e) { + // Node reports a throwing 'data' listener as uncaughtException and keeps reading. + reportError(e); + return; + } + if (full) readStop(self, socket); + }, + writeComplete(socket, status: number) { + onWriteComplete(socket.data, socket, status); + }, + end(socket) { + onReadEnd(socket.data); + }, + close(socket, err?: NativeSocketError) { + onHandleClose(socket.data, socket, err); + }, + // Node's onnewsessionclient: emit once the handshake is verified. Until then, the handshake handler emits it. + session(socket: Socket, session) { + const self = socket.data; + if (self._secureEstablished) { + self.emit("session", session); + } else { + self[kpendingSession] = session; + } }, keylog(socket, line) { - const { data: self } = socket; - if (!self) return; + const self = socket.data; self.emit("keylog", line); if (tlsKeylogPath !== undefined) appendTlsKeylog(tlsKeylogPath, line); self.server?.emit?.("keylog", line, self); @@ -1207,7 +998,7 @@ const ServerHandlers = { } return result; }, - serverName(server, servername, socketHandle) { + serverName(server, servername, socketHandle, socket = socketHandle?.data) { // Returns what the SNICallback selects for this handshake: // - the native SecureContext (synchronous selection) // - undefined to fall through to the default context @@ -1218,19 +1009,21 @@ 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 = { server, socketHandle, + socket, selected: undefined, failed: undefined, settled: false, 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(socket, servername, onSNIResolution.bind(null, state)); } catch (err) { state.settled = true; state.failed = toSNIError(err); @@ -1254,54 +1047,54 @@ const ServerHandlers = { } return state.selected; }, - close(socket, err) { - $debug("Bun.Server close"); - const data = this.data; - if (!data) return; - - { - if (!data[kclosed]) { - data[kclosed] = true; - //socket cannot be used after close - detachSocket(data); - if (!closeWithTLSSocket(data, socket)) SocketEmitEndNT(data, err); - data.data = null; - socket[owner_symbol] = null; - } - } - }, - end(socket) { - SocketHandlers.end(socket); - }, - open(socket: AcceptedSocketHandle) { - $debug("Bun.Server open"); - const self = socket.data as any as ServerInstance; - if (!self) return; + open(socket: SocketHandle) { + const self = socket.data; // Dispatch through the listener handle's onconnection hook so user code // (and node:cluster RoundRobinHandle) can intercept accepted sockets the // same way Node.js exposes TCP/Pipe wrap onconnection. - // For a standalone server-side wrap (new TLSSocket(duplex, { isServer })), - // `self` is the wrapping socket - not a Server - and its handle has no - // onconnection; throwing here would tear the brand-new TLS engine down - // before the ClientHello ever arrives. - const handle = self._handle || socket.listener; - if (handle && typeof handle.onconnection === "function") { - handle.onconnection(0, socket); + const listener = (self as any as ServerInstance)._handle || socket.listener; + if (listener && typeof listener.onconnection === "function") { + listener.onconnection(0, socket); + return; } + socket[owner_symbol] = self; + // Ahead of `oncomplete`: a 'connect' listener that writes sends the ClientHello, which has to offer the session. + const session = self[bunTLSConnectOptions]?.session; + // On the native socket: an fd upgrade assigns `_handle` after `open`. + if (session) socket.setSession(typeof session === "string" ? Buffer.from(session, "latin1") : session); + startNameCheckInHandshake(self, socket); + const req = socket[kConnectReq]; + socket[kConnectReq] = undefined; + // A TLS session over a transport the caller handed in has no request. An attempt that timed out has no `oncomplete`. + req?.oncomplete?.(0, self._handle, req, true, true); + }, + connectError(socket: SocketHandle, error: NativeConnectError) { + const self = socket.data; + const req = socket[kConnectReq]; + socket[owner_symbol] = self; + // connect() or bind() failed inside doConnect(): kConnectDispatch returns the errno, as Node's handle.connect() does. + if (req?.dispatching) { + req.errno = error.errno || uv().UV_ECANCELED; + return; + } + socket[kConnectReq] = undefined; + // A TLS engine that failed before it opened has no connect pending: afterConnect would drop the failure. + if (self[kupgraded]) { + if (!self.destroyed) self.destroy(new ErrnoException(error.errno, "connect")); + return; + } + // Not deferred like libuv's ECANCELED of a closed handle: it would fail a connect() made right after destroy(). + req?.oncomplete?.(error.errno, self._handle, req, true, true); }, handshake(socket: Socket, success, verifyError) { const self = socket.data; + if (!self.isServer) return onClientHandshake(self, socket, success, verifyError); // `server` is null for a standalone `new tls.TLSSocket(socket, { isServer: true })` // (no listening server owns it) — guard every server.emit / server option read. const server = self.server; if (self[khandshakeTimer]) { clearTimeout(self[khandshakeTimer]); self[khandshakeTimer] = undefined; - // node's _finishInit retires the handler with setTimeout(0, - // _handleTimeout); leaving it attached would turn a later idle timeout - // on an established connection into ERR_TLS_HANDSHAKE_TIMEOUT. - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1105-L1106 - self.removeListener("timeout", onHandshakeTimeout); } // On the server side the second argument is the raw handshake result // (client-certificate verification is reported separately through @@ -1387,6 +1180,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); @@ -1420,74 +1221,38 @@ const ServerHandlers = { reportError(err); } }, - error(socket, error) { - const data = this.data; - if (!data) return; - - if (data._hadError) return; - data._hadError = true; - const bunTLS = data[bunTlsSymbol]; - - if (typeof bunTLS === "function") { - const tlsError = establishedTLSError(data, error); - if (tlsError) error = tlsError; - const callback = data[kwriteCallback]; - if (callback) { - data[kwriteCallback] = null; - callback(error); - } - // Mirrors node's onerror: - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L467-L498 - if (!data._secureEstablished) { - // Destroy the socket if the error happened before the handshake - // finished, keeping it reachable while 'tlsClientError' is emitted - // through the '_tlsError' hop. - data._closeAfterHandlingError = true; - data.destroy(error); - } else if ( - data.isServer && - data._rejectUnauthorized && - /peer did not return a certificate/.test(error?.message) - ) { - // Ignore server's authorization errors - data.destroy(); - } else if (tlsError && !callback) { - data._emitTLSError(error); - } else { - // Node emits through _emitTLSError and leaves the socket alive. Bun - // still destroys here: its tls.Server completes the handshake for a - // peer that already reset, and leaving that connection alive turns the - // error it then raises into an uncaught exception. - data.emit("_tlsError", error); - data.destroy(data.listenerCount("error") > 0 ? error : undefined); - } + error(socket: Socket, error) { + const self = socket.data; + if (!self || self._hadError) return; + self._hadError = true; + if (typeof self[bunTlsSymbol] !== "function") { + self.destroy(error); return; } - // Plain TCP. On kqueue a fatal-flush from on_writable is the only place - // the errno is visible (the close it issues short-circuits the read - // dispatch at loop.c's us_socket_is_closed check), so swallowing it hung - // the server behind an un-failed pending write (test-net-stream on - // darwin). Shape it like Node's onWriteComplete: fail the pending write - // callback, then destroy with the error. destroy() owns the single - // 'error' emission via the stream's errorEmitted guard; callback(error) - // may have already destroyed, in which case this is a no-op. - const callback = data[kwriteCallback]; - if (callback) { - data[kwriteCallback] = null; - callback(error); - } - if (!data.destroyed) { - data.destroy(error); + const tlsError = establishedTLSError(self, error); + // What a listener threw comes here too, and has neither. + if ((tlsError || (error as NodeJS.ErrnoException)?.syscall !== undefined) && endsWithoutError(self)) return; + if (tlsError) error = tlsError; + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L467-L498 + if (!self._secureEstablished) { + // The socket stays reachable while 'tlsClientError' is emitted through the '_tlsError' hop. + self._closeAfterHandlingError = true; + self.destroy(error); + } else if (self.isServer && self._rejectUnauthorized && /peer did not return a certificate/.test(error?.message)) { + // Ignore server's authorization errors + self.destroy(); + } else if (tlsError && !self[kWriteReq]) { + self._emitTLSError(error); + } else if (self.isServer) { + // Node leaves the socket alive. Destroy it, and pass the error on only if something listens for 'error'. + self.emit("_tlsError", error); + self.destroy(self.listenerCount("error") > 0 ? error : undefined); + } else { + self.destroy(error); } }, - timeout(socket) { - SocketHandlers.timeout(socket); - }, - drain(socket, rejected?: number) { - SocketHandlers.drain(socket, rejected); - }, binaryType: "buffer", -} as const satisfies InternalServerHandler; +} as const satisfies InternalSocketHandler; function applyRejectUnauthorized(self, tls, rejectUnauthorized) { if (typeof rejectUnauthorized !== "undefined") { @@ -1518,13 +1283,12 @@ function onSocketTLSClose(hadError) { } } -function onHandshakeTimeout() { - this._emitTLSError($ERR_TLS_HANDSHAKE_TIMEOUT()); -} - +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1056-L1058 function onHandshakeDeadline(socket) { socket[khandshakeTimer] = undefined; - if (!socket.destroyed) socket.emit("timeout"); + if (socket.destroyed) return; + socket._emitTLSError($ERR_TLS_HANDSHAKE_TIMEOUT()); + socket.emit("timeout"); } // 'close' listener: `this` is the socket. @@ -1532,7 +1296,6 @@ function clearHandshakeTimeout() { if (this[khandshakeTimer]) { clearTimeout(this[khandshakeTimer]); this[khandshakeTimer] = undefined; - this.removeListener("timeout", onHandshakeTimeout); } } @@ -1543,16 +1306,7 @@ function initAcceptedTLSSocket(server, socket) { socket.on("close", onSocketTLSClose); const handshakeTimeout = server._handshakeTimeout; if (!(handshakeTimeout > 0)) return; - // The deadline surfaces as the socket's own 'timeout' event with - // _handleTimeout as its first listener, which routes it through - // _emitTLSError: 'tlsClientError' fires while control is still the server's, - // and the connection is neither errored nor destroyed. Node arms it with - // socket.setTimeout(); bun keeps a standalone timer because encrypted bytes - // arriving mid-handshake must not extend the deadline (verified against the - // v26.3.0 binary: a client dribbling bytes is still cut off on time). - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L961-L962 - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1056-L1058 - socket.once("timeout", onHandshakeTimeout); + // Node arms socket.setTimeout() on its TLSSocket. Here the program's own setTimeout() is on the same socket, and bytes that arrive mid-handshake must not put the deadline off. const timer = setTimeout(onHandshakeDeadline, handshakeTimeout, socket); // Node's handshake timer is unref'd: a fully-unref'd server (the // graceful-shutdown pattern) must not be held open by a client that @@ -1562,7 +1316,7 @@ function initAcceptedTLSSocket(server, socket) { socket.once("close", clearHandshakeTimeout); } // Node.js-compatible onconnection: assigned to server._handle.onconnection in -// kRealListen and invoked from ServerHandlers.open with `this` bound to the +// kRealListen and invoked from SocketHandlers.open with `this` bound to the // listener handle. Kept as a standalone function so tests/cluster can wrap it. function onconnection(err, clientHandle) { const handle = this; @@ -1571,6 +1325,7 @@ function onconnection(err, clientHandle) { self.emit("error", err); return; } + // Nothing reads this: it keeps the listener reachable for the garbage collector while a connection of its is open. clientHandle[kServerSocket] = handle; const options = self[bunSocketServerOptions]; const { [kSocketClass]: SClass } = options; @@ -1590,25 +1345,19 @@ 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); + _socket[kAttach](clientHandle); const blockList = self.blockList; if (blockList) { const addressType = isIP(clientHandle.remoteAddress); + // No 'drop': https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L2512-L2520 if (addressType && blockList.check(clientHandle.remoteAddress, `ipv${addressType}`)) { - const data = { - localAddress: _socket.localAddress, - localPort: _socket.localPort || clientHandle.localPort, - localFamily: _socket.localFamily, - remoteAddress: _socket.remoteAddress, - remotePort: _socket.remotePort, - remoteFamily: _socket.remoteFamily || "IPv4", - }; clientHandle.end(); - self.emit("drop", data); return; } } @@ -1636,19 +1385,20 @@ function onconnection(err, clientHandle) { } if (self.keepAlive && clientHandle.setKeepAlive) { _socket[kSetKeepAlive] = true; - _socket[kSetKeepAliveInitialDelay] = self.keepAliveInitialDelay; - clientHandle.setKeepAlive(true, self.keepAliveInitialDelay); + _socket[kSetKeepAliveInitialDelay] = keepAliveDelayOf(self); + clientHandle.setKeepAlive(true, _socket[kSetKeepAliveInitialDelay]); } self._connections++; _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 { @@ -1656,217 +1406,40 @@ 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 -const SocketHandlers2 = { - open(socket: Socket) { - $debug("Bun.Socket open"); - let { self, req } = socket.data; - socket[owner_symbol] = self; - $debug("self[kupgraded]", String(self[kupgraded])); - // Offer a previously-negotiated session for resumption before oncomplete - // (afterConnect) runs: a user 'connect' listener that writes immediately - // would otherwise send the ClientHello before SSL_set_session runs and - // silently skip resumption. - { - const options = self[bunTLSConnectOptions]; - if (options) { - const { session } = options; - if (session) { - // On the native socket: an fd upgrade assigns `_handle` after `open`. - socket.setSession(typeof session === "string" ? Buffer.from(session, "latin1") : session); - } - } - } - startNameCheckInHandshake(self, socket); - if (!self[kupgraded]) req.oncomplete(0, self._handle, req, true, true); - socket.data.req = undefined; - }, - data(socket, buffer) { - $debug("Bun.Socket data"); - const { self } = socket.data; - self._unrefTimer(); - self.bytesRead += buffer.length; - pushDataToSocket(self, socket, buffer); - }, - // See SocketHandlers.drain. - drain(socket, rejected?: number) { - $debug("Bun.Socket drain"); - const { self } = socket.data; - const callback = self[kwriteCallback]; - self.connecting = false; - if (callback && self[kRejectedWrite] === undefined) { - const writeChunk = self._pendingData; - 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; - self._unrefTimer(); - self[kLastWriteQueueSize] = 0; - unrefAfterDrain(self, socket); - callback(null); - } else { - self[kBytesWritten] = socket.bytesWritten; - self._pendingData = null; - } - } - }, - end(socket) { - $debug("Bun.Socket end"); - const { self } = socket.data; - if (deferEndForOnreadTail(self)) return; - finishSocketEnd(self); - }, - // See SocketHandlers.session. - session(socket: Socket>, session) { - const { self } = socket.data; - if (!self) return; - if (self._secureEstablished) { - self.emit("session", session); - } else { - self[kpendingSession] = session; - } - }, - keylog(socket, line) { - const { self } = socket.data; - if (!self) return; - self.emit("keylog", line); - if (tlsKeylogPath !== undefined) appendTlsKeylog(tlsKeylogPath, line); - }, - close(socket, err?: NativeSocketError) { - $debug("Bun.Socket close"); - let { self } = socket.data; - if (err) $debug(err); - if (self[kclosed]) return; - self[kclosed] = true; - const leftToTLSSocket = closeWithTLSSocket(self, socket); - if (failRejectedWrite(self)) return; - // A received RST surfacing as ECONNRESET with the close is not a clean - // EOF - Node destroys the socket with "read ECONNRESET" instead of a - // graceful 'end'. Only surface it when the closing handle is still the - // socket's current handle: connection attempts that lost the - // 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) { - // 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 (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 }; - er.errno = err.errno; - er.syscall = "read"; - self.destroy(er); - } else { - // Any other recv errno (ETIMEDOUT, EHOSTUNREACH, ENETUNREACH, ...) - // keeps its identity — Node's onStreamRead does - // `stream.destroy(errnoException(nread, 'read'))` for any nread that - // is not UV_EOF. The native on_close only passes a non-undefined err - // when the close was driven by a recv() failure (libus close-code - // enum values are filtered out in NewSocket::on_close). - self.destroy(err); - } - return; - } - if (!leftToTLSSocket && !deferEndForOnreadTail(self)) finishSocketEnd(self); - // 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()); - } - }, - handshake(socket, success, verifyError) { - $debug("Bun.Socket handshake"); - const { self } = socket.data; - onClientHandshake(self, socket, success, verifyError); - }, - error(socket, error) { - $debug("Bun.Socket error"); - if (socket.data === undefined) return; - const { self } = socket.data; - if (self._hadError) return; - self._hadError = true; - const tlsError = establishedTLSError(self, error); - if (tlsError) error = tlsError; +// Native code asks JS from inside every handshake for each of these members a table has, so a listener gets only what its server uses. +const { + session: _session, + connectError: _connectError, + serverName, + alpnCallback, + ...AcceptedHandlers +} = SocketHandlers; +const AcceptedHandlersSNI = { ...AcceptedHandlers, serverName }; +const AcceptedHandlersALPN = { ...AcceptedHandlers, alpnCallback }; +const AcceptedHandlersSNIAndALPN = { ...AcceptedHandlers, serverName, alpnCallback }; - const callback = self[kwriteCallback]; - if (callback) { - self[kwriteCallback] = null; - callback(error); - } else if (tlsError) { - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L467-L498 - (self as TLSSocketInstance)._emitTLSError(error); - return; - } +function serverHandlersFor(server) { + if (server._SNICallback) return server._ALPNCallback ? AcceptedHandlersSNIAndALPN : AcceptedHandlersSNI; + return server._ALPNCallback ? AcceptedHandlersALPN : AcceptedHandlers; +} - if (!self.destroyed) process.nextTick(destroyNT, self, error); - }, - timeout(socket) { - $debug("Bun.Socket timeout"); - const { self } = socket.data; - self.emit("timeout", self); - }, - connectError(socket: Socket, error: NativeConnectError) { - $debug("Bun.Socket connectError"); - let { self, req } = socket.data; - socket[owner_symbol] = self; - socket.data.req = undefined; - // doConnect dispatches this synchronously when connect()/bind() fails at - // the syscall; surface it as kConnectTcp/Pipe's return value (callers' - // Node-derived `if (err)` expects that) instead of re-entering oncomplete. - if (req.dispatching) { - req.errno = error.errno || uv().UV_ECANCELED; - return; - } - // A TLS engine that failed before it opened has no connect pending: afterConnect would drop the failure. - if (self[kupgraded]) { - if (!self.destroyed) self.destroy(new ErrnoException(error.errno, "connect")); +// The `onread` option: https://github.com/nodejs/node/blob/v26.5.0/lib/internal/stream_base_commons.js#L177-L185 +const OnreadHandlers = { + ...SocketHandlers, + data(socket, buffer) { + const self = socket.data; + self._unrefTimer(); + if (socket[kAdoptedTLSRaw]) return; + const tail = self[kOnreadTail]; + if (tail !== undefined) { + self[kOnreadTail] = Buffer.concat([tail, buffer]); return; } - // Closing the handle cancels the request (ECANCELED). libuv completes it on a later loop turn, - // after destroy(err)'s 'error'. Not deferred here: it would land on a connect() made right - // after destroy() and fail that one. - // An attempt that timed out was closed with its `oncomplete` cleared. - req.oncomplete?.(error.errno, self._handle, req, true, true); + self[kOnreadDeliver](buffer); }, -} satisfies InternalSocketHandler; - -// The same table minus the per-connection callback members: a listener whose -// config has neither handler never registers the native SNI/ALPN dispatches, -// so a server without an SNICallback or ALPNCallback does not pay a JS -// round-trip from inside the handshake for them. -const { serverName: _serverNameHandler, alpnCallback: _alpnCallbackHandler, ...ServerHandlersNoSNI } = ServerHandlers; -// Partial tables so a server with exactly one of the callbacks only registers -// that dispatch (the other would be a per-handshake JS round-trip that always -// falls through). -const { serverName: _snOnly, ...ServerHandlersALPNOnly } = ServerHandlers; -const { alpnCallback: _acOnly, ...ServerHandlersSNIOnly } = ServerHandlers; - -/** The handler table for a listen config: the full table only when a - * per-connection callback is configured, so other servers never pay a JS - * round-trip from inside the handshake. */ -function serverHandlersFor(server) { - const sni = !!server._SNICallback; - const alpn = !!server._ALPNCallback; - if (sni && alpn) return ServerHandlers; - if (sni) return ServerHandlersSNIOnly; - if (alpn) return ServerHandlersALPNOnly; - return ServerHandlersNoSNI; -} +}; // node.net.native trace events: one 'b'/'e' pair per connect *attempt*, including // failed ones. 'b' fires where the attempt is issued; the per-req flag dedupes the @@ -1900,6 +1473,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, @@ -1914,32 +1492,50 @@ function kConnectTcp(self, addressType, req, address, port) { allowHalfOpen: true, tls: req.tls, pauseOnConnect: req.pauseOnConnect, - data: { self, req }, - socket: self[khandlers], }); } 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 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 }, - 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 || errno === uv().UV_EISCONN) return errno; + setImmediate(afterPipeConnectFailed, self, handle, req, errno); + return 0; +} + +function afterPipeConnectFailed(self, handle, req, errno) { + // destroy() or another connect() already ended this request. + if (self._handle === handle && handle[kConnectReq] === req) req.oncomplete(errno, handle, req, true, true); } function kConnectDispatch(self, req, opts) { // Node's TCPWrap returns errno for sync uv_*_connect failure and defers // oncomplete; doConnect instead fires connectError inside this call. Bracket // it so connectError hands the errno back here instead of re-entering. + const handle = self._handle; + handle[kConnectReq] = req; + opts.data = self; + opts.socket = self[khandlers]; req.dispatching = true; - const promise = doConnect(self._handle, opts); + const promise = doConnect(handle, opts); req.dispatching = false; + // The handle has a connection that isn't its own to drop, like that of an http server. + if (promise === undefined) { + traceConnectEnd(req); + return uv().UV_EISCONN; + } promise.catch(_reason => { // eat this so there's no unhandledRejection // we already catch this in connectError and error @@ -2007,22 +1603,23 @@ function Socket(options?): void { // Bun.Socket), so store ms here. Node stores seconds because libuv does. this[kSetKeepAliveInitialDelay] = MathMax(0, ~~keepAliveInitialDelay); - this[khandlers] = SocketHandlers2; + this[khandlers] = SocketHandlers; this[kDestroyOnRead] = false; - this.bytesRead = 0; + this[kBytesRead] = 0; this[kBytesWritten] = undefined; + this[kLastBytesSent] = 0; this[kclosed] = false; this[kended] = false; + this[kHungUp] = false; this.connecting = false; this._host = undefined; this._port = undefined; this[bunTLSConnectOptions] = null; this.timeout = 0; - this[kLastWriteQueueSize] = 0; // node initializes the timer slot to null so it is observable before setTimeout() is called. // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L401 this[kTimeout] = null; - this[kwriteCallback] = undefined; + this[kWriteReq] = undefined; this._pendingData = undefined; this._pendingEncoding = undefined; // for compatibility this._hadError = false; @@ -2038,19 +1635,15 @@ function Socket(options?): void { // Shut down the socket when we're finished with it. this.on("end", onSocketEnd); + let stats; + let fdIsWriteOnly = false; 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 { @@ -2062,19 +1655,25 @@ 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)) { + fdIsWriteOnly = true; + // 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); } } } @@ -2122,7 +1721,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; @@ -2153,7 +1752,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; @@ -2172,22 +1771,7 @@ function Socket(options?): void { } } }; - // when the onread option is specified we use a different handlers object - this[khandlers] = { - ...SocketHandlers2, - data(socket, buffer) { - const { self } = socket.data; - if (!self) return; - self._unrefTimer(); - if (socket[kAdoptedTLSRaw]) return; - const tail = self[kOnreadTail]; - if (tail !== undefined) { - self[kOnreadTail] = Buffer.concat([tail, buffer]); - return; - } - self[kOnreadDeliver](buffer); - }, - }; + this[khandlers] = OnreadHandlers; } if (signal) { if (signal.aborted) { @@ -2207,6 +1791,19 @@ 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 (fdIsWriteOnly) { + Socket.prototype.connect.$call(this, { fd, fdIsWriteOnly }); + // Nothing is read from this handle. Like a socket whose peer sent FIN, it holds the loop only while a write is pending. + this[kended] = true; + this._handle.unref(); + } else if (process.platform !== "win32" && fd > 2 && 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); @@ -2221,11 +1818,11 @@ Socket.prototype.address = function address() { Socket.prototype._onTimeout = function () { const handle = this._handle; if (handle) { - // As in Node, a write that moved since the last look puts the timeout off. Its first look is at the size of the write, this one at 0. - // https://github.com/nodejs/node/blob/v26.12.1/lib/net.js#L872-L887 - const writeQueueSize = getBufferedAmount(handle); - if (writeQueueSize !== this[kLastWriteQueueSize]) { - this[kLastWriteQueueSize] = writeQueueSize; + // A write queue that the kernel took bytes of since the last look is still draining, so wait another period. Its size doesn't tell: a node:http response can add to it. + const { writeQueueSize } = handle; + const sent = handle.bytesWritten - writeQueueSize; + if (writeQueueSize !== 0 && sent > this[kLastBytesSent]) { + this[kLastBytesSent] = sent; this._unrefTimer(); return; } @@ -2242,15 +1839,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; @@ -2266,34 +1875,22 @@ Object.defineProperty(Socket.prototype, "bytesWritten", { }, }); -Socket.prototype[kAttach] = function (port, socket) { +Socket.prototype[kAttach] = function (socket) { socket.data = this; socket[owner_symbol] = this; const timeout = this.timeout; if (timeout) { this.setTimeout(timeout); } - // make sure to disable timeout on usocket and handle on TS side - socket.timeout(0); this._handle = socket; this.connecting = false; - if (this[kSetNoDelay]) { - socket.setNoDelay(true); - } - - if (this[kSetKeepAlive]) { - socket.setKeepAlive(true, this[kSetKeepAliveInitialDelay]); - } - 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); this.emit("ready"); } - SocketHandlers.drain(socket); }; Socket.prototype[kCloseRawConnection] = function () { @@ -2301,96 +1898,90 @@ 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(); }; +// A handle that is open already (fd, wrapped stream) starts reading unless the user paused. afterConnect does this for a dial. +function startReadingNT(self) { + if (!self.connecting && !self.isPaused()) self.read(0); +} + Socket.prototype.connect = function connect(...args) { $debug("Socket.prototype.connect"); + // The closed handle cancels its queued write from the event loop. By then that would fail this connection. + if (this.destroyed && this[kWriteReq]) onWriteComplete(this, this[kWriteReq].writeHandle, uv().UV_ECANCELED); + const [options, cb] = $isArray(args[0]) && args[0][normalizedArgsSymbol] ? args[0] : normalizeArgs(args); + const isTLS = typeof this[bunTlsSymbol] === "function"; { - const [options, connectListener] = - $isArray(args[0]) && args[0][normalizedArgsSymbol] ? args[0] : normalizeArgs(args); let connection = this[ksocket]; let upgradeDuplex = false; - let { port, host, path, socket, rejectUnauthorized, checkServerIdentity, session, fd, pauseOnConnect } = options; + const { port, host, path, socket, rejectUnauthorized, checkServerIdentity, session, fd, pauseOnConnect } = options; // A session over a transport the caller handed in has no connection of its own to dial again. if (!socket && this[kupgraded] && !this.destroyed) { const ex = new ExceptionWithHostPort(uv().UV_EISCONN, "connect", path || host || "localhost", port); process.nextTick(destroyNT, this, ex); return this; } - this.servername = options.servername; + if (isTLS) this.servername = options.servername; if (socket) { connection = socket; } if (fd != null) { - doConnect(this._handle, { - data: this, - fd: fd, - ...(options.fdIsRawSocket === true ? { fdIsRawSocket: true } : {}), - socket: SocketHandlers, - // Always half-open natively; see kConnect. - allowHalfOpen: true, - pauseOnConnect, - }).catch(error => { - if (!this.destroyed) { - this.emit("error", error); - this.emit("close", true); - } - }); + // createConnection({ fd }) gets here after the constructor already adopted the fd. + if (this._handle?.fd !== fd) { + this._handle ??= newDetachedSocket(false); + const errno = kConnectDispatch( + this, + { oncomplete: afterOpenFd }, + { + fd, + fdIsRawSocket: options.fdIsRawSocket === true, + fdIsWriteOnly: options.fdIsWriteOnly === true, + // Always half-open natively; see kConnect. + allowHalfOpen: true, + pauseOnConnect, + }, + ); + // https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L568-L569 + if (errno !== 0) throw new ErrnoException(errno, "open"); + } + if (pauseOnConnect) pauseOnCreate(this, this._handle); + else process.nextTick(startReadingNT, this); + return this; } if (pauseOnConnect) { - // An fd is open already; a dial is paused when it opens, and afterConnect releases its hold. - if (fd != null) pauseOnCreate(this, this._handle); - else this.readableFlowing = false; + // A dial is paused when it opens, and afterConnect releases its hold. + this.readableFlowing = false; } else { - process.nextTick(() => { - // An already-open handle (fd, wrapped duplex) starts reading here unless - // the user paused; read(0) does that without switching to flowing mode. - // A pending connect gets this from afterConnect instead, so a pause() - // that lands before then is still honored: - // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/net.js#L1649 - if (!this.connecting && !this.isPaused()) this.read(0); - }); - if (fd == null) this.connecting = true; - } - if (fd != null) { - return this; + this.connecting = true; + process.nextTick(startReadingNT, this); } 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) && (isTLS ? 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); + if (isTLS) { + if (checkServerIdentity !== undefined) validateFunction(checkServerIdentity, "options.checkServerIdentity"); // Client always request Cert this._requestCert = true; - if (tls) { - applyRejectUnauthorized(this, tls, rejectUnauthorized); - tls.requestCert = true; - tls.session = session || tls.session; - this.servername = tls.servername; - if (checkServerIdentity !== undefined) { - validateFunction(checkServerIdentity, "options.checkServerIdentity"); - } - tls.checkServerIdentity = checkServerIdentity || tls.checkServerIdentity; - this[bunTLSConnectOptions] = tls; - } + tls = clientTLSOptions(this, { rejectUnauthorized, checkServerIdentity, session }, host); 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"); @@ -2410,7 +2001,7 @@ Socket.prototype.connect = function connect(...args) { } // start using existing connection if (connection) { - if (connectListener != null) this.once("secureConnect", connectListener); + if (cb != null) this.once("secureConnect", cb); // reset the underlying writable object when establishing a new connection // this is a function on `Duplex`, originally defined on `Writable` // https://github.com/nodejs/node/blob/c5cfdd48497fe9bd8dbd55fd1fca84b321f48ec1/lib/net.js#L311 @@ -2425,13 +2016,7 @@ Socket.prototype.connect = function connect(...args) { } } - const [options, cb] = $isArray(args[0]) && args[0][normalizedArgsSymbol] ? args[0] : normalizeArgs(args); - - if (typeof this[bunTlsSymbol] === "function" && cb !== null) { - this.once("secureConnect", cb); - } else if (cb !== null) { - this.once("connect", cb); - } + if (cb !== null) this.once(isTLS ? "secureConnect" : "connect", cb); if (this._parent?.connecting) { return this; } @@ -2452,7 +2037,7 @@ Socket.prototype.connect = function connect(...args) { $debug("pipe", pipe, path); if (!this._handle) { - this._handle = newDetachedSocket(typeof this[bunTlsSymbol] === "function"); + this._handle = newDetachedSocket(isTLS); initSocketHandle(this); } @@ -2479,45 +2064,23 @@ Socket.prototype.end = function end(data, encoding, callback) { return Duplex.prototype.end.$call(this, data, encoding, callback); }; -Socket.prototype._destroy = function _destroy(err, callback) { +// `scheduleClose` is node:http's, for a connection of its server. +Socket.prototype._destroy = function _destroy(err, callback, scheduleClose = setImmediate) { $debug("Socket.prototype._destroy"); this.connecting = false; const upgraded = this[kupgraded]; - // Close an fd adopted for synchronous writes (node closes the wrapping - // libuv handle here). Leave stdio fds 0-2 open: process.stdout/stderr and - // other wrappers share them, matching SyncWriteStream's autoClose gate. - const syncFd = this[kSyncWriteFd]; - if (syncFd !== undefined) { - this[kSyncWriteFd] = undefined; - // Drop the instance overrides so a later connect() on this (reusable) - // socket goes through the fresh handle's normal write path. - delete this._write; - delete this._writev; - if (syncFd > 2) { - try { - require("node:fs").closeSync(syncFd); - } catch { - // Already closed by the peer/user; nothing to release. - } - } - } - // What closes with this socket clears its own. clearTimeout(this[kTimeout]); const released = unadoptedTransport(this); if (released !== undefined && !this.resetAndClosing && this[kSessionAccepted] === true) releaseTransport(released); $debug("close"); - if (this._handle) { + const currentHandle = this._handle; + if (currentHandle) { $debug("close handle"); const isException = err ? true : false; - // `bytesRead` and `kBytesWritten` should be accessible after `.destroy()` - // this[kBytesRead] = this._handle.bytesRead; - this[kBytesWritten] = this._handle.bytesWritten; - - const currentHandle = this._handle; // Ahead of every branch: two of them close the handle a loop turn later, when the flight has left. if (typeof this[bunTlsSymbol] === "function" || currentHandle[kAdoptedTLSRaw]) { releaseHeldFlight(currentHandle); @@ -2547,22 +2110,15 @@ Socket.prototype._destroy = function _destroy(err, callback) { currentHandle.pause?.(); setImmediate(closeAdoptedTLSRawNT, this, currentHandle, isException); } else { - closeSocketHandle(this, currentHandle, isException); + closeSocketHandle(this, currentHandle, isException, false, scheduleClose); } - if (!this._closeAfterHandlingError) { - const handle = this._handle; - if (handle) handle.onread = noop; - this._handle = null; - this._sockname = null; - } + if (!this._closeAfterHandlingError) detachSocket(this); callback(err); closeOwedRaw(this, upgraded); - if (this[kwriteCallback]) process.nextTick(cancelPendingWriteNT, this); } else { callback(err); closeOwedRaw(this, upgraded); - if (this[kwriteCallback]) process.nextTick(cancelPendingWriteNT, this); const transport = transportToDestroy(this); if (transport !== undefined) abortTransport(transport); if (transport instanceof Socket) transport.once("close", emitCloseNT.bind(null, this, err ? true : false)); @@ -2627,7 +2183,7 @@ Object.defineProperty(Socket.prototype, "pending", { // if the fd were adopted; such sockets use the stream-level TLS engine, whose // ciphertext queues behind the pending writes (order + callbacks preserved). function hasUnflushedWrites(connection) { - return connection.writableLength > 0 || connection[kwriteCallback] != null; + return connection.writableLength > 0 || connection[kWriteReq] !== undefined; } function drainOnreadTail(self, fromRead?) { @@ -2654,24 +2210,20 @@ function drainOnreadTailNT(socket) { socket[kOnreadPendingEnd] = false; finishSocketEnd(socket); } else if (fromRead || !socket.isPaused()) { - socket._handle?.resume?.(); - restorePausedHold(socket, socket._handle); + readStart(socket, socket._handle); } } Socket.prototype.resume = function resume() { + // Node's Readable.resume() ignores a destroyed stream. Ours also waits for 'end', which destroy() doesn't emit. + if (this.destroyed) return this; + // Schedule the Readable flow tick first so its read() runs while // kOnreadDraining is still set and does not queue a second drain: Node's // override sets handle.reading synchronously for the same reason. const ret = Duplex.prototype.resume.$call(this); - // An ended readable side (EOF emitted, or `readable: false`) never restarts the handle: node reaches readStart only from _read. - // An onread socket is the exception: https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L830-L845 - if (this.readableEnded && this[kOnreadBuffer] === undefined) return ret; - if (!this.connecting && !drainOnreadTail(this)) { - this._handle?.resume?.(); - } - // Even while still connecting, so pause-then-resume stays symmetric. - restorePausedHold(this, this._handle); + // Reads start from _read(), which a full buffer doesn't reach. Only an onread socket starts them here: https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L976-L982 + if (this[kOnreadBuffer] !== undefined && !this.connecting && !drainOnreadTail(this)) readStart(this, this._handle); return ret; }; @@ -2684,18 +2236,33 @@ 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 +// serverHandlersFor() — 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)) { - // No adoptable fd (generic Duplex, named pipe, never dialed), TLS over TLS (the fd belongs to the + if ( + !socket || + (connection[kclosed] && connection.allowHalfOpen) || + !(connection instanceof Socket) || + connection.encrypted || + isNamedPipeSocket(socket) || + hasUnflushedWrites(connection) + ) { + // No adoptable fd (generic Duplex, named pipe, never dialed, closed under a half-open socket), TLS over TLS (the fd belongs to the // outer SSL layer), or pending plain writes that must flush first. attachTLSEngine(this, connection, options); } else if (connection.connecting) { @@ -2716,12 +2283,10 @@ Socket.prototype[kUpgradeClientTLS] = function (connection, servername) { }); }; +// https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L985-L991 Socket.prototype.read = function read(size) { - // See resume(): an ended readable side never restarts the handle. - if ((!this.readableEnded || this[kOnreadBuffer] !== undefined) && !this.connecting && !drainOnreadTail(this, true)) { - this._handle?.resume?.(); - restorePausedHold(this, this._handle); - } + if (this[kOnreadBuffer] !== undefined && !this.connecting && !drainOnreadTail(this, true)) + readStart(this, this._handle); return Duplex.prototype.read.$call(this, size); }; @@ -2730,8 +2295,7 @@ Socket.prototype._read = function _read(size) { if (this.connecting || !socket) { this.once("connect", () => this._read(size)); } else if (!drainOnreadTail(this, true)) { - socket?.resume?.(); - restorePausedHold(this, socket); + readStart(this, socket); } }; @@ -2814,47 +2378,6 @@ Object.defineProperty(Socket.prototype, "remoteFamily", { }, }); -function fdSyncWrite(chunk, encoding, callback) { - const fs = require("node:fs"); - // node's _writeGeneric restarts the idle timer on every write. - this._unrefTimer(); - 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); - } -} - -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; - while (offset < buf.length) { - offset += fs.writeSync(this[kSyncWriteFd], buf, offset); - } - total += offset; - } - // See fdSyncWrite: no native handle to account these on. - this[kBytesWritten] = (this[kBytesWritten] || 0) + total; - callback(); - } catch (err) { - callback(err); - } -} - Socket.prototype.resetAndDestroy = function resetAndDestroy() { if (this._handle) { if (this.connecting) { @@ -2868,6 +2391,12 @@ Socket.prototype.resetAndDestroy = function resetAndDestroy() { return this; }; +// The handle takes milliseconds that fit an int32. Past that the system's own delay applies. +function keepAliveDelayOf(server) { + const delay = server.keepAliveInitialDelay * 1000; + return delay > 0 && delay <= 2147483647 ? delay : 0; +} + Socket.prototype.setKeepAlive = function setKeepAlive(enable = false, initialDelayMsecs = 0) { enable = Boolean(enable); // Bun's native _handle.setKeepAlive takes milliseconds; the ms→seconds @@ -2906,6 +2435,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. @@ -2926,7 +2460,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"); } } @@ -2941,7 +2475,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"); @@ -2949,6 +2483,11 @@ Socket.prototype.getTypeOfService = function getTypeOfService() { return res; }; +// https://github.com/nodejs/node/blob/v26.12.1/lib/internal/stream_base_commons.js +function onStreamTimeout(stream) { + stream._onTimeout(); +} + Socket.prototype.setTimeout = { setTimeout(msecs, callback) { if (this.destroyed) return this; @@ -2965,7 +2504,7 @@ Socket.prototype.setTimeout = { this.removeListener("timeout", callback); } } else { - this[kTimeout] = setTimeout(this._onTimeout.bind(this), msecs).unref(); + this[kTimeout] = setTimeout(onStreamTimeout, msecs, this).unref(); if (callback !== undefined) { validateFunction(callback, "callback"); @@ -3001,32 +2540,23 @@ Socket.prototype.destroySoon = function destroySoon() { }; //TODO: migrate to native +// Writable calls this with two chunks or more. Socket.prototype._writev = function _writev(data, callback) { - const allBuffers = data.allBuffers; - const chunks = data; - if (allBuffers) { - if (data.length === 1) { - return this._write(data[0], "buffer", callback); - } - for (let i = 0; i < data.length; i++) { - data[i] = data[i].chunk; - } - } else { - if (data.length === 1) { - const { chunk, encoding } = data[0]; - return this._write(chunk, encoding, callback); - } - for (let i = 0; i < data.length; i++) { - const { chunk, encoding } = data[i]; - if (typeof chunk === "string") { - data[i] = Buffer.from(chunk, encoding); - } else { - data[i] = chunk; - } - } + // An HTTP message ends with an empty write, whose callback is its 'finish'. A lone string needs no copy, and nobody can change it while part of it waits. + let only; + let count = 0; + for (let i = 0; i < data.length; i++) { + if (data[i].chunk.length !== 0 && ++count === 1) only = data[i]; } - const chunk = Buffer.concat(chunks || []); - return this._write(chunk, "buffer", callback); + if (count === 1) { + const { chunk, encoding } = only; + if (typeof chunk === "string") return this._write(chunk, encoding, callback); + } + for (let i = 0; i < data.length; i++) { + const { chunk, encoding } = data[i]; + data[i] = typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk; + } + return this._write(Buffer.concat(data), "buffer", callback); }; Socket.prototype._write = function _write(chunk, encoding, callback) { @@ -3035,7 +2565,6 @@ Socket.prototype._write = function _write(chunk, encoding, callback) { // The Writable logic will buffer up any more writes while // waiting for this one to be done. if (this.connecting) { - this[kwriteCallback] = callback; this._pendingData = chunk; this._pendingEncoding = encoding; function onClose() { @@ -3056,7 +2585,6 @@ Socket.prototype._write = function _write(chunk, encoding, callback) { // adopts the fd a tick later, so hold the write until it lands rather // than failing it as a closed socket. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L590-L608 - this[kwriteCallback] = callback; this._pendingData = chunk; this._pendingEncoding = encoding; const onClose = onUpgradeWriteClose.bind(this, callback); @@ -3066,59 +2594,30 @@ Socket.prototype._write = function _write(chunk, encoding, callback) { } this._pendingData = null; this._pendingEncoding = ""; - this[kwriteCallback] = null; if (!socket) { callback($ERR_SOCKET_CLOSED()); return false; } this._unrefTimer(); - this[kLastWriteQueueSize] = 0; if (this.secureConnecting) this[kPreHandshakeWrite] = true; - 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 - // in Node instead of waiting forever for a drain that never comes. - // Node reports this as errnoException(UV_EBADF/UV_EPIPE, 'write'), with - // message, code, errno and syscall all populated. - const er = new ErrnoException(process.platform === "win32" ? -4047 /* UV_EPIPE */ : -9 /* UV_EBADF */, "write"); - // Node fails a write that the handle rejects at once inside the same call, - // so write() returns false. The stream runs the write callback and destroys - // the socket on the next tick. - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/stream_base_commons.js#L158-L159 - callback(er); - 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. - if (!holdRejectedWrite(this, socket, res, callback)) callback(writeErrnoException(res)); - return false; - } - if (res) { - if (this.encrypted) { - // TLS batches writes through the SSL engine, so the bytes stay buffered - // after $write returns. Defer the callback so writableLength/bufferSize - // reflects the queued bytes until they are flushed (test-tls-buffersize.js). - // Node's bufferSize getter is just writableLength: - // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/net.js#L752 - process.nextTick(callback); - } else { - // A plain TCP write completes synchronously once $write reports success. - // Calling the callback synchronously lets writableLength drain so a tight - // write() loop backpressures at the kernel rather than the JS - // highWaterMark, matching Node's _write (test-net-throttle.js): - // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/net.js#L1036 - callback(); - } - } else if (this[kwriteCallback]) { - callback(new Error("overlapping _write()")); - } else { - this[kwriteCallback] = callback; + const status = socket.$write(chunk, encoding); + if (status === false) { + this[kWriteReq] = { writeHandle: socket, writeCallback: callback, writeFrame: AsyncContextFrame.current() }; // A pending write holds the loop even on a handle whose FIN/pause dropped // its hold (libuv: the uv_write_t is active); unrefAfterDrain lets go again. if ((this[kended] || this[kPausedUnref]) && !this[kUserUnrefed]) socket.ref?.(); + } else if (status !== true) { + // https://github.com/nodejs/node/blob/v26.5.0/lib/internal/stream_base_commons.js#L158-L159 + // Node still holds the descriptor of a peer that hung up, so its write fails with EPIPE. + const err = new ErrnoException(this[kHungUp] && status === uv().UV_EBADF ? uv().UV_EPIPE : status, "write"); + if (endsWithoutError(this)) process.nextTick(callback, err); + else callback(err); + } else if (this.encrypted) { + // A TLS write is never done in the same call in Node, so writableLength counts it until the next tick (test-tls-buffersize.js). + process.nextTick(callback); + } else { + // Done in the same call, so a tight write() loop stops at the kernel's backpressure, not at highWaterMark (test-net-throttle.js). + callback(); } }; @@ -3335,11 +2834,11 @@ function lookupAndConnectMultiple(self, lookup, host, options, dnsopts, port, lo }); } -// Per attempt, not per connect(): setServername()/setSession() calls made after tls.connect() must reach the dial. -function clientTLSOptionsForAttempt(self, options, port): TLSConnectOptions | undefined { +// connect() asks for a transport it was handed. A dial asks per attempt: a setSession() or setServername() made since connect() counts. +function clientTLSOptions(self, options, host): TLSConnectOptions | undefined { const bunTLS = self[bunTlsSymbol]; if (typeof bunTLS !== "function") return undefined; - const tls = bunTLS.$call(self, port, self._host, true); + const tls = bunTLS.$call(self, host); if (tls) { const { rejectUnauthorized, session, checkServerIdentity } = options; applyRejectUnauthorized(self, tls, rejectUnauthorized); @@ -3353,42 +2852,21 @@ function clientTLSOptionsForAttempt(self, options, port): TLSConnectOptions | un } function internalConnect(self, options, path); -function internalConnect(self, options, address, port, addressType, localAddress, localPort, _flags?); -function internalConnect(self, options, address, port?, addressType?, localAddress?, localPort?, _flags?) { +function internalConnect(self, options, address, port, addressType, localAddress, localPort); +function internalConnect(self, options, address, port?, addressType?, localAddress?, localPort?) { $assert(self.connecting); let err; - if (localAddress || localPort) { - if (addressType === 4) { - localAddress ||= "0.0.0.0"; - // TODO: - // err = self._handle.bind(localAddress, localPort); - } else { - // addressType === 6 - localAddress ||= "::"; - // TODO: - // err = self._handle.bind6(localAddress, localPort, flags); - } - $debug( - "connect: binding to localAddress: %s and localPort: %d (addressType: %d)", - localAddress, - localPort, - addressType, - ); - - err = checkBindError(err, localPort, self._handle); - if (err) { - const ex = new ExceptionWithHostPort(err, "bind", localAddress, localPort); - self.destroy(ex); - return; - } - } + // The native connect binds, and reports a failed bind() as its own error. + if (localPort) localAddress ||= addressType === 4 ? "0.0.0.0" : "::"; - const tls = clientTLSOptionsForAttempt(self, options, port); + const tls = clientTLSOptions(self, options, self._host); $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}`)) { @@ -3405,7 +2883,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 @@ -3426,7 +2903,6 @@ function internalConnect(self, options, address, port?, addressType?, localAddre req.tls = tls; req.pauseOnConnect = options.pauseOnConnect; - traceConnectStart(req, address); err = kConnectPipe(self, req, address); } @@ -3464,37 +2940,9 @@ function internalConnectMultiple(context, canceled?) { self[kReinitializeHandle](newDetachedSocket(typeof self[bunTlsSymbol] === "function")); } - const { localPort, port, _flags } = context; + const { localPort, port } = context; const { address, family: addressType } = context.addresses[current]; - let localAddress; - let err; - - if (localPort) { - if (addressType === 4) { - localAddress = "0.0.0.0"; - // TODO: - // err = self._handle.bind(localAddress, localPort); - } else { - // addressType === 6 - localAddress = "::"; - // TODO: - // err = self._handle.bind6(localAddress, localPort, flags); - } - - $debug( - "connect/multiple: binding to localAddress: %s and localPort: %d (addressType: %d)", - localAddress, - localPort, - addressType, - ); - - err = checkBindError(err, localPort, self._handle); - if (err) { - ArrayPrototypePush.$call(context.errors, new ExceptionWithHostPort(err, "bind", localAddress, localPort)); - internalConnectMultiple(context); - return; - } - } + const localAddress = localPort ? (addressType === 4 ? "0.0.0.0" : "::") : undefined; if (self.blockList?.check(address, `ipv${addressType}`)) { const ex = $ERR_IP_BLOCKED(address); @@ -3504,10 +2952,11 @@ function internalConnectMultiple(context, canceled?) { return; } - const tls = clientTLSOptionsForAttempt(self, context.options, port); + const tls = clientTLSOptions(self, context.options, self._host); $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 = {}; @@ -3522,8 +2971,7 @@ function internalConnectMultiple(context, canceled?) { ArrayPrototypePush.$call(self.autoSelectFamilyAttemptedAddresses, `${address}:${port}`); - traceConnectStart(req); - err = kConnectTcp(self, addressType, req, address, port); + const err = kConnectTcp(self, addressType, req, address, port); if (err) { const ex = new ExceptionWithHostPort(err, "connect", address, port); @@ -3535,9 +2983,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; } @@ -3581,6 +3027,32 @@ function internalConnectMultipleTimeout(context, req, handle) { } } +// Options set before the socket had a connection. +function applySocketOptions(self, handle) { + if (self[kSetTOS] !== undefined && handle.setTypeOfService) { + handle.setTypeOfService(self[kSetTOS]); + } + if (self[kSetNoDelay] && handle.setNoDelay) { + handle.setNoDelay(true); + } + if (self[kSetKeepAlive] && handle.setKeepAlive) { + handle.setKeepAlive(true, self[kSetKeepAliveInitialDelay]); + } +} + +// An adopted descriptor is open already, so this runs inside connect({ fd }). +function afterOpenFd(_status, handle) { + const self = handle[owner_symbol]; + self._unrefTimer(); + applySocketOptions(self, handle); + try { + self.emit("connect", self); + self.emit("ready"); + } catch (e) { + reportError(e); + } +} + function afterConnect(status, handle, req, readable, writable) { traceConnectEnd(req); if (!handle) return; @@ -3612,25 +3084,20 @@ function afterConnect(status, handle, req, readable, writable) { self.end(); } self._unrefTimer(); - - if (self[kSetTOS] !== undefined && self._handle.setTypeOfService) { - self._handle.setTypeOfService(self[kSetTOS]); - } - if (self[kSetNoDelay] && self._handle.setNoDelay) { - self._handle.setNoDelay(true); - } - - if (self[kSetKeepAlive] && self._handle.setKeepAlive) { - self._handle.setKeepAlive(true, self[kSetKeepAliveInitialDelay]); - } + applySocketOptions(self, self._handle); // Ours already reads, Node's starts at read(): stop a paused plain socket now, and after the listeners unless one asked for a read. // A socket built with `readable: false` never reads: read() cannot reach _read once the readable side has ended. 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); @@ -3770,7 +3237,8 @@ function Server(options?, connectionListener?): void { // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/net.js#L1880 this.allowHalfOpen = allowHalfOpen; this.keepAlive = Boolean(keepAlive); - this.keepAliveInitialDelay = MathMax(0, ~~keepAliveInitialDelay); + // In seconds: https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L2041 + this.keepAliveInitialDelay = ~~(keepAliveInitialDelay / 1000); this.highWaterMark = highWaterMark; this.pauseOnConnect = Boolean(pauseOnConnect); this.noDelay = Boolean(noDelay); @@ -3800,6 +3268,12 @@ Object.defineProperty(Server.prototype, "listening", { }, }); +// node:http's listener is uWS's. Returns the handler table for the handles it accepts and their first `data`, where open() looks for the listener. +Server.prototype[kForeignListener] = function (SocketClass) { + this[bunSocketServerOptions] = { [kSocketClass]: SocketClass }; + return [serverHandlersFor(this), { _handle: { [owner_symbol]: this, onconnection } }]; +}; + Server.prototype.ref = function ref() { this._unref = false; this._handle?.ref(); @@ -3887,21 +3361,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; @@ -3909,35 +3380,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; @@ -3952,7 +3402,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; @@ -4021,7 +3471,8 @@ Server.prototype.listen = function listen(port, hostname, onListen) { // ipv6Only For TCP servers, setting ipv6Only to true will disable dual-stack support, i.e., binding to host :: won't make 0.0.0.0 be bound. Default: false. // signal An AbortSignal that may be used to close a listening server. - if (typeof options.callback === "function") onListen = options?.callback; + const { callback } = options; + if (typeof callback === "function") onListen = callback; } else if (port === undefined || port === null) { port = 0; } else if (typeof port === "number" || typeof port === "string") { @@ -4045,10 +3496,6 @@ Server.prototype.listen = function listen(port, hostname, onListen) { hostname = hostname || "::"; } - if (typeof port === "number" && (port < 0 || port >= 65536)) { - throw $ERR_SOCKET_BAD_PORT(`options.port should be >= 0 and < 65536. Received type number: (${port})`); - } - if (this._handle) { throw $ERR_SERVER_ALREADY_LISTEN(); } @@ -4064,7 +3511,7 @@ Server.prototype.listen = function listen(port, hostname, onListen) { const options = this[bunSocketServerOptions]; let contexts: Map | null = null; if (typeof bunTLS === "function") { - [tls, TLSSocketClass] = bunTLS.$call(this, port, hostname, false); + [tls, TLSSocketClass] = bunTLS.$call(this, hostname); options.servername = tls.serverName; options[kSocketClass] = TLSSocketClass; contexts = tls.contexts; @@ -4080,7 +3527,7 @@ Server.prototype.listen = function listen(port, hostname, onListen) { queryAddress = path; queryPort = -1; queryAddressType = -1; - } else if (typeof fd === "number" && fd >= 0) { + } else if (fd !== undefined) { queryPort = null; queryAddressType = null; } else if (typeof clusterHost === "string") { @@ -4088,33 +3535,36 @@ Server.prototype.listen = function listen(port, hostname, onListen) { queryAddressType = isIP(clusterHost) || 4; } - listenInCluster( - this, - queryAddress, - queryPort, - queryAddressType, - backlog, - fd, - exclusive, - ipv6Only, - reusePort, - readableAll, - writableAll, - flags, - undefined, - path, - hostname, - tls, - contexts, - onListen, - ); + const listenOn = host => + listenInCluster( + this, + queryAddress, + queryPort, + queryAddressType, + backlog, + fd, + exclusive, + ipv6Only, + reusePort, + readableAll, + writableAll, + flags, + path, + host, + tls, + contexts, + ); + try { + listenOn(hostname); + } catch (err: any) { + // No host and no IPv6 in the kernel: https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L2127-L2139 + if (queryAddress !== null || err?.code !== "EAFNOSUPPORT") throw err; + listenOn((hostname = "0.0.0.0")); + } } catch (err) { - const isUnix = path != null; - process.nextTick( - emitErrorNextTick, - this, - formatListenError(err, isUnix ? path : hostname, isUnix ? undefined : port), - ); + const address = path ?? (fd === undefined ? hostname : null); + const { listenException } = require("internal/net/errors"); + process.nextTick(emitErrorNextTick, this, listenException(err, address, path != null ? -1 : port)); } return this; }; @@ -4130,7 +3580,6 @@ Server.prototype[kRealListen] = function ( writableAll, tls, contexts, - _onListen, fd, ) { // NOTE: accepted sockets are always allowHalfOpen:true at the native layer @@ -4139,20 +3588,21 @@ Server.prototype[kRealListen] = function ( if (reusePort) { exclusive = false; } + const serverOptions = this[bunSocketServerOptions]; + const listenOptions: any = { + tls, + allowHalfOpen: true, + reusePort: reusePort || serverOptions?.reusePort || false, + ipv6Only: ipv6Only || serverOptions?.ipv6Only || false, + exclusive: exclusive || serverOptions?.exclusive || false, + socket: serverHandlersFor(this), + data: this, + pauseOnConnect: this.pauseOnConnect, + ...(path ? { unix: path } : fd != null ? { fd, hostname } : { port, hostname }), + }; + $putByIdDirectPrivate(listenOptions, "defaultTimeout", false); + this._handle = Bun.listen(listenOptions); if (path) { - this._handle = Bun.listen({ - unix: path, - tls, - // Accepted sockets are always half-open natively; the stream layer - // implements allowHalfOpen=false (see kConnect / onSocketEnd). - allowHalfOpen: true, - reusePort: reusePort || this[bunSocketServerOptions]?.reusePort || false, - ipv6Only: ipv6Only || this[bunSocketServerOptions]?.ipv6Only || false, - exclusive: exclusive || this[bunSocketServerOptions]?.exclusive || false, - socket: serverHandlersFor(this), - data: this, - pauseOnConnect: this.pauseOnConnect, - }); // Mirror libuv uv_pipe_chmod: readableAll/writableAll relax the unix socket // file's group/other permission bits. Skipped on Windows and abstract // sockets (no filesystem entry). uSockets binds synchronously, so the file @@ -4173,32 +3623,6 @@ Server.prototype[kRealListen] = function ( throw e; } } - } else if (fd != null) { - this._handle = Bun.listen({ - fd, - hostname, - tls, - allowHalfOpen: true, - reusePort: reusePort || this[bunSocketServerOptions]?.reusePort || false, - ipv6Only: ipv6Only || this[bunSocketServerOptions]?.ipv6Only || false, - exclusive: exclusive || this[bunSocketServerOptions]?.exclusive || false, - socket: serverHandlersFor(this), - data: this, - pauseOnConnect: this.pauseOnConnect, - }); - } else { - this._handle = Bun.listen({ - port, - hostname, - tls, - allowHalfOpen: true, - reusePort: reusePort || this[bunSocketServerOptions]?.reusePort || false, - ipv6Only: ipv6Only || this[bunSocketServerOptions]?.ipv6Only || false, - exclusive: exclusive || this[bunSocketServerOptions]?.exclusive || false, - socket: serverHandlersFor(this), - data: this, - pauseOnConnect: this.pauseOnConnect, - }); } this._handle[owner_symbol] = this; @@ -4266,12 +3690,10 @@ function listenInCluster( readableAll, writableAll, flags, - options, path, hostname, tls, contexts, - onListen, ) { exclusive = !!exclusive; @@ -4310,12 +3732,10 @@ function listenInCluster( readableAll, writableAll, flags, - options, path, hostname, tls, contexts, - onListen, ); }); return; @@ -4333,7 +3753,6 @@ function listenInCluster( writableAll, tls, contexts, - onListen, fd, ); return; @@ -4348,7 +3767,6 @@ function listenInCluster( backlog, readableAll, writableAll, - ...options, sharedOnly: tls ? true : undefined, }; const listeningId = (server[kClusterListeningId] = (server[kClusterListeningId] || 0) + 1); @@ -4374,7 +3792,7 @@ function listenInCluster( try { // The reply is asynchronous: a setSecureContext() or addContext() made since listen() counts. if (tls) { - tls = server[bunTlsSymbol](port, hostname, false)[0]; + tls = server[bunTlsSymbol](hostname)[0]; contexts = tls.contexts; } server[kRealListen]( @@ -4388,7 +3806,6 @@ function listenInCluster( writableAll, tls, contexts, - onListen, sharedFd, ); handle.adopted = true; @@ -4447,7 +3864,7 @@ function onClusterConnection(err, clientHandle) { if (self.noDelay) socket[kSetNoDelay] = true; if (self.keepAlive) { socket[kSetKeepAlive] = true; - socket[kSetKeepAliveInitialDelay] = self.keepAliveInitialDelay; + socket[kSetKeepAliveInitialDelay] = keepAliveDelayOf(self); } socket.connect({ fd: clientHandle.fd, fdIsRawSocket: true, pauseOnConnect: self.pauseOnConnect }); const blockList = self.blockList; @@ -4507,6 +3924,12 @@ function initSocketHandle(self) { self._sockname = null; self[kclosed] = false; self[kended] = false; + self[kHungUp] = 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; @@ -4521,7 +3944,7 @@ function initSocketHandle(self) { // intercepts close on `socket._handle` and invokes it, so always pass one. function onSocketHandleClosed() {} -function closeSocketHandle(self, handle, isException, isCleanupPending = false) { +function closeSocketHandle(self, handle, isException, isCleanupPending = false, scheduleClose = setImmediate) { $debug("closeSocketHandle", isException, isCleanupPending); // Ahead of the setImmediate below: the transport's 'close' precedes this socket's. const transport = transportToDestroy(self); @@ -4531,7 +3954,7 @@ function closeSocketHandle(self, handle, isException, isCleanupPending = false) handle.close(onSocketHandleClosed); // Arguments instead of a closure, so no scope keeps the closed handle alive. if (isCleanupPending) setImmediate(emitCloseAndDetachHandle, self, isException, handle); - else setImmediate(emitClose, self, isException); + else scheduleClose(emitClose, self, isException); } function emitClose(self, isException) { @@ -4541,44 +3964,8 @@ function emitClose(self, isException) { function emitCloseAndDetachHandle(self, isException, handle) { emitClose(self, isException); - // self._handle may have been detached or replaced since. - handle.onread = noop; - if (self._handle === handle) { - self._handle = null; - self._sockname = null; - } -} - -// Reformat a native listen error to Node's "listen : " -// (Node uses exceptionWithHostPort). Only rewrites known uv codes; the code is -// already set natively. -// https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/net.js#L1899 -function uvListenErrorDescription(code) { - switch (code) { - case "EADDRINUSE": - return "address already in use"; - case "EACCES": - return "permission denied"; - case "EADDRNOTAVAIL": - return "address not available"; - case "EINVAL": - return "invalid argument"; - default: - return undefined; - } -} -function formatListenError(err, address, port) { - const desc = err && typeof err.code === "string" ? uvListenErrorDescription(err.code) : undefined; - if (desc) { - err.syscall = "listen"; - // Node's exceptionWithHostPort also exposes the failing address/port as - // own properties; user code commonly reads them off listen errors. - err.address = address; - if (port) err.port = port; - const where = port ? `${address}:${port}` : address; - err.message = `listen ${err.code}: ${desc}${where ? ` ${where}` : ""}`; - } - return err; + // connect() may have replaced it since. + if (self._handle === handle) detachSocket(self); } function checkBindError(err, port, handle) { diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 1a17c82d2b13..cc6071df410b 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1,22 +1,23 @@ // Hardcoded module "node:tls" -const { isArrayBufferView } = require("node:util/types"); +const { isAnyArrayBuffer, isArrayBufferView } = require("node:util/types"); const net = require("node:net"); const Duplex = require("internal/streams/duplex"); 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, - tlsStringToProtocolVersion, - secureProtocolToVersionRange, + nativeCiphersAndVersions, normalizePemKeyOption, processPfxOptions, + readSecureContextOptions, 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,9 @@ const parseCACertificates = $newCppFunction("NodeTLS.cpp", "parseCACertificates" const getTLSDefaultCiphers = $bindgen("rare_data.bind.ts", "getTLSDefaultCiphers"); const setTLSDefaultCiphers = $bindgen("rare_data.bind.ts", "setTLSDefaultCiphers"); +const getTLSDefaultCA = $bindgen("node_tls_binding.bind.ts", "getDefaultCA"); +const setTLSDefaultCA = $bindgen("node_tls_binding.bind.ts", "setDefaultCA"); +const getDefaultCACertificateTypes = $bindgen("node_tls_binding.bind.ts", "getDefaultCACertificateTypes"); const selectServerName = $newCppFunction("NodeTLS.cpp", "selectServerName", 2); let _VALID_CIPHERS_SET: Set | undefined; function getValidCiphersSet() { @@ -145,60 +151,47 @@ function getCipherListSelectors() { return _CIPHER_LIST_SELECTORS; } -function validateCiphers(ciphers: string, name: string = "options") { - // Set the cipher list and cipher suite before anything else because - // @SECLEVEL= changes the security level and that affects subsequent - // operations. - if (ciphers !== undefined && ciphers !== null) { - validateString(ciphers, `${name}.ciphers`); - - // TODO: right now we need this because we dont create the CTX before listening/connecting - // we need to change that in the future and let BoringSSL do the validation - const ciphersSet = getValidCiphersSet(); - const requested = StringPrototypeSplit.$call(ciphers, ":"); - let sawLegacyEntry = false; - let sawUsableEntry = false; - for (const r of requested) { - if (!r) continue; - // BoringSSL has no security levels: its cipher parser rejects - // @SECLEVEL with INVALID_COMMAND. Report that the way the native - // parser would, with Node's decomposed error shape. - if (StringPrototypeIncludes.$call(r, "@SECLEVEL")) { - const err = new Error("error:0f000076:SSL routines:OPENSSL_internal:INVALID_COMMAND") as Error & { - code: string; - library: string; - function: string; - reason: string; - }; - err.code = "ERR_SSL_INVALID_COMMAND"; - err.library = "SSL routines"; - err.function = "OPENSSL_internal"; - err.reason = "INVALID_COMMAND"; - throw err; - } - if (StringPrototypeStartsWith.$call(r, "TLS_")) continue; - sawLegacyEntry = true; - // OpenSSL cipher-list grammar: `!X`/`-X`/`+X` operators, `A+B` - // intersections, `@STRENGTH` directives and selector keywords - // (HIGH, PSK, aNULL, ...) are not literal cipher names — leave their - // evaluation to BoringSSL and assume they can contribute matches. - const first = StringPrototypeCharCodeAt.$call(r, 0); - if ( - first === 0x21 /* ! */ || - first === 0x2d /* - */ || - first === 0x2b /* + */ || - first === 0x40 /* @ */ || - StringPrototypeIncludes.$call(r, "+") || - getCipherListSelectors().has(r) || - ciphersSet.has(r) - ) { - sawUsableEntry = true; - } +// tls.connect() and tls.createServer() report a list that matches nothing in OpenSSL's words. A bare context reports BoringSSL's. +function checkCipherMatch(ciphers: string) { + const ciphersSet = getValidCiphersSet(); + const requested = StringPrototypeSplit.$call(ciphers, ":"); + let sawLegacyEntry = false; + let sawUsableEntry = false; + for (const r of requested) { + if (!r) continue; + // BoringSSL has no security levels, and its parser rejects @SECLEVEL with INVALID_COMMAND. This is that error in Node's shape. + if (StringPrototypeIncludes.$call(r, "@SECLEVEL")) { + const err = new Error("error:0f000076:SSL routines:OPENSSL_internal:INVALID_COMMAND") as Error & { + code: string; + library: string; + function: string; + reason: string; + }; + err.code = "ERR_SSL_INVALID_COMMAND"; + err.library = "SSL routines"; + err.function = "OPENSSL_internal"; + err.reason = "INVALID_COMMAND"; + throw err; } - if (sawLegacyEntry && !sawUsableEntry) { - throw $ERR_SSL_NO_CIPHER_MATCH(); + if (StringPrototypeStartsWith.$call(r, "TLS_")) continue; + sawLegacyEntry = true; + // Operators (`!X`, `-X`, `+X`, `A+B`), `@STRENGTH` and selector keywords (HIGH, PSK, aNULL) aren't cipher names: BoringSSL evaluates them. + const first = StringPrototypeCharCodeAt.$call(r, 0); + if ( + first === 0x21 /* ! */ || + first === 0x2d /* - */ || + first === 0x2b /* + */ || + first === 0x40 /* @ */ || + StringPrototypeIncludes.$call(r, "+") || + getCipherListSelectors().has(r) || + ciphersSet.has(r) + ) { + sawUsableEntry = true; } } + if (sawLegacyEntry && !sawUsableEntry) { + throw $ERR_SSL_NO_CIPHER_MATCH(); + } } const SymbolReplace = Symbol.replace; @@ -233,12 +226,6 @@ function parseCertString() { throwNotImplemented("Not implemented"); } -// Node.js reads NODE_TLS_REJECT_UNAUTHORIZED lazily (per connection), so a -// script can set it after loading the module and still have it apply. -function rejectUnauthorizedDefault() { - return process.env.NODE_TLS_REJECT_UNAUTHORIZED !== "0"; -} - // Mirrors Node's getAllowUnauthorized(): warn (once) when certificate // verification is disabled by NODE_TLS_REJECT_UNAUTHORIZED=0. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/options.js#L204-L215 @@ -437,12 +424,6 @@ function checkServerIdentity(hostname, cert) { // that used to live in this file. const NativeSecureContext = $bindgen("SecureContext.bind.ts", "SecureContext"); -// Node treats any falsy key/cert/ca as "not provided" (test-tls-options- -// boolean-check.js exercises false/0/""). The bindgen SSLConfigFile union only -// accepts null|string|ArrayBuffer|Blob|array, so coerce falsy → null before -// crossing into native so `{ key: false }` etc. doesn't throw -// ERR_INVALID_ARG_TYPE from the bindgen layer. - // SNI is a C string, so as in Node.js it ends at a NUL. checkServerIdentity() still gets the whole servername. function sniName(servername) { if (typeof servername !== "string") return servername; @@ -450,83 +431,31 @@ function sniName(servername) { return nul === -1 ? servername : StringPrototypeSlice.$call(servername, 0, nul); } +// `options` is a record of ours, never a caller's object, so it's normalized in place. function newNativeSecureContext(options, cached = false) { maybeWarnAboutExtraCACerts(); - // tls.createSecureContext() with no options still goes through the version - // translation below so the module-level DEFAULT_MIN/MAX_VERSION apply. - options = options == null ? {} : processPfxOptions(options); - const servername = options.servername; - if (sniName(servername) !== servername) options = { ...options, servername: sniName(servername) }; - // PKCS#12-embedded CAs extend the trust set after the context is built; a - // mutated context must not be the shared cached one. + options = processPfxOptions(options); + options.servername = sniName(options.servername); + // A bundle's CAs and the ticket keys change the context once it's built, so it can't be the shared cached one. const pfxExtraCAs = options._pfxExtraCACerts; - if (pfxExtraCAs) 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. - const ALPNProtocols = options.ALPNProtocols; - if (Array.isArray(ALPNProtocols)) { - const normalized: { ALPNProtocols?: Buffer } = {}; - convertALPNProtocols(ALPNProtocols, normalized); - options = { ...options, ALPNProtocols: normalized.ALPNProtocols }; - } - if (options) { - let { key, cert, ca } = options; - const normalizedKey = normalizePemKeyOption(key, options.passphrase); - if (normalizedKey !== key) { - key = normalizedKey; - options = { ...options, key }; - } - if (!key || !cert || !ca) { - options = { - ...options, - key: key || null, - cert: cert || null, - ca: ca || null, - }; - } - if (options.sessionTimeout == null) { - options = { ...options, sessionTimeout: 0 }; - } - if (options.ecdhCurve === undefined) { - options = { ...options, ecdhCurve: DEFAULT_ECDH_CURVE }; - } - const rejectUnauthorized = options.rejectUnauthorized; - if (rejectUnauthorized !== undefined && typeof rejectUnauthorized !== "boolean") { - options = { ...options, rejectUnauthorized: true }; - } - const allowPartialTrustChain = options.allowPartialTrustChain; - if (allowPartialTrustChain !== undefined && typeof allowPartialTrustChain !== "boolean") { - options = { ...options, allowPartialTrustChain: !!allowPartialTrustChain }; - } - if (options.honorCipherOrder) { - options = { ...options, secureOptions: options.secureOptions | 0 | SSL_OP_CIPHER_SERVER_PREFERENCE }; - } - } - if (options) { - // Read each option once. Translate minVersion/maxVersion/secureProtocol to - // the integer protocol range the native layer applies, so the bindings - // receive numbers, not the user-facing strings. When none are given the - // module-level tls.DEFAULT_MIN_VERSION / DEFAULT_MAX_VERSION apply, the - // way Node's createSecureContext does. - const { minVersion: optMinVersion, maxVersion: optMaxVersion, secureProtocol: optSecureProtocol } = options; - { - let minVersion, maxVersion; - const range = secureProtocolToVersionRange(optSecureProtocol); - if (range) { - minVersion = range[0]; - maxVersion = range[1]; - } else { - minVersion = tlsStringToProtocolVersion(optMinVersion ?? DEFAULT_MIN_VERSION); - maxVersion = tlsStringToProtocolVersion(optMaxVersion ?? DEFAULT_MAX_VERSION); - } - options = { ...options, minVersion, maxVersion }; - } - } + const ticketKeys = options.ticketKeys; + if (pfxExtraCAs || ticketKeys != null) cached = false; + // Node counts a falsy key, cert or ca as absent (test-tls-options-boolean-check.js). The native parser only takes null. + options.key = normalizePemKeyOption(options.key, options.passphrase) || null; + options.cert ||= null; + options.ca ||= null; + options.sessionTimeout ??= 0; + if (options.ecdhCurve === undefined) options.ecdhCurve = DEFAULT_ECDH_CURVE; + const rejectUnauthorized = options.rejectUnauthorized; + if (rejectUnauthorized !== undefined && typeof rejectUnauthorized !== "boolean") options.rejectUnauthorized = true; + if (options.allowPartialTrustChain !== undefined) options.allowPartialTrustChain = !!options.allowPartialTrustChain; + if (options.honorCipherOrder) options.secureOptions = options.secureOptions | 0 | SSL_OP_CIPHER_SERVER_PREFERENCE; + ObjectAssign(options, nativeCiphersAndVersions(options, DEFAULT_MIN_VERSION, DEFAULT_MAX_VERSION)); const ctx = (cached ? NativeSecureContext.intern : NativeSecureContext.createPrivate)(options); if (pfxExtraCAs) { for (const pem of pfxExtraCAs) ctx.addCACert(pem); } + if (ticketKeys != null) ctx.setTicketKeys(ticketKeys); return ctx; } @@ -535,43 +464,34 @@ var InternalSecureContext = class SecureContext { servername; constructor(options, cached = false) { - // When tls.setDefaultCACertificates() has installed an override and no - // explicit `ca` was given, use the override as the default CA set so the - // process-wide default applies on every construction path (the public - // createSecureContext(), the connect/TLSSocket path, addContext and - // setSecureContext), matching Node's secure-context default. - if (_defaultCACertificatesOverride !== undefined && (options == null || options.ca == null)) { - options = { ...options, ca: _defaultCACertificatesOverride }; - } - if (options) { - validateSecureContextOptions(options); - const cert = options.cert; - if (cert) throwOnInvalidTLSArray("options.cert", cert); - const key = options.key; - if (key) throwOnInvalidTLSArray("options.key", key); - const ca = options.ca; - if (ca) throwOnInvalidTLSArray("options.ca", ca); - const crl = options.crl; - if (crl) throwOnInvalidTLSArray("options.crl", crl); - if (options.servername != null && typeof options.servername !== "string") - throw new TypeError("servername argument must be an string"); - if (options.secureOptions != null && typeof options.secureOptions !== "number") - throw new TypeError("secureOptions argument must be an number"); - } - const requestedCiphers = options?.ciphers; - if (requestedCiphers && StringPrototypeIncludes.$call(requestedCiphers, "TLS_")) { - const { cipherList, tls13Only } = stripTls13CipherNames(requestedCiphers); - options = { ...options, ciphers: cipherList }; - if (tls13Only) options.minVersion = "TLSv1.3"; - } // The native handle (SSL_CTX wrapper) is what's memoised — not this JS // object — so per-call fields like `servername` come from THIS call's // options while the expensive SSL_CTX is shared. + this.servername = options.servername; this.context = newNativeSecureContext(options, cached); - this.servername = options?.servername; } }; +// Every check node:tls makes of a context's options. Each path to a context runs it once. +function validateContextOptions(options, matchCiphers) { + validateSecureContextOptions(options); + const { ciphers, cert, key, ca, crl, servername, secureOptions } = options; + if (matchCiphers && ciphers) checkCipherMatch(ciphers); + if (cert) throwOnInvalidTLSArray("options.cert", cert); + if (key) throwOnInvalidTLSArray("options.key", key); + if (ca) throwOnInvalidTLSArray("options.ca", ca); + if (crl) throwOnInvalidTLSArray("options.crl", crl); + if (servername != null) validateString(servername, "options.servername"); + if (secureOptions != null) validateNumber(secureOptions, "options.secureOptions"); +} + +// For options as a caller gave them. tls.Server reads and checks its own before it stages them. +function newSecureContext(options, cached, matchCiphers = false) { + options = readSecureContextOptions(options || {}); + validateContextOptions(options, matchCiphers); + return new InternalSecureContext(options, cached); +} + function SecureContext(options): void { return createSecureContext(options) as never; } @@ -580,21 +500,12 @@ InternalSecureContext.prototype.constructor = SecureContext; function createSecureContext(options) { if (options instanceof InternalSecureContext) return options; - // The setDefaultCACertificates() override is applied inside the - // InternalSecureContext constructor so every construction path honors it. // The native handle (SSL_CTX) is memoised inside `NativeSecureContext.intern` // by the per-VM `SSLContextCache`, so no JS-side hashing here. The JS wrapper // is built fresh because it carries the per-call `servername`. // The user-facing constructor owns its SSL_CTX exclusively so addCACert // cannot leak across contexts; a TLSSocket's own and addContext()'s stay cached. - return new InternalSecureContext(options); -} - -// Translate some fields from the handle's C-friendly format into more idiomatic -// javascript object representations before passing them back to the user. Can -// be used on any cert object, but changing the name would be semver-major. -function translatePeerCertificate(c) { - return c; + return newSecureContext(options, false); } const ksecureContext = Symbol("ksecureContext"); @@ -602,7 +513,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,7 +521,26 @@ const buntls = Symbol.for("::buntls::"); const kNativeSecureContextCtor = Symbol.for("::buntlsnativesecurecontextctor::"); const kUpgradeServerTLS = Symbol.for("::bunUpgradeServerTLS::"); +// `target` is a tls.Server or a server-side TLSSocket: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L929 +function takeServerCallbacks(target, options) { + const sniCallback = options.SNICallback; + if (sniCallback) { + validateFunction(sniCallback, "options.SNICallback"); + target._SNICallback = sniCallback; + } + const alpnCallback = options.ALPNCallback; + if (alpnCallback) { + validateFunction(alpnCallback, "options.ALPNCallback"); + // Node forbids combining the dynamic callback with a static list. + if (options.ALPNProtocols) { + throw $ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS(); + } + target._ALPNCallback = alpnCallback; + } +} + function TLSSocket(socket?, options?) { + this[kAcceptedTLS] = null; this[ksecureContext] = undefined; this.ALPNProtocols = undefined; this[kcheckServerIdentity] = undefined; @@ -634,15 +563,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) { @@ -650,19 +578,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 @@ -679,47 +607,31 @@ function TLSSocket(socket?, options?) { this.isServer = isServer; this._requestCert = !!options.requestCert || !isServer; - // A custom SNICallback must be a function — but Node only validates it on the - // server side (it is meaningless for a client), inside the isServer branch. - // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/wrap.js#L929 - if (isServer) { - const sniCallback = options.SNICallback; - if (sniCallback != null) { - validateFunction(sniCallback, "options.SNICallback"); - this._SNICallback = sniCallback; - } - const alpnCallback = options.ALPNCallback; - if (alpnCallback != null) { - validateFunction(alpnCallback, "options.ALPNCallback"); - if (options.ALPNProtocols) { - throw $ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS(); - } - this._ALPNCallback = alpnCallback; - } - } + // They mean nothing to a client, so Node doesn't look at a client's. + if (isServer) takeServerCallbacks(this, options); - this.ciphers = options.ciphers; - if (this.ciphers) { - validateCiphers(options.ciphers); - } - - if (typeof options === "object") { - const { ALPNProtocols } = options; - if (ALPNProtocols) { - convertALPNProtocols(ALPNProtocols, this); + const { ALPNProtocols, secureContext } = options; + if (ALPNProtocols) { + convertALPNProtocols(ALPNProtocols, this); + // What convertALPNProtocols() leaves alone, Node's setALPNProtocols() refuses. A string or an ArrayBuffer is ignored, as on 1.4.4. + if (this.ALPNProtocols === undefined && typeof ALPNProtocols !== "string" && !isAnyArrayBuffer(ALPNProtocols)) { + throw new TypeError("Must give a Buffer as first argument"); } } - // Internal path: keep the per-digest cache (the user-facing constructors, - // createSecureContext() and new tls.SecureContext(), own theirs exclusively). - const secureContext = options.secureContext; - if (secureContext && !(secureContext.context instanceof NativeSecureContext)) { - throw $ERR_TLS_INVALID_CONTEXT("context must be a SecureContext"); + if (secureContext) { + if (!(secureContext.context instanceof NativeSecureContext)) { + throw $ERR_TLS_INVALID_CONTEXT("context must be a SecureContext"); + } + // Node doesn't look at `ciphers` next to a context. 1.4.4 checked it. + const ciphers = options.ciphers; + if (ciphers) { + validateString(ciphers, "options.ciphers"); + checkCipherMatch(ciphers); + } } - this[ksecureContext] = secureContext || new InternalSecureContext(options, true); - this.authorized = false; + // Cached by digest. createSecureContext() and new tls.SecureContext() own theirs. + this[ksecureContext] = secureContext || newSecureContext(options, true, true); this.secureConnecting = true; - this._secureEstablished = false; - this._securePending = true; const checkServerIdentityOption = options.checkServerIdentity; if (checkServerIdentityOption !== undefined) { validateFunction(checkServerIdentityOption, "options.checkServerIdentity"); @@ -730,7 +642,7 @@ function TLSSocket(socket?, options?) { // Both upgrades live in net.ts (module-private state); _handle stays unset until one hands back the TLS handle. if (isNetSocketOrDuplex) { if (isServer) { - this[kUpgradeServerTLS](socket, this[buntls](null, null)); + this[kUpgradeServerTLS](socket, this[buntls](null)); } else { // The rule of tls.connect(): an untrusted certificate is rejected unless the caller passes `false`. this._rejectUnauthorized = ObjectPrototypeHasOwnProperty.$call(options, "rejectUnauthorized") @@ -915,8 +827,9 @@ TLSSocket.prototype.setKeyCert = function setKeyCert(context) { // Serve this connection's identity from the given context (Node calls this // 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); + const ctx = context?.context ? context : newSecureContext(context, true); + // 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) { @@ -966,23 +879,15 @@ TLSSocket.prototype.getPeerCertificate = function getPeerCertificate(detailed) { // The native parameter means "abbreviated" - the inverse of Node's // `detailed`. Detailed requests get the whole chain with // issuerCertificate links; everything else gets just the leaf. - const cert = arguments.length < 1 ? handle.getPeerCertificate?.() : handle.getPeerCertificate?.(!detailed); - if (cert) { - return translatePeerCertificate(cert); - } - return {}; + return (arguments.length < 1 ? handle.getPeerCertificate?.() : handle.getPeerCertificate?.(!detailed)) || {}; } return null; }; TLSSocket.prototype.getCertificate = function getCertificate() { - if (!this._handle) return null; - const cert = this._handle.getCertificate?.(); - if (cert) { - // It's not a peer cert, but the formatting is identical. - return translatePeerCertificate(cert); - } - return {}; + const handle = this._handle; + if (!handle) return null; + return handle.getCertificate?.() || {}; }; TLSSocket.prototype.getPeerX509Certificate = function getPeerX509Certificate() { @@ -1023,13 +928,13 @@ TLSSocket.prototype.getX509Certificate = function getX509Certificate() { return this._handle?.getX509Certificate?.(); }; -TLSSocket.prototype[buntls] = function (port, host) { +TLSSocket.prototype[buntls] = function (host) { const ctx = this[ksecureContext]; // RFC 6066 forbids IP literals in SNI. Match Node.js: only default servername to 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 { @@ -1039,7 +944,6 @@ TLSSocket.prototype[buntls] = function (port, host) { session: this[ksession], rejectUnauthorized: this._rejectUnauthorized, requestCert: this._requestCert, - ciphers: this.ciphers && stripTls13CipherNames(this.ciphers).cipherList, // Hand the native SSL_CTX wrapper to upgradeTLS so it can up_ref instead // of rebuilding from raw cert/key bytes. secureContext: ctx?.context, @@ -1048,38 +952,9 @@ TLSSocket.prototype[buntls] = function (port, host) { }; }; -let CLIENT_RENEG_LIMIT = 3, - CLIENT_RENEG_WINDOW = 600; - -// Not interned: one SSL_CTX is one session cache and one set of ticket keys, and no other server or client may share them. -function buildSharedCreds(server, options) { - return new InternalSecureContext({ - ...options, - pfx: undefined, - _pfxExtraCACerts: undefined, - key: server.key, - cert: server.cert, - ca: server.ca, - crl: server.crl, - ciphers: server.ciphers, - secureOptions: server.secureOptions, - allowPartialTrustChain: server.allowPartialTrustChain, - sessionTimeout: server.sessionTimeout, - sigalgs: server.sigalgs, - ecdhCurve: server.ecdhCurve ?? DEFAULT_ECDH_CURVE, - passphrase: server.passphrase, - secureProtocol: server.secureProtocol, - minVersion: server.minVersion, - maxVersion: server.maxVersion, - // The verify mode accepted sockets inherit. setSecureContext() does not name them. - requestCert: server._requestCert === true, - rejectUnauthorized: server._rejectUnauthorized, - }); -} - // 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 { @@ -1095,24 +970,7 @@ function Server(options, secureConnectionListener): void { } else if (options != null && typeof options !== "object") { throw $ERR_INVALID_ARG_TYPE("options", "object", options); } - // A custom SNICallback must be a function. - // 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) { - validateFunction(sniCallback, "options.SNICallback"); - this._SNICallback = sniCallback; - } - const alpnCallback = options.ALPNCallback; - if (alpnCallback != null) { - validateFunction(alpnCallback, "options.ALPNCallback"); - // Node forbids combining the dynamic callback with a static list. - if (options.ALPNProtocols) { - throw $ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS(); - } - this._ALPNCallback = alpnCallback; - } - } + if (options != null) takeServerCallbacks(this, options); // The listener belongs on "secureConnection", not "connection": do not let // the net.Server constructor register it. @@ -1124,6 +982,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; @@ -1143,7 +1002,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 @@ -1154,7 +1013,7 @@ function Server(options, secureConnectionListener): void { throw new TypeError("hostname must be a string"); } if (!(context instanceof InternalSecureContext)) { - context = new InternalSecureContext(context, true); + context = newSecureContext(context, true); } const handle = this._handle; if (handle) { @@ -1168,192 +1027,98 @@ function Server(options, secureConnectionListener): void { }; let listenerContext; + // What the last setSecureContext() that named options built its context from. + let contextOptions: Record = { __proto__: null }; this.setSecureContext = function (options) { - const serverTLSOptions = options; - const next: Record = { __proto__: null }; - if (options instanceof InternalSecureContext) { - 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, - }; - } - validateSecureContextOptions(options); - options = processPfxOptions(options); - - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1431-L1474: a falsy key, cert, ca or crl counts as absent - let cert = options.cert || undefined; - // Assign unconditionally so a later setSecureContext() that omits an - // option clears the previous call's value (Node resets each omitted - // field) instead of silently keeping stale key material. - if (cert) { - throwOnInvalidTLSArray("options.cert", cert); - } - next.cert = cert; - - let key = options.key || undefined; - if (key) { - throwOnInvalidTLSArray("options.key", key); - } - next.key = key; - - let ca = options.ca || undefined; - // InternalSecureContext applies tls.setDefaultCACertificates() itself; a named pipe listener builds from this field. - if (_defaultCACertificatesOverride !== undefined && ca == null) { - ca = _defaultCACertificatesOverride; - } - // buildSharedCreds() drops them, so they go into `ca`: an mTLS server verifies clients against the bundle's own CA chain. - const pfxExtraCAs = options._pfxExtraCACerts; + const isContext = options instanceof InternalSecureContext; + // An instance names no key, as in Node. + let given = isContext ? {} : options; + let staged = contextOptions; + if (given) { + given = readSecureContextOptions(given); + // A falsy value counts as absent: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1423-L1518 + given.pfx ||= undefined; + given.key ||= undefined; + given.passphrase ||= undefined; + given.cert ||= undefined; + given.clientCertEngine ||= undefined; + given.ca ||= undefined; + given.minVersion ||= undefined; + given.maxVersion ||= undefined; + given.secureProtocol ||= undefined; + given.crl ||= undefined; + given.ciphers ||= undefined; + given.secureOptions ||= undefined; + given.sessionTimeout ||= undefined; + given.ticketKeys ||= undefined; + given.servername ||= undefined; + validateContextOptions(given, true); + given = processPfxOptions(given); + + let ca = given.ca; + // An mTLS server verifies clients against the bundle's own CA chain, so it goes into `ca`. + const pfxExtraCAs = given._pfxExtraCACerts; if (pfxExtraCAs?.length) { + // They extend tls.setDefaultCACertificates()'s list, which a `ca` of only them would replace. + ca ??= getTLSDefaultCA(); ca = ca == null ? pfxExtraCAs : Array.isArray(ca) ? [...ca, ...pfxExtraCAs] : [ca, ...pfxExtraCAs]; } - if (ca) { - throwOnInvalidTLSArray("options.ca", ca); - } - next.ca = ca; - - const crl = options.crl || undefined; - if (crl) { - throwOnInvalidTLSArray("options.crl", crl); - } - next.crl = crl; - - next.allowPartialTrustChain = !!options.allowPartialTrustChain; - - next.sessionTimeout = options.sessionTimeout; - - const sigalgs = options.sigalgs; - if (sigalgs !== undefined && sigalgs !== null) { - validateString(sigalgs, "options.sigalgs"); - if (sigalgs === "") throw $ERR_INVALID_ARG_VALUE("options.sigalgs", sigalgs); - } - next.sigalgs = sigalgs; - - const ecdhCurve = options.ecdhCurve; - if (ecdhCurve !== undefined) validateString(ecdhCurve, "options.ecdhCurve"); - next.ecdhCurve = ecdhCurve; - - let passphrase = options.passphrase; - if (passphrase && typeof passphrase !== "string") { - throw $ERR_INVALID_ARG_TYPE("options.passphrase", "string", passphrase); - } - next.passphrase = passphrase; - - let servername = options.servername; - if (servername && typeof servername !== "string") { - throw $ERR_INVALID_ARG_TYPE("options.servername", "string", servername); - } - next.servername = servername; - - let secureOptions = options.secureOptions || 0; - if (secureOptions && typeof secureOptions !== "number") { - throw $ERR_INVALID_ARG_TYPE("options.secureOptions", "number", secureOptions); - } - if (options.honorCipherOrder !== false) secureOptions |= SSL_OP_CIPHER_SERVER_PREFERENCE; - next.secureOptions = secureOptions; - - const ciphers = options.ciphers; - if (typeof ciphers !== "undefined") { - if (typeof ciphers !== "string") { - throw $ERR_INVALID_ARG_TYPE("options.ciphers", "string", ciphers); - } - - validateCiphers(ciphers); - } - // Unconditional so an omitted `ciphers` clears the previous value. - next.ciphers = options.ciphers; - - // Pin the protocol version range the server will negotiate. - // validateSecureContextOptions already rejected unknown method names. - // Assign unconditionally so a later setSecureContext() without these - // options clears the previous call's version constraints instead of - // re-applying them to the next context built. - next.secureProtocol = options.secureProtocol; - next.minVersion = options.minVersion; - next.maxVersion = options.maxVersion; + let secureOptions = given.secureOptions || 0; + if (given.honorCipherOrder !== false) secureOptions |= SSL_OP_CIPHER_SERVER_PREFERENCE; + + // Names every field, so an option this call omits clears the previous call's value, as in Node. + staged = { + __proto__: null, + key: given.key, + cert: given.cert, + ca, + crl: given.crl, + allowPartialTrustChain: !!given.allowPartialTrustChain, + sessionTimeout: given.sessionTimeout, + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1482-L1483: omitting it keeps the previous keys + ticketKeys: given.ticketKeys || this.ticketKeys, + sigalgs: given.sigalgs, + ecdhCurve: given.ecdhCurve, + passphrase: given.passphrase, + servername: given.servername, + secureOptions, + ciphers: given.ciphers, + secureProtocol: given.secureProtocol, + minVersion: given.minVersion, + maxVersion: given.maxVersion, + }; } - 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); - // 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. + // Not interned: no other server or client may share its session cache and ticket keys. Throws on material BoringSSL rejects, before any field changes. + const own = new InternalSecureContext({ + ...given, + pfx: undefined, + _pfxExtraCACerts: undefined, + ...staged, + // The verify mode accepted sockets inherit. setSecureContext() does not name them. + requestCert: this._requestCert === true, + rejectUnauthorized: this._rejectUnauthorized, + }); + // Accepted sockets take their verify mode from the context, so the listener's is always this server's own. listenerContext = own.context; - const handle = this._handle; - if (handle && options) { - setListenerSecureContext(handle, listenerContext); - } - if (options) { - this.cert = next.cert; - this.key = next.key; - this.ca = next.ca; - this.crl = next.crl; - this.allowPartialTrustChain = next.allowPartialTrustChain; - this.sessionTimeout = next.sessionTimeout; - this.sigalgs = next.sigalgs; - this.ecdhCurve = next.ecdhCurve; - this.passphrase = next.passphrase; - this.servername = next.servername; - this.secureOptions = next.secureOptions; - this.ciphers = next.ciphers; - this.secureProtocol = next.secureProtocol; - this.minVersion = next.minVersion; - this.maxVersion = next.maxVersion; + if (given) { + const handle = this._handle; + if (handle) setListenerSecureContext(handle, listenerContext); + contextOptions = staged; + ObjectAssign(this, staged); } - this._sharedCreds = isContext ? serverTLSOptions : own; + this._sharedCreds = isContext ? options : own; }; - this[buntls] = function (port, host, isClient) { - const requestCert = isClient ? true : this._requestCert; + this[buntls] = function (host) { return [ { serverName: sniName(this.servername || host || "localhost"), - // What a socket listener serves. Only a named pipe's still builds its own from the fields below. secureContext: listenerContext, - key: normalizePemKeyOption(this.key, this.passphrase), - cert: this.cert, - ca: this.ca, - crl: this.crl, - allowPartialTrustChain: this.allowPartialTrustChain, - sessionTimeout: this.sessionTimeout ?? 0, - sigalgs: this.sigalgs, - ecdhCurve: this.ecdhCurve ?? DEFAULT_ECDH_CURVE, - passphrase: this.passphrase, - secureOptions: this.secureOptions, rejectUnauthorized: this._rejectUnauthorized, - requestCert, + requestCert: this._requestCert, ALPNProtocols: this.ALPNProtocols, - clientRenegotiationLimit: CLIENT_RENEG_LIMIT, - clientRenegotiationWindow: CLIENT_RENEG_WINDOW, - contexts: contexts, - // Translate minVersion/maxVersion/secureProtocol to the integer - // protocol range the native layer applies (secureProtocol wins, like - // Node's SecureContext::Init). When none are given the module-level - // tls.DEFAULT_MIN_VERSION / DEFAULT_MAX_VERSION apply. - ...(() => { - const processed = this.ciphers && stripTls13CipherNames(this.ciphers); - let minVersion, maxVersion; - const range = secureProtocolToVersionRange(this.secureProtocol); - if (range) { - minVersion = range[0]; - maxVersion = range[1]; - } else { - const min = processed && processed.tls13Only ? "TLSv1.3" : (this.minVersion ?? DEFAULT_MIN_VERSION); - minVersion = tlsStringToProtocolVersion(min); - maxVersion = tlsStringToProtocolVersion(this.maxVersion ?? DEFAULT_MAX_VERSION); - } - return { ciphers: processed && processed.cipherList, minVersion, maxVersion }; - })(), + contexts, }, TLSSocket, ]; @@ -1368,12 +1133,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, @@ -1403,18 +1168,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) { @@ -1464,19 +1229,22 @@ function normalizeConnectArgs(listArgs) { function connect(...args) { let normal = normalizeConnectArgs(args); const options = normal[0]; - const { ALPNProtocols, servername } = options as { ALPNProtocols?: unknown; servername?: string }; - - // The TLSSocket applies the NODE_TLS_REJECT_UNAUTHORIZED default itself - // (rejectUnauthorizedDefault); this call exists to emit Node's one-time - // process warning when the env var disables verification, like Node's - // connect() does: - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1730 - getAllowUnauthorized(); - - if (ObjectPrototypeHasOwnProperty.$call(options, "checkServerIdentity")) { - // Node validates whenever the key is present - an explicit `undefined` - // throws ERR_INVALID_ARG_TYPE (test-tls-basic-validations). - validateFunction(options.checkServerIdentity, "options.checkServerIdentity"); + const servername = options.servername as string | undefined; + + // Read per connection, and it warns even if `rejectUnauthorized` overrides it: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1730 + const allowUnauthorized = getAllowUnauthorized(); + + // Node rejects an own key that is `undefined` (test-tls-basic-validations). The TLSSocket constructor checks every other value. + if ( + options.checkServerIdentity === undefined && + ObjectPrototypeHasOwnProperty.$call(options, "checkServerIdentity") + ) { + validateFunction(undefined, "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)) { @@ -1487,19 +1255,26 @@ function connect(...args) { ); } - const hasOwnRejectUnauthorized = ObjectPrototypeHasOwnProperty.$call(options, "rejectUnauthorized"); - const rejectUnauthorized = hasOwnRejectUnauthorized + const rejectUnauthorized = ObjectPrototypeHasOwnProperty.$call(options, "rejectUnauthorized") ? options.rejectUnauthorized !== false - : rejectUnauthorizedDefault(); + : !allowUnauthorized; - const connectOptions: Record = { checkServerIdentity, ...options, rejectUnauthorized }; + const connectOptions: Record = { + checkServerIdentity, + ...options, + // Node's spread would drop an inherited one, which 1.4.4 took. + ALPNProtocols: options.ALPNProtocols, + rejectUnauthorized, + }; if (!ObjectPrototypeHasOwnProperty.$call(options, "ciphers") || connectOptions.ciphers == null) { connectOptions.ciphers = getDefaultCiphers(); } normal[0] = connectOptions; - if (ALPNProtocols) { - 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); @@ -1507,7 +1282,6 @@ function connect(...args) { // to handle: node releases control before the connection is even started. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1798 tlssock._releaseControl(); - tlssock._rejectUnauthorized = rejectUnauthorized; // Honor the `timeout` option here: Socket.prototype.connect does not (only // the net.createConnection factory does), so tls.connect applies it // explicitly, exactly like Node's tls connect. @@ -1584,35 +1358,17 @@ function cacheBundledRootCertificates(): string[] { bundledRootCertificates ||= getBundledRootCertificates() as string[]; return bundledRootCertificates; } -const getCAStore = $bindgen("node_tls_binding.bind.ts", "getCAStore"); let defaultCACertificates: string[] | undefined; function cacheDefaultCACertificates() { if (defaultCACertificates) return defaultCACertificates; defaultCACertificates = []; - // As in Node, what OpenSSL's default certificate file and directory hold is not listed. - // https://github.com/nodejs/node/blob/v26.3.0/lib/tls.js#L157 - const store = getCAStore(); - if (store !== "openssl") { - 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 (store === "system" || process.env.NODE_USE_SYSTEM_CA === "1") { - const system = cacheSystemCACertificates(); - for (let i = 0; i < system.length; ++i) { - ArrayPrototypePush.$call(defaultCACertificates, system[i]); - } - } - } - - if (process.env.NODE_EXTRA_CA_CERTS) { - const extra = cacheExtraCACertificates(); - for (let i = 0; i < extra.length; ++i) { - ArrayPrototypePush.$call(defaultCACertificates, extra[i]); + const types = getDefaultCACertificateTypes(); + for (let i = 0; i < types.length; ++i) { + const certs = getCACertificates(types[i]); + for (let j = 0; j < certs.length; ++j) { + ArrayPrototypePush.$call(defaultCACertificates, certs[j]); } } @@ -1632,22 +1388,13 @@ function cacheExtraCACertificates(): string[] { return extraCACertificates; } -// Runtime override for the "default" CA certificate set, installed by -// tls.setDefaultCACertificates(). undefined = no override (use the real -// bundled/system default). Only affects type "default"/implicit — "bundled", -// "system" and "extra" are unchanged. -// https://github.com/nodejs/node/blob/main/lib/internal/tls/secure-context.js -let _defaultCACertificatesOverride: Array | undefined; - type CACertInput = string | NodeJS.ArrayBufferView; // tls.setDefaultCACertificates(certs) // https://github.com/nodejs/node/blob/v25.2.1/lib/tls.js#L202 // Node validates `certs` as an Array (its ERR_INVALID_ARG_TYPE renders the // 'Array' name as "an instance of Array"; Bun's validateArray renders the same // 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. +// then hands the certs to the native root store. function setDefaultCACertificates(certs: ReadonlyArray): void { if (!$isArray(certs)) { let received: string; @@ -1673,7 +1420,7 @@ function setDefaultCACertificates(certs: ReadonlyArray): void { if (normalized.length === 0 && snapshot.length > 0) { throw $ERR_CRYPTO_OPERATION_FAILED("No valid certificates found in the provided array"); } - _defaultCACertificatesOverride = normalized; + setTLSDefaultCA(normalized); } function getCACertificates(type = "default") { @@ -1681,10 +1428,7 @@ function getCACertificates(type = "default") { switch (type) { case "default": - if (_defaultCACertificatesOverride !== undefined) { - return _defaultCACertificatesOverride.slice(); - } - return cacheDefaultCACertificates(); + return getTLSDefaultCA() ?? cacheDefaultCACertificates(); case "bundled": return cacheBundledRootCertificates(); case "system": @@ -1703,8 +1447,8 @@ function getDefaultCiphers() { } const tlsExports = { - CLIENT_RENEG_LIMIT, - CLIENT_RENEG_WINDOW, + CLIENT_RENEG_LIMIT: 3, + CLIENT_RENEG_WINDOW: 600, connect, convertALPNProtocols, createSecureContext, @@ -1714,7 +1458,8 @@ const tlsExports = { }, set DEFAULT_CIPHERS(value) { if (value) { - validateCiphers(value, "value"); + validateString(value, "value.ciphers"); + checkCipherMatch(value); // filter out TLS_ ciphers value = stripTls13CipherNames(value).cipherList; // Throws ERR_SSL_NO_CIPHER_MATCH for a list such as "!aNULL": every default context is built from what is stored. diff --git a/src/js/private.d.ts b/src/js/private.d.ts index 2359db7aa4f9..8228d2b31789 100644 --- a/src/js/private.d.ts +++ b/src/js/private.d.ts @@ -89,8 +89,8 @@ declare module "bun" { namespace Serve { interface NodeHTTPServeOptions extends HostnamePortServeOptions { unix?: string; + fd?: number; onNodeHTTPRequest( - bunServer: Server, url: string, method: string, dispatchBits: number, @@ -1174,6 +1174,8 @@ declare namespace $Node { /** -2: shut down, -1: detached, 0: closed, 1: established, 2: connecting. */ readonly readyState: number; readonly bytesWritten: number; + /** The bytes that the kernel has not taken yet, sealed TLS records too. */ + readonly writeQueueSize: number; readonly fd: number; readonly listener: ListenerHandle | undefined; readonly authorized: boolean; @@ -1266,20 +1268,26 @@ declare namespace $Node { /** The native `H2FrameParser` of `node:http2`: src/runtime/api/bun/h2_frame_parser.bind.ts, h2_frame_parser.rs. */ interface H2FrameParser { - /** The stream's id, or -1. `undefined` where the session has gone. */ request( streamId: number, context: unknown, headers: object, sensitiveNames: object, options?: object, - ): number | undefined; - setNativeSocket(socket: unknown): void; + informational?: boolean, + ): undefined; + /** Throws what `request()` would throw for these headers. */ + validateRequest(headers: object): undefined; + setNativeSocket(socket: unknown): undefined; /** `false`: too many pings wait for their acknowledgement. */ - ping(payload: ArrayBufferView): boolean; - altsvc(origin: string, alt: string, streamId: number): void; - origin(origins: string | string[]): void; - goaway(code: number, lastStreamId?: number, opaqueData?: ArrayBufferView): void; + ping(payload: ArrayBuffer | SharedArrayBuffer | ArrayBufferView): boolean; + altsvc(origin: string, alt: string, streamId: number): undefined; + origin(origins: string | string[]): undefined; + goaway( + code: number, + lastStreamId: number, + opaqueData: ArrayBuffer | SharedArrayBuffer | ArrayBufferView, + ): undefined; getCurrentState(): { effectiveLocalWindowSize: number; effectiveRecvDataLength: number; @@ -1292,14 +1300,14 @@ declare namespace $Node { outboundQueueSize: number; }; getFrameCounters(): { framesReceived: number; framesSent: number }; - settings(settings: object): boolean; - setLocalWindowSize(windowSize: number): void; - read(data: ArrayBufferView): void; + settings(settings: object): undefined; + setLocalWindowSize(windowSize: number): undefined; + read(data: ArrayBuffer | SharedArrayBuffer | ArrayBufferView): undefined; /** How many bytes still wait. */ flush(): number; - detach(): void; - rstStream(streamId: number, code: number): true | undefined; - /** `false`: there is no such stream. Otherwise what became of the write. */ + detach(keepOutput?: boolean): undefined; + rstStream(streamId: number, code: number): undefined; + /** `false`: no such stream can send, and the parser calls `callback`. Otherwise what became of the write. */ writeStream( streamId: number, data: string | ArrayBufferView, @@ -1308,27 +1316,32 @@ declare namespace $Node { callback: (error?: Error | null) => void, deferCallback?: boolean, ): number | false; - sendTrailers(streamId: number, headers: object, sensitiveNames: object): void; - noTrailers(streamId: number): void; - setStreamReading(streamId: number, reading: boolean): void; + sendTrailers(streamId: number, headers: object, sensitiveNames: object): undefined; + noTrailers(streamId: number): undefined; + setStreamReading(streamId: number, reading: boolean): undefined; getStreamContext(streamId: number): unknown; - setStreamContext(streamId: number, context: unknown): void; - getEndAfterHeaders(streamId: number): boolean; - getStreamState(streamId: number): { - localWindowSize: number; - state: number; - localClose: number; - remoteClose: number; - sumDependencyWeight: number; - weight: number; - }; + setStreamContext(streamId: number, context: unknown): undefined; + /** `undefined`: there is no such stream. */ + getStreamState(streamId: number): + | { + localWindowSize: number; + state: number; + localClose: number; + remoteClose: number; + sumDependencyWeight: number; + weight: number; + } + | undefined; + hasOpenStreams(): boolean; + hasRejectedSend(): boolean; + isStreamClosed(streamId: number): boolean; + releaseStream(streamId: number): boolean; bufferSize(): number; - setNextStreamID(id: number): void; - /** Calls `callback` with the context of every stream that has one. */ - forEachStream(callback: (stream: never) => void, thisValue?: unknown): void; - emitErrorToAllStreams(code: number): void; - emitAbortToAllStreams(): void; - /** -1 when the ids have run out. */ + setNextStreamID(id: number): undefined; + /** `closed`: the closed streams. Otherwise those whose close was not delivered yet. Only streams with a context. */ + forEachStream(callback: (stream: never, isClosed: boolean) => void, closed?: boolean): undefined; + emitErrorToAllStreams(code: number): undefined; + /** -1: the ids have run out. 0: the peer's limit of concurrent streams is reached. */ getNextStream(): number; /** The promised id, or -1. */ pushPromise(streamId: number, promisedId: number, headers: object, sensitiveNames: object): number; @@ -1372,6 +1385,8 @@ declare namespace $Node { /** The native `SecureContext`, an SSL_CTX: src/runtime/api/bun/SecureContext.bind.ts, SecureContext.rs. */ interface NativeSecureContext { addCACert(pem: string | ArrayBufferView): void; + getTicketKeys(): ArrayBuffer | ArrayBufferView; + setTicketKeys(keys: ArrayBuffer | SharedArrayBuffer | ArrayBufferView): void; } interface NativeSecureContextConstructor { new (options?: object): NativeSecureContext; @@ -1476,7 +1491,6 @@ interface $BunModules { "internal/html": $ModuleValue; "internal/http": $ModuleValue; "internal/http/FakeSocket": $ModuleValue; - "internal/http1_server_fallback": $ModuleValue; "internal/inspector/cdp": $ModuleValue; "internal/linkedlist": $ModuleValue; "internal/net/isIP": $ModuleValue; @@ -2364,16 +2378,14 @@ declare namespace $Node { [name: string]: string | string[] | undefined; } - /** What `ServerResponse` calls on the object that puts its bytes on the wire: a `NodeHTTPResponse`, or the stand-in of `internal/http1_server_fallback`. */ + /** What `ServerResponse` calls on the object that puts its bytes on the wire. */ interface HttpResponseHandle { /** `NodeHTTPResponseFlags` */ readonly flags: number; readonly finished: boolean; - readonly aborted: boolean; readonly bufferedAmount: number; get onwritable(): HttpDrainCallback | undefined; set onwritable(callback: HttpDrainCallback | null | undefined); - cork(callback: () => void): void; writeContinue(): void; writeInformational(chunk: HttpChunk, encoding?: string | null): void; writeHead( @@ -2394,19 +2406,26 @@ declare namespace $Node { autoHeaderBits: number, keepAliveTimeoutSecs: number, ): number; - /** Negative when the socket did not take all of it: `callback` runs once it has drained. */ - write( + /** The head and the first chunk. The answer is that of `write()`. */ + writeHeadAndWrite( + statusCode: number, + statusMessage: string | undefined, + headers: string[], chunk: HttpChunk | null | undefined, encoding: string | null | undefined, callback: HttpDrainCallback | null | undefined, strictContentLength: number | undefined, + autoHeaderBits: number, + keepAliveTimeoutSecs: number, ): number; - end( + /** Negative when the socket did not take all of it: `callback` runs once it has drained. */ + write( chunk: HttpChunk | null | undefined, encoding: string | null | undefined, - callback: undefined, + callback: HttpDrainCallback | null | undefined, strictContentLength: number | undefined, ): number; + end(chunk?: HttpChunk | null, encoding?: string | null, callback?: undefined, strictContentLength?: number): number; flushHeaders(): void; abort(): void; } @@ -2426,16 +2445,11 @@ declare namespace $Node { ): number | undefined; /** `NodeHTTPBodyReadState` */ readonly hasBody: number; - readonly upgraded: boolean; /** `event` is a `NodeHTTPResponseAbortEvent`. */ ondata: ((chunk: Buffer | undefined, isLast: boolean, event: number) => void) | undefined; onabort: ((event: number) => void) | undefined; - getBytesWritten(): number; - ref(): void; - unref(): void; pause(): boolean; resume(): boolean; - pauseReads(): void; notifyWhenReadParsed(): boolean; takeRequestTrailers(lenient: boolean): string[] | undefined; takeRawHeaders(headers: IncomingHttpHeaders | null): string[] | undefined; @@ -2446,40 +2460,26 @@ declare namespace $Node { /** The native class `NodeHTTPServerSocket` (src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp): one connection. */ interface NodeHTTPServerSocket { - onclose: (() => void) | undefined; - ondrain: (() => void) | undefined; - ondata: ((chunk: Buffer | undefined, last: boolean) => void) | undefined; + /** With the socket: the connection is open, and it's the socket's from here on. */ + onclose: ((left?: HttpServerSocket) => void) | undefined; duplex: HttpServerSocket | null; - readonly bytesWritten: number; readonly closed: boolean; readonly response: NodeHTTPResponse | null; - readonly remoteAddress: SocketAddressInfo | null; - readonly localAddress: SocketAddressInfo | null; - readonly secureEstablished: boolean; - readonly servername: string | null; - readonly authorizationError: string | null; - readonly peerCertVerified: boolean; - readonly hasQueuedTunnelReads: boolean; - readonly closedWithUnsentData: boolean | undefined; readonly closeError: (Error & { errno: number; code: string }) | undefined; readonly peerEnded: boolean; /** The FIN of `end()` has left, rather than waiting for queued bytes. */ readonly finSent: boolean; - /** No member of the native class. The server asks for it where it reports a dropped connection. */ - readonly localPort?: number; close(): void; - reset(): void; - /** Whether the kernel took all of it. */ - write(data: HttpChunk | undefined, encoding?: string | null): boolean | number; - end(destroySoon?: boolean): void; - upgradeToTunnel(hasBody: boolean, response: NodeHTTPResponse): void; - readStop(): void; - readStart(): void; + leaveHttp(resumes: boolean): boolean; + leaveHttpAfterBody(): void; + setRawReads(enabled: boolean): void; setResponseTrailers(trailers: string): void; isRequestTimedOut(headersTimeout: number, requestTimeout: number): boolean; - startPipelinedResponse(response: HttpResponseHandle, isAncient: boolean, connectionClose: boolean): boolean; + startRawResponse(response: NodeHTTPResponse, connectionClose: boolean): boolean; stopParsing(): void; - closeWhenDrained(): void; + detachParser(): void; + /** Whether the connection ended in the middle of a message. */ + finishParsing(): boolean; closeIfIdle(): boolean; } @@ -2818,7 +2818,7 @@ declare namespace $Bindgen { destroy(error?: unknown): void; sendHeaders(kind: number, headers: unknown, flags: number): boolean; stopSending(code: bigint | number): void; - resetStream(code: bigint | number): void; + resetStream(code: bigint | number): boolean; abortForDestroy(stopCode: bigint | number | undefined, resetCode: bigint | number | undefined): void; setPriority(packed: number): void; getPriority(): number; diff --git a/src/js/thirdparty/node-fetch.ts b/src/js/thirdparty/node-fetch.ts index ba70dc92c085..fde1d67c306b 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,44 @@ function readableFromOldStyleStream(source: $Node.Stream) { return passthrough; } +function webStreamFromOldStyleStream(source: $Node.Stream) { + return require("node:stream").Readable.toWeb(readableFromOldStyleStream(source)); +} + +// Pipes the source on the first read, so a Request that is only inspected leaves a form-data body unread. +function lazyWebStreamFromOldStyleStream(source: $Node.Stream) { + let reader: ReadableStreamDefaultReader | undefined; + return new ReadableStream( + { + async pull(controller) { + reader ??= webStreamFromOldStyleStream(source).getReader(); + const { done, value } = await reader!.read(); + if (done) controller.close(); + else controller.enqueue(value); + }, + cancel(reason) { + return reader?.cancel(reason); + }, + }, + { highWaterMark: 0 }, + ); +} + +// `init` with one member replaced. A spread would drop its inherited and non-enumerable members. +function withMember(init, name: string, value): any { + return { __proto__: init, [name]: value }; +} + +// An old-style Stream has no Symbol.asyncIterator, which is how native fetch() and Request read a node stream. +function withWebBody(init, toWebStream: typeof webStreamFromOldStyleStream) { + const body = init?.body; + return typeof body?.pipe === "function" && + !body[Symbol.asyncIterator] && + body instanceof require("node:stream").Stream + ? withMember(init, "body", toWebStream(body)) + : init; +} + class Response extends WebResponse { [kBody]: any; [kHeaders]; @@ -133,12 +172,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 +209,8 @@ class Request extends WebRequest { [kUrl]?: string; constructor(input, init) { + init = withWebBody(init, lazyWebStreamFromOldStyleStream); + // 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 @@ -192,33 +242,57 @@ async function fetch( // eslint-disable-next-line no-unused-vars init?: RequestInit & { body?: any }, ) { - // Convert Node.js streams to Web ReadableStream if they don't have Symbol.asyncIterator. - // This is needed for libraries like `form-data` that use CombinedStream which extends - // Node.js Stream but doesn't implement Symbol.asyncIterator. - const initBody = init?.body; - if (initBody && typeof initBody === "object" && !initBody[Symbol.asyncIterator]) { - const { Readable, Stream } = require("node:stream"); - if (initBody instanceof Stream || initBody instanceof Readable) { - const readable = initBody instanceof Readable ? initBody : readableFromOldStyleStream(initBody); - init = { ...init, body: Readable.toWeb(readable) }; - } - } if (init && ObjectHasOwn(init, "agent") && (init as any).agent && (init as any).tls === undefined) { const tls = tlsFromAgent((init as any).agent, url); - if (tls !== undefined) init = { ...init, tls } as any; + if (tls !== undefined) init = withMember(init, "tls", tls); + } + init = withWebBody(init, webStreamFromOldStyleStream); + let response; + try { + response = await nativeFetch.$call(undefined, url, init); + } catch (error) { + throw toNodeFetchError(error); } - const response = await nativeFetch.$call(undefined, url, init); 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": + 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.ts b/src/js/thirdparty/undici.ts index eecfd5d941fb..ac7a5e10a1ea 100644 --- a/src/js/thirdparty/undici.ts +++ b/src/js/thirdparty/undici.ts @@ -1,12 +1,18 @@ 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: (input: string | URL | Request, init?: object) => Promise = Bun.fetch; +type Fetch = (input: string | URL | Request, init?: object) => Promise; + +const nativeFetch: Fetch & { preconnect: typeof Bun.fetch.preconnect } = Bun.fetch; +const FetchSession = Bun.FetchSession; +const isDecodedBody: (stream: unknown) => boolean = $bindgen("ReadableStream.bind.ts", "isDecodedBody"); const bindings = $cpp("Undici.cpp", "createUndiciInternalBinding"); const Response: typeof globalThis.Response = bindings[0]; const Request: typeof globalThis.Request = bindings[1]; @@ -35,17 +41,16 @@ function notImplemented(): never { throw new Error("This function is not yet implemented in Bun"); } -/** - * An object representing a URL. - * @typedef {Object} UrlObject - * @property {string | number} [port] - * @property {string} [path] - * @property {string} [pathname] - * @property {string} [hostname] - * @property {string} [origin] - * @property {string} [protocol] - * @property {string} [search] - */ +/** An object representing a URL. */ +interface UrlObject { + port?: string | number | null; + path?: string | null; + pathname?: string | null; + hostname?: string | null; + origin?: string | null; + protocol?: string | null; + search?: string | null; +} /** * @typedef {import('http').IncomingHttpHeaders} IncomingHttpHeaders @@ -61,14 +66,17 @@ function closeEmptyBody(controller: ReadableStreamDefaultController) { class BodyReadable extends ReadableFromWeb { #response: Response; #bodyUsed: boolean; + #onFinished: () => void; - constructor(response: Response, options: object = {}) { + constructor(response: Response, onFinished: () => void) { // 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 +84,263 @@ class BodyReadable extends ReadableFromWeb { return this.#bodyUsed; } - #consume() { + async #consume(method: "arrayBuffer" | "blob" | "formData" | "json" | "text" | "bytes") { 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?: { limit?: number; signal?: AbortSignal | null }) { + 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; + + const { promise, resolve, reject } = Promise.withResolvers(); + if (Number(this.#response.headers.get("content-length")) > limit) this.destroy(new AbortError()); + + const onAbort = () => this.destroy((signal!.reason ?? new AbortError()) as Error); + 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: Buffer) => { + bytesRead += chunk.length; + if (bytesRead >= limit) this.destroy(); + }) + .resume(); + return await promise; + } +} + +// https://github.com/nodejs/undici/blob/v6.21.3/lib/core/util.js#L108-L203 +function isValidPort(port: string | number) { + const value = parseInt(port as string, 10); + return value === Number(port) && value >= 0 && value <= 65535; +} + +function isHttpOrHttpsPrefixed(value: string | null | undefined) { + return ( + value != null && + value[0] === "h" && + value[1] === "t" && + value[2] === "t" && + value[3] === "p" && + (value[4] === ":" || (value[4] === "s" && value[5] === ":")) + ); +} + +function parseURL(url: string | URL | UrlObject) { + if (typeof url === "string" || url instanceof URL) return new URL(url); + if (require("internal/url").isURL(url)) return new URL(url.href); + if (!url || typeof url !== "object") throw new TypeError("url must be a string, URL, or UrlObject"); + let { port, path, origin } = url; + const { pathname, hostname, protocol, search } = url; + if (port != null && port !== "" && !isValidPort(port)) { + throw new InvalidArgumentError( + "Invalid URL: port must be a valid integer or a string representation of an integer.", + ); + } + if (path != null && typeof path !== "string") { + throw new InvalidArgumentError("Invalid URL path: the path must be a string or null/undefined."); + } + if (pathname != null && typeof pathname !== "string") { + throw new InvalidArgumentError("Invalid URL pathname: the pathname must be a string or null/undefined."); + } + if (hostname != null && typeof hostname !== "string") { + throw new InvalidArgumentError("Invalid URL hostname: the hostname must be a string or null/undefined."); } + if (origin != null && typeof origin !== "string") { + throw new InvalidArgumentError("Invalid URL origin: the origin must be a string or null/undefined."); + } + if (!isHttpOrHttpsPrefixed(origin || protocol)) { + throw new InvalidArgumentError("Invalid URL protocol: the URL must start with `http:` or `https:`."); + } + port ??= protocol === "https:" ? 443 : 80; + origin ??= `${protocol || ""}//${hostname || ""}:${port}`; + path ??= `${pathname || ""}${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}`); +} - async arrayBuffer() { - this.#consume(); - return await this.#response.arrayBuffer(); +function parseOrigin(origin: string | URL | UrlObject) { + 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; +} - async blob() { - this.#consume(); - return await this.#response.blob(); +type HeaderValue = string | string[] | null | undefined; +type HeaderPairs = Iterable<[string, HeaderValue]>; +type RequestHeaders = string[] | HeaderPairs | Record; + +// https://github.com/nodejs/undici/blob/v6.21.3/lib/core/request.js#L159-L182 +function toHeaders(input: RequestHeaders | null | undefined) { + 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: string, value: HeaderValue) => { + 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); + }; + // An array of [name, value] pairs, which fetch() takes, is read by the iterable branch. + if ($isJSArray(input) && !$isJSArray(input[0])) { + 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 as HeaderPairs)[Symbol.iterator]) { + for (const { 0: name, 1: value } of input as HeaderPairs) append(name, value); + } else { + for (const name of Object.keys(input)) append(name, (input as Record)[name]); } + return headers; +} + +type RequestSignal = AbortSignal | ($Node.EventEmitter & { aborted?: boolean; reason?: unknown }); - async formData() { - this.#consume(); - return await this.#response.formData(); +// Aborts `controller` on the signal's "abort" event, like undici. Returns the function that removes the listener. +function addAbortListener(signal: RequestSignal | null | undefined, controller: AbortController): () => void { + 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: FinalizationRegistry<() => void> | undefined; + +function callFunction(fn: () => void) { + fn(); +} + +type ParsedHeaders = Record; - async json() { - this.#consume(); - return await this.#response.json(); +// If fetch() decoded the body, Content-Encoding and the encoded Content-Length no longer describe it. +function responseHeaders(response: Response) { + const headers: ParsedHeaders = response.headers.toJSON(); + if (isDecodedBody(response.body)) { + delete headers["content-encoding"]; + delete headers["content-length"]; } + return headers; +} - async text() { - this.#consume(); - return await this.#response.text(); +function validateRequest( + method: string | null | undefined, + body: $Node.BodyInit | undefined, + maxRedirections: number | undefined, + signal: { on?: unknown; addEventListener?: unknown } | null | undefined, +) { + // 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"); + + if (body && (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"); + } + + 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 the dispatcher's fetch(). +function send( + dispatcher: DispatcherLike | null | undefined, + url: URL, + method: string, + headers: RequestHeaders | null | undefined, + body: $Node.BodyInit | undefined, + maxRedirections: number | undefined, + reset: boolean | undefined, + signal: AbortSignal, +) { + const followRedirects = maxRedirections! > 0; + return (dispatcher?.[kFetch] ?? nativeFetch)(url, { + signal, + method, + headers: toHeaders(headers), + body, + redirect: followRedirects ? "follow" : "manual", + maxRedirects: followRedirects ? maxRedirections : undefined, + keepalive: !reset, + }); +} + +type Callback = (err: unknown, data: unknown) => void; + +function callbackify(run: () => Promise, callback: Callback | undefined, opaque: unknown) { + 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 +360,7 @@ class BodyReadable extends ReadableFromWeb { * query?: Record; * reset?: boolean; * throwOnError?: boolean; + * opaque?: *; * }} [options] * @returns {{ * statusCode: number; @@ -141,110 +372,99 @@ class BodyReadable extends ReadableFromWeb { * }} */ interface RequestOptions { + dispatcher?: DispatcherLike | null; method?: string | null; - signal?: AbortSignal | null; + signal?: RequestSignal | null; maxRedirections?: number; body?: $Node.BodyInit; - headers?: $Node.HeadersInit | null; + headers?: RequestHeaders | null; query?: ConstructorParameters[0] | null; reset?: boolean; throwOnError?: boolean; + opaque?: unknown; } -async function request( - url: string | URL, - options: RequestOptions = { - 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 - if (!require("internal/url").isURL(url)) throw new Error("not implemented"); - url = new URL((url as URL).href); - } - } else throw new TypeError("url must be a string, URL, or UrlObject"); +interface ResponseData { + statusCode: number; + headers: ParsedHeaders; + body: BodyReadable; + trailers: ParsedHeaders; + opaque: unknown; + context: unknown; +} - if (typeof url === "string" && query) url = new URL(url); - if (typeof url === "object" && url !== null && query) if (query) url.search = new URLSearchParams(query).toString(); +async function request(url: string | URL | UrlObject, options: RequestOptions = kEmptyObject) { + const { dispatcher = globalDispatcher } = options; + return requestWith(dispatcher, url, options); +} - method = method && typeof method === "string" ? method.toUpperCase() : null; - // idempotent = idempotent === undefined ? method === "GET" || method === "HEAD" : idempotent; +async function requestWith( + dispatcher: DispatcherLike | null | undefined, + url: string | URL | UrlObject, + options: RequestOptions, +): Promise { + const { query, signal, throwOnError, opaque = kEmptyObject, headers, maxRedirections, reset } = options; + const requestBody = options.body; + + url = parseURL(url); + if (query) url.search = new URLSearchParams(query).toString(); + const method = validateRequest(options.method, requestBody, maxRedirections, signal); + + const controller = new AbortController(); + const removeAbortListener = addAbortListener(signal, controller); + try { + const run = (signal: AbortSignal) => + hasOwnDispatch(dispatcher) + ? sendWithDispatch(dispatcher, url as URL, { ...options, method, query: undefined }, signal) + : send(dispatcher, url as URL, method, headers, requestBody, maxRedirections, reset, signal); + 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}`); + } - if (inputBody && (method === "GET" || method === "HEAD")) { - throw new Error("Body not allowed for GET or HEAD requests"); + const body = new BodyReadable(resp, removeAbortListener); + if (signal) (droppedBodies ??= new FinalizationRegistry(callFunction)).register(body, removeAbortListener); + return { statusCode, headers: responseHeaders(resp), body, trailers: {}, opaque, context: kEmptyObject }; + } catch (err) { + removeAbortListener(); + throw err; } +} - if (maxRedirections != null && (!Number.isInteger(maxRedirections) || maxRedirections < 0)) { - throw new Error("maxRedirections must be a positive number"); - } +type StreamFactory = (data: Omit) => $Node.Writable; - 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: string | URL | UrlObject, + options: RequestOptions | StreamFactory | undefined, + factory?: StreamFactory, +) { + if ($isCallable(options)) { + factory = options; + options = undefined; } + return streamWith(() => request(url, options), factory); +} - const followRedirects = maxRedirections != null && maxRedirections > 0; - - const resp: Response & { trailers?: undefined } = 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}`); +async function streamWith(run: () => Promise, factory: StreamFactory | undefined) { + if (!$isCallable(factory)) throw new InvalidArgumentError("invalid factory"); + const { statusCode, headers, body, opaque, context } = await run(); + 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(); } @@ -267,33 +487,469 @@ class MockAgent { function mockErrors() {} -class Dispatcher extends EventEmitter {} -class Agent extends Dispatcher {} -class Pool extends Dispatcher { - request() {} +const kFetch = Symbol("kFetch"); +const kTrack = Symbol("kTrack"); +const kUsesFetch = Symbol("kUsesFetch"); + +interface DispatchOptions extends RequestOptions { + origin?: string | URL; + path: string; } -class BalancedPool extends Dispatcher {} -class Client extends Dispatcher { - request() {} + +interface DispatchController { + pause(): void; + resume(): void; + abort(err: unknown): void; + readonly aborted: boolean; + readonly reason: unknown; + readonly paused: boolean; } -class DispatcherBase extends EventEmitter {} +// onConnect() to onError() are the callbacks of undici 6, the others those of undici 7. +interface DispatchHandler { + onConnect?(abort: (reason?: unknown) => void, context: unknown): void; + onHeaders?(statusCode: number, rawHeaders: Buffer[], resume: () => void, statusText: string): boolean | void; + onData?(chunk: Buffer): boolean | void; + onComplete?(rawTrailers: Buffer[]): void; + onError?(err: unknown): void; + onRequestStart?(controller: DispatchController, context: unknown): void; + onResponseStart?( + controller: DispatchController, + statusCode: number, + headers: ParsedHeaders, + statusText: string, + ): void; + onResponseData?(controller: DispatchController, chunk: Buffer): void; + onResponseEnd?(controller: DispatchController, trailers: ParsedHeaders): void; + onResponseError?(controller: DispatchController, err: unknown): void; +} -class ProxyAgent extends DispatcherBase { - constructor() { +// What this module reads from a dispatcher, which can be any object with a dispatch() method. +interface DispatcherLike { + dispatch?(opts: DispatchOptions, handler: DispatchHandler): boolean; + request?(opts: DispatchOptions, callback?: Callback): unknown; + close?(callback?: Callback): unknown; + destroy?(err?: Error | Callback | null, callback?: Callback): unknown; + [kFetch]?: Fetch; + [kTrack]?(signal: AbortSignal, run: (signal: AbortSignal) => Promise): Promise; + [kUsesFetch]?: boolean; +} + +interface CustomDispatcher extends DispatcherLike { + dispatch(opts: DispatchOptions, handler: DispatchHandler): boolean; +} + +class Dispatcher extends EventEmitter { + dispatch(opts: DispatchOptions, handler: DispatchHandler): boolean; + dispatch(): never { + throw new Error("not implemented"); + } + + close(callback?: Callback): unknown; + close(): never { + throw new Error("not implemented"); + } + + destroy(err?: Error | Callback | null, callback?: Callback): unknown; + destroy(): never { + throw new Error("not implemented"); + } +} + +type FetchSessionInit = import("bun").FetchSessionInit; + +interface ConnectOptions { + socketPath?: string | null; + [option: string]: unknown; +} + +interface DispatcherOptions { + connect?: ConnectOptions | ((options: object, callback: Callback) => void) | null; + socketPath?: string | null; +} + +// Maps a dispatcher's `connect` options, which undici passes to tls.connect(), to Bun.FetchSession options. +function toSessionOptions( + connect: DispatcherOptions["connect"], + socketPath: string | null | undefined, + proxy: FetchSessionInit["proxy"], +): FetchSessionInit { + // A connector function has no options. Its own properties must not relax TLS while the function itself is ignored. + if ($isCallable(connect)) connect = undefined; + return { + tls: connect && require("internal/tls").nodeClientTlsToNative({ __proto__: null, ...connect }), + unix: (socketPath ?? connect?.socketPath) || undefined, + proxy, + }; +} + +// https://github.com/nodejs/undici/blob/v7.16.0/lib/handler/unwrap-handler.js +function unwrapHandler(handler: DispatchHandler): DispatchHandler { + if (!handler.onRequestStart) return handler; + let paused = false; + let aborted = false; + let reason: unknown = null; + let abortRequest: (reason?: unknown) => void; + let resumeBody: (() => void) | undefined; + const controller: DispatchController = { + 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: Buffer[]) { + const headers: ParsedHeaders = {}; + 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; +} + +// Runs one native fetch() and reports it through a dispatch() handler's callbacks. +async function dispatchWithFetch( + send: (signal: AbortSignal) => Promise, + handler: DispatchHandler, + controller: AbortController, + signal: AbortSignal, +) { + 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(signal); + + const headers = responseHeaders(response); + const rawHeaders: Buffer[] = []; + 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) { + const { promise, resolve } = Promise.withResolvers(); + wake = resolve; + await promise; + } + 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: string | undefined; + #hasConnector: boolean; + #sessionOptions: FetchSessionInit; + #session: import("bun").FetchSession | undefined; + #closed = false; + #destroyed = false; + #aborter = new AbortController(); + #pending = 0; + #onDrain: (() => void)[] = []; + + constructor(options?: DispatcherOptions | null, origin?: string, proxy?: FetchSessionInit["proxy"]) { super(); + this.#origin = origin; + this.#hasConnector = $isCallable(options?.connect); + this.#sessionOptions = toSessionOptions(options?.connect, options?.socketPath, proxy); + } + + // fetch() with this dispatcher's TLS options, socket path, proxy and connections. Bad options fail the first request, as in undici. + get [kFetch](): Fetch { + return (this.#session ??= new FetchSession(this.#sessionOptions)).fetch; + } + + 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: DispatchOptions) { + 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"); + // undici sends an absolute URL to the origin as the request target, which fetch() can't. + if (path.startsWith("http://") || path.startsWith("https://")) { + throw new NotSupportedError("An absolute URL as `path` is not yet implemented in Bun"); + } + 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: AbortSignal, run: (signal: AbortSignal) => Promise): Promise { + 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: () => void) { + if (this.#pending === 0) queueMicrotask(callback); + else this.#onDrain.push(callback); + } + + close(callback?: Callback) { + if (callback === undefined) { + const { promise, resolve, reject } = Promise.withResolvers(); + this.close((err, data) => (err ? reject(err) : resolve(data))); + return promise; + } + 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; + this.#session?.close(); + callback(null, null); + }); + } + + destroy(err?: Error | Callback | null, callback?: Callback) { + if ($isCallable(err)) { + callback = err; + err = null; + } + if (callback === undefined) { + const { promise, resolve, reject } = Promise.withResolvers(); + this.destroy(err, (err, data) => (err ? reject(err) : resolve(data))); + return promise; + } + if (!$isCallable(callback)) throw new InvalidArgumentError("invalid callback"); + this.#destroyed = true; + this.#aborter.abort(err || new ClientDestroyedError()); + this.#whenDrained(() => { + this.#session?.close(); + callback(null, null); + }); + } + + dispatch(opts: DispatchOptions, handler: DispatchHandler) { + if (!handler || typeof handler !== "object") throw new InvalidArgumentError("handler must be an object"); + handler = unwrapHandler(handler); + let run: (signal: AbortSignal) => Promise; + try { + if (!opts || typeof opts !== "object") throw new InvalidArgumentError("opts must be an object."); + this.#assertOpen(); + const url = this.#url(opts); + const { query, headers, body, maxRedirections, reset } = opts; + if (query) url.search = new URLSearchParams(query).toString(); + const method = validateRequest(opts.method, body, maxRedirections, opts.signal); + if (!$isCallable(handler.onError)) throw new InvalidArgumentError("invalid onError method"); + run = signal => send(this, url, method, headers, body, maxRedirections, reset, signal); + } 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(run, handler, controller, signal); + } catch (err) { + controller.abort(err); + handler.onError!(err); + } + }); + return true; + } + + request(opts: DispatchOptions, callback?: Callback) { + return callbackify(async () => requestWith(this, this.#url(opts), opts), callback, opts?.opaque); + } + + stream(opts: DispatchOptions, factory: StreamFactory, callback?: Callback) { + return callbackify( + async () => { + const url = this.#url(opts); + return streamWith(() => requestWith(this, url, opts), factory); + }, + callback, + opts?.opaque, + ); + } +} + +class Agent extends DispatcherBase { + constructor(options?: DispatcherOptions) { + super(options); + } +} + +class Client extends DispatcherBase { + constructor(origin: string | URL | UrlObject, options?: DispatcherOptions) { + super(options, parseOrigin(origin)); + } +} + +class Pool extends DispatcherBase { + constructor(origin: string | URL | UrlObject, options?: DispatcherOptions) { + super(options, parseOrigin(origin)); + } +} + +class BalancedPool extends Dispatcher {} + +interface ProxyAgentOptions { + uri: string | URL; + auth?: string; + token?: string; + headers?: Record; + requestTls?: ConnectOptions | null; +} + +// https://github.com/nodejs/undici/blob/v6.21.3/lib/dispatcher/proxy-agent.js#L28-L61 +class ProxyAgent extends DispatcherBase { + constructor(opts: string | URL | ProxyAgentOptions) { + if (!opts || (typeof opts === "object" && !(opts instanceof URL) && !opts.uri)) { + throw new InvalidArgumentError("Proxy uri is mandatory"); + } + const url = new URL(typeof opts === "string" || opts instanceof URL ? opts : opts.uri).href; + const { auth, token } = opts as ProxyAgentOptions; + const headers = { ...(opts as ProxyAgentOptions).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; + } + // fetch()'s `tls` also covers the connection to an https: proxy, which requestTls must not relax. + if ((opts as ProxyAgentOptions).requestTls != null && url.startsWith("https:")) { + throw new InvalidArgumentError("requestTls is not supported with an https: proxy"); + } + // Only EnvHttpProxyAgent follows NO_PROXY. + super({ connect: (opts as ProxyAgentOptions).requestTls }, undefined, { url, headers, respectNoProxy: false }); } } +// Native fetch() reads the *_PROXY environment variables itself, on every redirect hop. class EnvHttpProxyAgent extends DispatcherBase { - constructor() { - super(); + constructor(options?: DispatcherOptions) { + super(options); } } class RetryAgent extends Dispatcher { - constructor() { + #agent: DispatcherLike; + + constructor(agent: DispatcherLike) { super(); + this.#agent = agent; + } + + get [kFetch]() { + return this.#agent?.[kFetch]; + } + + get [kUsesFetch]() { + return this.dispatch === RetryAgent.prototype.dispatch && this.#agent?.[kUsesFetch] === true; + } + + dispatch(opts: DispatchOptions, handler: DispatchHandler) { + return this.#agent.dispatch!(opts, handler); + } + + request(opts: DispatchOptions, callback?: Callback) { + return this.#agent.request!(opts, callback); + } + + close(callback?: Callback) { + return this.#agent.close!(callback); + } + + destroy(err?: Error | Callback | null, callback?: Callback) { + return this.#agent.destroy!(err, callback); } } @@ -319,29 +975,136 @@ 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?: string, options?: ErrorOptions) { + super(message, options); + this.name = "UndiciError"; + this.code = "UND_ERR"; + } +} + +function defineError(className: string, code: string, defaultMessage: string, name = className, Base = UndiciError) { + const ErrorClass = class extends Base { + constructor(message?: string) { + 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 { + declare data: string | undefined; + + constructor(message?: string, code?: string, data?: Buffer) { + super(message); + this.name = "HTTPParserError"; + this.code = code ? `HPE_${code}` : undefined; + this.data = data ? data.toString() : undefined; + } +} + +class ResponseStatusCodeError extends UndiciError { + declare body: unknown; + declare status: number | undefined; + declare statusCode: number | undefined; + declare headers: unknown; + + constructor(message?: string, statusCode?: number, headers?: unknown, body?: unknown) { + 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 { + declare socket: unknown; + + constructor(message?: string, socket?: unknown) { + super(message || "Socket error"); + this.name = "SocketError"; + this.code = "UND_ERR_SOCKET"; + this.socket = socket; + } +} + +class RequestRetryError extends UndiciError { + declare statusCode: number | undefined; + declare data: unknown; + declare headers: unknown; + + constructor( + message?: string, + code?: number, + { headers, data }: { headers?: unknown; data?: unknown } = 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?: unknown, message?: string, options?: ErrorOptions) { + super(message || "Secure Proxy Connection failed", { cause, ...options }); + this.name = "SecureProxyConnectionError"; + this.code = "UND_ERR_PRX_TLS"; + } +} const errors = { AbortError, @@ -461,8 +1224,122 @@ function setGlobalDispatcher(dispatcher: 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: DispatcherLike | null | undefined): dispatcher is CustomDispatcher { + if (dispatcher == null || dispatcher[kUsesFetch]) return false; + const { dispatch } = dispatcher; + // BalancedPool only inherits the abstract dispatch(), which throws. + return $isCallable(dispatch) && dispatch !== Dispatcher.prototype.dispatch; +} + +// Calls dispatcher.dispatch() and builds the Response from the handler callbacks. +function sendWithDispatch( + dispatcher: CustomDispatcher, + url: URL, + options: RequestOptions, + signal: AbortSignal, + redirect?: string, +): Promise { + if (signal.aborted) return Promise.$reject(signal.reason); + const { promise, resolve, reject } = Promise.withResolvers(); + const { method } = options; + let abort: (reason?: unknown) => void = noop; + let body: ReadableStreamDefaultController | undefined; + // A dispatcher doesn't have to report an abort back, so each abort settles the promise first. + const stop = (err: unknown) => { + fail(err); + abort(err); + }; + const onAbort = () => stop(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + const fail = (err: unknown) => { + signal.removeEventListener("abort", onAbort); + body?.error(err); + body = undefined; + reject(err); + }; + + try { + 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)) { + stop(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(); + body = undefined; + }, + onError: fail, + }, + ); + } catch (err) { + fail(err); + } + return promise; +} + +interface FetchInit { + dispatcher?: DispatcherLike | null; + [option: string]: unknown; +} + +async function fetchWithDispatch(dispatcher: CustomDispatcher, input: string | URL | Request, init?: FetchInit) { + 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: string | URL | Request, init?: FetchInit): Promise { + const dispatcher: DispatcherLike | null | undefined = init?.dispatcher ?? globalDispatcher; + if (hasOwnDispatch(dispatcher)) return fetchWithDispatch(dispatcher, input, init); + try { + return (dispatcher?.[kFetch] ?? nativeFetch)(input, init); + } catch (err) { + return Promise.$reject(err); + } } +fetch.preconnect = nativeFetch.preconnect; // Add missing origin functions function setGlobalOrigin() {} diff --git a/src/js/thirdparty/ws.ts b/src/js/thirdparty/ws.ts index 210a91fbd5a3..ea35e339d6a5 100644 --- a/src/js/thirdparty/ws.ts +++ b/src/js/thirdparty/ws.ts @@ -15,8 +15,9 @@ const { setRef, streamClosed, } = $cpp("JSWebSocket.cpp", "createWebSocketBindingForWs") as NativeClientBinding; -const upgradeWebSocket = $bindgen("node_http_binding.bind.ts", "upgradeNodeHTTPResponseToWebSocket") as ( - response: unknown, +const upgradeSocketToWebSocket = $bindgen("node_http_binding.bind.ts", "upgradeSocketToWebSocket") as ( + server: unknown, + socketHandle: unknown, websocket: WebSocket, handlers: ServerHandlers, handshake: string, @@ -24,6 +25,9 @@ const upgradeWebSocket = $bindgen("node_http_binding.bind.ts", "upgradeNodeHTTPR flags: number, deflate: NativeStreamOptions["deflate"], ) => NativeServer | undefined; +const closedWithAllDataSent = $bindgen("node_http_binding.bind.ts", "closedWithAllDataSent") as ( + socketHandle: unknown, +) => boolean; const serverSendFrame = $bindgen("node_http_binding.bind.ts", "sendWebSocketFrame") as ( handle: NativeServer, data: string | Buffer | Blob, @@ -418,9 +422,10 @@ type WebSocketDuplex = Duplex; // node:http is almost the entire cost of require("ws"); load it on first use. let http: HttpModule | undefined; let kHeaderSource: symbol | undefined; -let kClosedWithUnsentData: symbol | undefined; -let kHasQueuedReads: symbol | undefined; -let kRememberPeer: symbol | undefined; +let kLeaveHttp: symbol | undefined; +let serverSymbol: symbol | undefined; +// `_socket._handle` as it was at the upgrade. net.Socket drops it when the connection closes. +const kSocketHandle = Symbol("kSocketHandle"); function lazyHttp(): HttpModule { return (http ??= require("node:http")); } @@ -1182,20 +1187,13 @@ class WebSocket extends EventEmitter { setSocket(socket: ServerSocket, head: Buffer, options: SetSocketOptions): boolean { const maxPayload = Math.max(options.maxPayload | 0, 0); - // node:http does this itself for an 'upgrade' listener, but handleUpgrade() can also be called from a 'request' one. - if (this._isServer) socket[(kRememberPeer ??= require("internal/http").kRememberPeer as symbol)]?.(); - - // The native server socket only sees data received after it adopts the connection. If handleUpgrade() was called - // late, node:http may already have read frames into `head` or `socket`'s buffer, or be about to push them or the - // peer's FIN into that buffer, so use the stream transport. - if ( - options[kResponse] && - head.length === 0 && - socket.readableLength === 0 && - !socket[(kHasQueuedReads ??= require("internal/http").kHasQueuedReads as symbol)] - ) { - const native = upgradeWebSocket( + // The native WebSocket only sees what arrives after it adopts the connection. The stream transport takes frames that are in `head` or in the socket's buffer. + let native: NativeServer | undefined; + if (options[kResponse] && head.length === 0 && socket.readableLength === 0) { + this[kSocketHandle] = socket._handle; + native = upgradeSocketToWebSocket( options[kResponse], + this[kSocketHandle], this, serverHandlers, options[kHandshake], @@ -1203,9 +1201,9 @@ class WebSocket extends EventEmitter { (this._autoPong ? 1 : 0) | (options.skipUTF8Validation ? 2 : 0), nativeDeflateParams(this), ); - - if (native === undefined) return false; - + if (native === undefined && socket.destroyed) return false; + } + if (native !== undefined) { this[kNative] = native; this[kTransport] = NATIVE_SERVER; } else { @@ -1237,13 +1235,14 @@ class WebSocket extends EventEmitter { socket.on("close", socketOnClose); socket.on("error", socketOnError); + // On the native transport it comes once the connection has closed. + socket.on("end", socketOnEnd); if (this[kTransport] === STREAM) { if (socket.setNoDelay) socket.setNoDelay(); if (head.length > 0) socket.unshift(head); socket.on("data", socketOnData); - socket.on("end", socketOnEnd); } this._readyState = OPEN; @@ -1597,10 +1596,7 @@ function settleCallbacks(websocket: WebSocket, failed: boolean): void { // Once the closing handshake is done, uWS keeps writing what is queued but no longer reports a drain. So when the socket // closes, callbacks can still be waiting for data that was written. function wroteEverything(websocket: WebSocket): boolean { - if (websocket[kTransport] !== NATIVE_SERVER) return false; - - kClosedWithUnsentData ??= require("internal/http").kClosedWithUnsentData as symbol; - return websocket._socket![kClosedWithUnsentData] === false; + return websocket[kTransport] === NATIVE_SERVER && closedWithAllDataSent(websocket[kSocketHandle]); } function runCallbacks(callbacks: WaitingCallback[], start: number, err: Error | null): void { @@ -2006,6 +2002,7 @@ function socketOnClose(this: ServerSocket): void { const websocket = this[kWebSocket]!; this.removeListener("close", socketOnClose); + this.removeListener("end", socketOnEnd); websocket._readyState = CLOSING; @@ -2013,7 +2010,6 @@ function socketOnClose(this: ServerSocket): void { const native = websocket[kNative] as NativeClient; this.removeListener("data", socketOnData); - this.removeListener("end", socketOnEnd); // The socket closed with unread data in its buffer, which may include the Close frame. const { readableLength } = this; @@ -3373,6 +3369,10 @@ class WebSocketServer extends EventEmitter { head: Buffer, cb: UpgradeCallback, ): ServerSocket | void { + // While the body of the request is incomplete, node:http gives 'upgrade' a wrapper that hides what arrives. The WebSocket takes the connection itself. + const requestSocket = req.socket; + if (wrapsNativeConnection(requestSocket, socket)) socket = requestSocket; + // Destroy the socket if the client has already sent a FIN packet. if (!socket.readable || !socket.writable) return socket.destroy(); @@ -3450,10 +3450,22 @@ class WebSocketServer extends EventEmitter { // Returns the connection's native response if `socket` is the node:http socket itself and `req` arrived on it. // Returns undefined for a wrapper around the socket, so the stream transport is used and the wrapper sees every byte. // A `req` that node:http didn't create (e.g. a framework's own request object) is assumed to belong to `socket`. +function wrapsNativeConnection(connection: ServerSocket | undefined, socket: ServerSocket): boolean { + if (connection == null || connection === socket) return false; + const { kHandle, kUpgradeStream } = require("internal/http"); + return (connection[kUpgradeStream] as unknown) === socket && connection[kHandle] !== undefined; +} + function nativeResponse(req: ServerRequest, socket: ServerSocket): unknown { - const response = socket[kInternalSocketData]; - const headerSource = req[(kHeaderSource ??= require("internal/http").kHeaderSource)]; - return headerSource === undefined || headerSource === response ? response : undefined; + const server = socket.server; + const leaveHttp = server?.[(kLeaveHttp ??= require("internal/http").kLeaveHttp)]; + if (leaveHttp === undefined) return undefined; + if (req[(kHeaderSource ??= require("internal/http").kHeaderSource)] !== undefined && req.socket !== socket) + return undefined; + // handleUpgrade() can be called from a 'request' listener, or before the body of the request has ended. + leaveHttp.$call(server, socket, req); + // undefined once the server has closed: the stream transport takes a connection that outlived it. + return server![(serverSymbol ??= require("internal/http").serverSymbol)]; } function addListeners(server: HttpServer, map: Dictionary<(...args: any[]) => void>): () => void { diff --git a/src/js_parser_jsc/Macro.rs b/src/js_parser_jsc/Macro.rs index a71b5e47e5f7..8dff8ad79edc 100644 --- a/src/js_parser_jsc/Macro.rs +++ b/src/js_parser_jsc/Macro.rs @@ -708,8 +708,7 @@ impl<'a> Run<'a> { let mut blob_: Option<*const WebCore::Blob> = None; if value.js_type() == jsc::JSType::DOMWrapper { - // LAYERING: `Response`/`Request` (and their `BodyMixin:: - // get_blob_without_call_frame`) live in `bun_runtime:: + // LAYERING: `Response`/`Request` (and their `blob()`) live in `bun_runtime:: // webcore`, which depends on this crate. The downcast + // body-extract is dispatched through `RuntimeHooks` (the // established §Dispatch cycle-break) so the data shapes diff --git a/src/jsc/AbortSignal.rs b/src/jsc/AbortSignal.rs index 4754422f2118..960634b1afc8 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 886958bc8c75..1a5b8cbe621a 100644 --- a/src/jsc/CommonStrings.rs +++ b/src/jsc/CommonStrings.rs @@ -45,6 +45,7 @@ pub enum CommonStringsForRust { FetchOptionTls = 30, FetchOptionUnix = 31, FetchOptionVerbose = 32, + FetchBasic = 33, } unsafe extern "C" { @@ -106,6 +107,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/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index a935712eb980..df9cdba95e0d 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -1607,7 +1607,6 @@ pub mod formatter { /// Raw pointer (not `Box`) because `visited::Pool` owns the /// `heap::alloc`/`from_raw` lifecycle. pub(crate) map_node: Option>, - pub(crate) hide_native: bool, pub(crate) indent: u32, pub depth: u16, pub(crate) max_depth: u16, @@ -1643,7 +1642,6 @@ pub mod formatter { remaining_values: bun_ptr::RawSlice::EMPTY, map: visited::Map::default(), map_node: None, - hide_native: false, indent: 0, depth: 0, max_depth: 8, @@ -1681,7 +1679,6 @@ pub mod formatter { remaining_values: self.remaining_values, map: visited::Map::default(), map_node: None, - hide_native: self.hide_native, indent: self.indent, depth: self.depth, max_depth: self.max_depth, @@ -5962,41 +5959,6 @@ pub(crate) extern "C" fn Bun__ConsoleObject__timeLog( let _ = bun_io::Write::flush(&mut writer); } -/// Stamp out the empty `Bun__ConsoleObject__*` C-ABI hooks that JSC's -/// `ConsoleClient` vtable requires but Bun leaves unimplemented. Two arms cover -/// the two trailing-arg shapes the C++ side declares in -/// `bindings/headers.h:686-694`: `(…, *const u8, usize)` for the title-string -/// hooks and `(…, *mut ScriptArguments)` for the inspector-args hooks. -/// -/// Ident concat via `${concat()}` is unstable (`macro_metavar_expr_concat`) -/// and `paste` is not a `bun_jsc` dep, so the full `Bun__ConsoleObject__` -/// export symbol is passed verbatim. -macro_rules! console_noop_hooks { - (str: $($name:ident),+ $(,)?) => {$( - #[unsafe(no_mangle)] - #[crate::host_call] - pub extern "C" fn $name( - _console: *mut ConsoleObject, - _global: &JSGlobalObject, - _chars: *const u8, - _len: usize, - ) { - } - )+}; - (args: $($name:ident),+ $(,)?) => {$( - #[unsafe(no_mangle)] - #[crate::host_call] - pub extern "C" fn $name( - _console: *mut ConsoleObject, - _global: &JSGlobalObject, - _args: *mut ScriptArguments, - ) { - } - )+}; -} - -console_noop_hooks!(str: Bun__ConsoleObject__profile, Bun__ConsoleObject__profileEnd); - #[unsafe(no_mangle)] #[crate::host_call] pub(crate) extern "C" fn Bun__ConsoleObject__takeHeapSnapshot( @@ -6015,13 +5977,14 @@ pub(crate) extern "C" fn Bun__ConsoleObject__takeHeapSnapshot( ); } -console_noop_hooks!( - args: - Bun__ConsoleObject__timeStamp, - Bun__ConsoleObject__record, - Bun__ConsoleObject__recordEnd, - Bun__ConsoleObject__screenshot, -); +#[unsafe(no_mangle)] +#[crate::host_call] +pub(crate) extern "C" fn Bun__ConsoleObject__timeStamp( + _console: *mut ConsoleObject, + _global: &JSGlobalObject, + _args: *mut ScriptArguments, +) { +} #[unsafe(no_mangle)] #[crate::host_call] diff --git a/src/jsc/FetchHeaders.rs b/src/jsc/FetchHeaders.rs index 2543c7cbbccb..15b220e81eb0 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, @@ -239,11 +239,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/GarbageCollectionController.rs b/src/jsc/GarbageCollectionController.rs index a5afd7033f94..9abda85d6f0e 100644 --- a/src/jsc/GarbageCollectionController.rs +++ b/src/jsc/GarbageCollectionController.rs @@ -189,6 +189,26 @@ impl GarbageCollectionController { self.gc_last_heap_size.set(vm.block_bytes_allocated()); } + /// Something outside the heap waits for a finalizer: a dropped `Response` holds a request slot that a `fetch()` needs. + pub fn collect_for_finalizers(&self) { + VirtualMachine::get() + .jsc_vm() + .collect_async_for_finalizers(); + // As for `needs_ticks_to_finish`. + if cfg!(windows) && !self.disabled.get() { + self.heap_size_didnt_change_for_repeating_timer_ticks_count + .set(0); + self.gc_repeating_timer_fast.set(true); + Self::arm( + VirtualMachine::get_mut_ptr(), + core::ptr::addr_of!(self.gc_repeating_timer) + .cast::() + .cast_mut(), + self.repeat_interval(), + ); + } + } + /// `Tag::GcRepeating` fire body: `BUN_GC_TIMER_INTERVAL` (default 1 s) in fast mode, 30 s in slow mode; drops to slow after 30 fires without heap growth beyond the chatter slack, back to fast when it grows. /// /// # Safety diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index a9cf3e6dfbe1..5c59a9ec48b0 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -1134,8 +1134,15 @@ impl JSGlobalObject { crate::call_zero_is_throw(self, || ZigGlobalObject__readableStreamToJSON(self, value)) } - pub fn readable_stream_to_blob(&self, value: JSValue) -> JsResult { - crate::call_zero_is_throw(self, || ZigGlobalObject__readableStreamToBlob(self, value)) + /// `blob()` of a body that is a stream. `content_type` is its owner's, or undefined. + pub fn readable_stream_to_body_blob( + &self, + value: JSValue, + content_type: JSValue, + ) -> JsResult { + crate::call_zero_is_throw(self, || { + ZigGlobalObject__readableStreamToBodyBlob(self, value, content_type) + }) } pub fn readable_stream_to_form_data( @@ -1155,6 +1162,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, @@ -1498,6 +1511,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, @@ -1546,8 +1560,11 @@ unsafe extern "C" { value: JSValue, content_type: JSValue, ) -> JSValue; - safe fn ZigGlobalObject__readableStreamToBlob(this: &JSGlobalObject, value: JSValue) - -> JSValue; + safe fn ZigGlobalObject__readableStreamToBodyBlob( + this: &JSGlobalObject, + value: JSValue, + content_type: JSValue, + ) -> JSValue; safe fn ZigGlobalObject__makeNapiEnvForFFI(this: &JSGlobalObject) -> *mut c_void; diff --git a/src/jsc/JSPromise.rs b/src/jsc/JSPromise.rs index dd755e9019a8..3bfb6adb8550 100644 --- a/src/jsc/JSPromise.rs +++ b/src/jsc/JSPromise.rs @@ -201,24 +201,6 @@ impl JSPromise { Ok(promise) } - pub fn wrap_value(global: &JSGlobalObject, value: JSValue) -> JSValue { - if value.is_empty() { - return Self::resolved_promise_value(global, JSValue::UNDEFINED); - } else if value.is_empty_or_undefined_or_null() || !value.is_cell() { - return Self::resolved_promise_value(global, value); - } - - if value.js_type() == crate::JSType::JSPromise { - return value; - } - - if let Some(err) = value.to_error() { - return Self::rejected_promise(global, err).to_js(); - } - - Self::resolved_promise_value(global, value) - } - pub fn status(&self) -> Status { match JSC__JSPromise__status(self) { 0 => Status::Pending, diff --git a/src/jsc/ScriptExecutionContext.rs b/src/jsc/ScriptExecutionContext.rs index 1d83a625479f..9c2183e33599 100644 --- a/src/jsc/ScriptExecutionContext.rs +++ b/src/jsc/ScriptExecutionContext.rs @@ -155,19 +155,19 @@ impl ScriptExecutionContext { self.stopped.set(true); self.stop_again_queued.set(false); let result = self.stop_handles(reason); - self.close_sockets(); + self.close_sockets(reason); result } /// Close every client socket of a graph's context (its script is told nothing). - fn close_sockets(&self) { + fn close_sockets(&self, reason: StopReason) { if let Some(groups) = self .socket_groups .with_mut(|groups| groups.as_deref_mut().map(ptr::from_mut)) { // SAFETY: the box is dropped only with `self`; closing dispatches // close handlers, so no borrow of the cell is held across it. - unsafe { crate::rare_data::SocketGroups::close_all(groups) }; + unsafe { crate::rare_data::SocketGroups::close_all(groups, reason) }; } } diff --git a/src/jsc/VM.rs b/src/jsc/VM.rs index f95c6923be56..8c21adfccbf7 100644 --- a/src/jsc/VM.rs +++ b/src/jsc/VM.rs @@ -31,6 +31,7 @@ unsafe extern "C" { safe fn JSC__VM__heapSize(vm: &VM) -> usize; safe fn JSC__VM__collectAsync(vm: &VM, full: bool); safe fn JSC__VM__collectAsyncIdle(vm: &VM); + safe fn JSC__VM__collectAsyncForFinalizers(vm: &VM); safe fn JSC__VM__shrinkFootprintNow(vm: &VM) -> bool; safe fn JSC__VM__setStartupJITDeferralScale(vm: &VM, scale: f64); safe fn JSC__VM__executionForbidden(vm: &VM) -> bool; @@ -114,6 +115,11 @@ impl VM { JSC__VM__collectAsyncIdle(self) } + /// A full collection that finishes although this thread parks, unless one that does is already under way. + pub(crate) fn collect_async_for_finalizers(&self) { + JSC__VM__collectAsyncForFinalizers(self) + } + /// Let go of what JSC gets back cheaply, of functions that have no linked code any more (an idle collection has found /// them not running): unlinked bytecode it can decode again from a bytecode cache, the parser's caches. Nothing that /// would have to be parsed again. The caller's next full collection frees it. `false`: nothing was done, because JS diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index dbc3ff61239c..c92481bb546d 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -372,7 +372,7 @@ pub struct VirtualMachine { pub module_loader: ModuleLoader::ModuleLoader, - pub(crate) gc_controller: crate::GarbageCollectionController, + pub gc_controller: crate::GarbageCollectionController, /// The `WebWorker` whose thread this VM runs on (`None` on the main thread). /// The thread holds a ref on it for its whole life. pub worker: Option<*const c_void>, @@ -396,6 +396,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, + /// Set by a deferred task that can call JS, so the checkpoint drains microtasks again after the queue. `Cell` for the same reason. + pub deferred_task_called_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, @@ -743,6 +745,7 @@ impl VMHolder { None } + /// WebKit's `WTF::RunLoop` calls this through a weak symbol, so nothing in this repository names it. Without it a Worker's run loop isn't Bun's. #[unsafe(no_mangle)] extern "C" fn Bun__thisThreadHasVM() -> bool { VM.get().is_some() @@ -3065,8 +3068,7 @@ pub struct RuntimeHooks { pub has_blob_url: fn(blob_id: &[u8]) -> bool, /// `bun_aot::module_keys::name_of_text` of the text behind an object URL. pub name_text_of_blob_url: fn(blob_id: &[u8], vm: &VirtualMachine) -> Option>, - /// `Response::get_blob_without_call_frame` / - /// `Request::get_blob_without_call_frame`. If + /// `blob()` of a `Response` or `Request`. If /// `value` downcasts to a `Response` or `Request` (both live in /// `bun_runtime::webcore`), return its body Blob wrapped in a resolved /// Promise; `Ok(None)` to fall through to the `Blob`/`BuildMessage`/ @@ -4585,12 +4587,38 @@ impl VirtualMachine { } /// The TLS 1.2 list assigned through `tls.DEFAULT_CIPHERS`, `None` when never assigned. - pub fn tls_default_ciphers(&self) -> Option<&[u8]> { + pub(crate) fn tls_default_ciphers(&self) -> Option<&[u8]> { self.rare_data .as_deref() .and_then(RareData::tls_default_ciphers) } + /// Returns the `tls.setDefaultCACertificates()` list, or `None` if it was never called. + pub(crate) fn tls_default_ca(&self) -> Option<&[Box<[u8]>]> { + self.rare_data.as_deref().and_then(RareData::tls_default_ca) + } + + /// Fills what `given` leaves out from `NODE_TLS_REJECT_UNAUTHORIZED` (clients only), `tls.DEFAULT_CIPHERS` and `tls.setDefaultCACertificates()`. + pub fn tls_config( + &self, + given: bun_http::ssl_config::Builder, + is_server: bool, + ) -> bun_http::ssl_config::SSLConfig { + let reject_unauthorized = given + .reject_unauthorized + .unwrap_or_else(|| is_server || self.get_tls_reject_unauthorized()); + given.build( + reject_unauthorized, + self.tls_default_ciphers(), + self.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); @@ -6488,6 +6516,12 @@ impl VirtualMachine { // node:quic events into the dead realm. if !shares_global { rare.node_quic_callbacks.deinit(); + // The next file's `node:tls` starts from the stock defaults, so native clients must too. + let had_ca = rare.tls_default_ca.take().is_some(); + let had_ciphers = rare.tls_default_ciphers.take().is_some(); + if had_ca || had_ciphers { + rare.drop_client_tls_defaults(); + } } } let _ = self.event_loop_mut().drain_microtasks(); diff --git a/src/jsc/ZigException.rs b/src/jsc/ZigException.rs index fdbb3921b694..86feb9137848 100644 --- a/src/jsc/ZigException.rs +++ b/src/jsc/ZigException.rs @@ -53,11 +53,6 @@ impl ZigException<'_> { ZigException__collectSourceLines(value, global, self); } - // `ZigException__fromException` is declared in headers.h but has no C++ - // body (bindings.cpp dropped it; the only producer is - // `JSC__JSValue__toZigException` which writes through an out-param), so - // there is intentionally no `from_exception` here. - pub(crate) fn add_to_error_list( &mut self, error_list: &mut Vec, diff --git a/src/jsc/array_buffer.rs b/src/jsc/array_buffer.rs index 28df7e1e55ed..6b4ab0a1a637 100644 --- a/src/jsc/array_buffer.rs +++ b/src/jsc/array_buffer.rs @@ -1176,11 +1176,6 @@ impl MarkedArrayBuffer { // `no_mangle` dropped: 0 C++ refs (phase_c_exports.rs mention is a comment). pub use bun_alloc::c_thunks::mi_free_bytes as MarkedArrayBuffer_deallocator; -// LAYERING: `BlobArrayBuffer_deallocator` releases a -// `Blob::Store` ref. `Store` is a `bun_runtime` type, so the `#[no_mangle]` -// export lives next to it at `bun_runtime::webcore::blob::Store` — `bun_jsc` -// cannot own this symbol without a dep cycle. C++ links by name only. - // ────────────────────────────────────────────────────────────────────────── // Free functions // ────────────────────────────────────────────────────────────────────────── diff --git a/src/jsc/bindings/BunClientData.h b/src/jsc/bindings/BunClientData.h index dc44ae96b889..00bd03a0e74e 100644 --- a/src/jsc/bindings/BunClientData.h +++ b/src/jsc/bindings/BunClientData.h @@ -265,7 +265,7 @@ class JSVMClientData : public JSC::VM::ClientData { // `bun test --isolate` loads DOM packages into this global, which outlives the test files. gcProtect'ed for the VM's life. JSC::JSGlobalObject* testEnvironmentRealm { nullptr }; - // Idle full collections GarbageCollectionController requested that have not finished (see + // Collections GarbageCollectionController requested that finish while the JS thread is parked, and have not yet (see // Bun__JSC_onBeforeWait). Counted up on the JS thread, down at the end of each one's end phase, on either thread. std::atomic idleCollectionsPending { 0 }; // Their GCRequest::didFinishEndPhase, made once (JSC__VM__collectAsyncIdle). diff --git a/src/jsc/bindings/BunCommonStrings.cpp b/src/jsc/bindings/BunCommonStrings.cpp index 40906df43ff0..5b6d767be036 100644 --- a/src/jsc/bindings/BunCommonStrings.cpp +++ b/src/jsc/bindings/BunCommonStrings.cpp @@ -226,6 +226,7 @@ enum class CommonStringsForRust : uint8_t { fetchOptionTls = 30, fetchOptionUnix = 31, fetchOptionVerbose = 32, + fetchBasic = 33, }; static JSC::JSValue toJS(Zig::GlobalObject* globalObject, CommonStringsForRust commonString) @@ -298,6 +299,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 cf4e311032a0..656a0f433080 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 cb08d4d092b4..c6bae6671b13 100644 --- a/src/jsc/bindings/BunString.cpp +++ b/src/jsc/bindings/BunString.cpp @@ -655,13 +655,15 @@ 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 }; + if (excludeFragment && url.hasFragmentIdentifier()) + return Bun::toStringRef(url.stringWithoutFragmentIdentifier()); return Bun::toStringRef(url.string()); } @@ -675,7 +677,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(); @@ -683,7 +685,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/DOMFormData.cpp b/src/jsc/bindings/DOMFormData.cpp index d3517723951d..cbdf9390572e 100644 --- a/src/jsc/bindings/DOMFormData.cpp +++ b/src/jsc/bindings/DOMFormData.cpp @@ -33,7 +33,6 @@ #include "URLSearchParams.h" #include "VectorSizeLimit.h" #include "wtf/DebugHeap.h" -#include namespace WebCore { @@ -65,18 +64,6 @@ ExceptionOr> DOMFormData::create(ScriptExecutionContext* contex return newFormData; } -String DOMFormData::toURLEncodedString() -{ - WTF::URLParser::URLEncodedForm form; - form.reserveInitialCapacity(m_items.size()); - for (auto& item : m_items) { - if (auto value = std::get_if(&item.data)) - form.append({ item.name, *value }); - } - - return WTF::URLParser::serialize(form); -} - extern "C" void DOMFormData__forEach(DOMFormData* form, void* context, void (*callback)(void* context, EncodedSlice*, void*, EncodedSlice*, uint8_t)) { for (auto& item : form->items()) { diff --git a/src/jsc/bindings/DOMFormData.h b/src/jsc/bindings/DOMFormData.h index ed888a0ae87d..c9651f217063 100644 --- a/src/jsc/bindings/DOMFormData.h +++ b/src/jsc/bindings/DOMFormData.h @@ -77,8 +77,6 @@ class DOMFormData : public RefCounted, public ContextDestructionObs size_t count() const { return m_items.size(); } size_t memoryCost() const; - String toURLEncodedString(); - class Iterator { public: explicit Iterator(DOMFormData&); diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp index 0eba26508c0b..ebd39329f360 100644 --- a/src/jsc/bindings/ErrorCode.cpp +++ b/src/jsc/bindings/ErrorCode.cpp @@ -2062,6 +2062,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; } @@ -2158,6 +2161,7 @@ static constexpr SimpleErrorMessage simpleErrorMessages[] = { { ErrorCode::ERR_HTTP2_TOO_MANY_ORIGINS, 1, { "The server sent more ORIGIN frames than the allowed number of "_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_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 } }, @@ -2684,6 +2688,8 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Bun::jsFunctionMakeErrorWithCode, __att return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP_TRAILER_INVALID, "Trailers are invalid with this transfer encoding"_s)); case ErrorCode::ERR_HTTP_SOCKET_ASSIGNED: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP_SOCKET_ASSIGNED, "ServerResponse has an already assigned socket"_s)); + case ErrorCode::ERR_HTTP_SOCKET_ENCODING: + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP_SOCKET_ENCODING, "Changing the socket encoding is not allowed per RFC7230 Section 3."_s)); case ErrorCode::ERR_STREAM_RELEASE_LOCK: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_STREAM_RELEASE_LOCK, "Stream reader cancelled via releaseLock()"_s)); case ErrorCode::ERR_SOCKET_CONNECTION_TIMEOUT: diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index 3f27b3a32c67..52a853853abf 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -359,6 +359,7 @@ const errors: ErrorCodeMapping = [ ["ERR_REDIS_SERVER_ERROR", Error, "RedisError"], ["ERR_FFI_CC_DISABLED", Error], ["HPE_INVALID_URL", Error], + ["ERR_HTTP_SOCKET_ENCODING", Error], ["ERR_HTTP2_TOO_MANY_ORIGINS", Error], ]; export default errors; diff --git a/src/jsc/bindings/H2HeadersMaterializer.cpp b/src/jsc/bindings/H2HeadersMaterializer.cpp index 7e7265b5ee8c..88ac273bdc51 100644 --- a/src/jsc/bindings/H2HeadersMaterializer.cpp +++ b/src/jsc/bindings/H2HeadersMaterializer.cpp @@ -11,10 +11,10 @@ #include #include #include +#include #include #include #include -#include #include #include "HTTPHeaderIdentifiers.h" #include "HTTPHeaderNames.h" @@ -171,15 +171,13 @@ extern "C" [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue Bun__h2__materializ arr->putDirectIndex(globalObject, arr->length(), fieldValue); RETURN_IF_EXCEPTION(scope, {}); } else { - // cookie joins with "; ", everything else with ", " (RFC 7230 §3.2.2). - auto existingString = existing.toWTFString(globalObject); + // cookie joins with "; ", everything else with ", " (RFC 7230 §3.2.2). A rope, so a repeat doesn't copy what's joined so far. + JSString* existingString = existing.toString(globalObject); RETURN_IF_EXCEPTION(scope, {}); - auto valueString = valueStr->getString(globalObject); + JSString* separator = jsNontrivialString(vm, isCookie ? "; "_s : ", "_s); + JSString* joined = jsString(globalObject, existingString, separator, valueStr); RETURN_IF_EXCEPTION(scope, {}); - auto joined = isCookie - ? WTF::makeString(existingString, "; "_s, valueString) - : WTF::makeString(existingString, ", "_s, valueString); - obj->putDirect(vm, ident, jsString(vm, WTF::move(joined)), 0); + obj->putDirect(vm, ident, joined, 0); } } } diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index 2e4e244c4345..9a901cda5e10 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -360,7 +360,6 @@ bool JSEnvironmentVariableMap::deleteProperty(JSCell* cell, JSGlobalObject* glob extern "C" int Bun__getTLSRejectUnauthorizedValue(); extern "C" void Bun__setTLSRejectUnauthorizedValue(int value); -extern "C" int Bun__getVerboseFetchValue(); extern "C" void Bun__setVerboseFetchValue(int value); ALWAYS_INLINE static Identifier NODE_TLS_REJECT_UNAUTHORIZED_PRIVATE_PROPERTY(VM& vm) @@ -1129,3 +1128,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/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index 3c97d67112af..04ffdf82e2e0 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -19,6 +19,7 @@ #include #include #include +#include "_NativeModule.h" #include #include #include @@ -3080,6 +3081,22 @@ __attribute__((minsize)) static JSC::EncodedJSValue spyOn(JSC::JSGlobalObject* l RETURN_IF_EXCEPTION(scope, {}); } + // A builtin module's lazy export (fs.ReadStream) is an accessor until its first read. Read it, then spy on the value. + if (access == SpyAccess::Value && object->type() != JSC::JSType::ProxyObjectType) { + JSC::PropertySlot probe(object, JSC::PropertySlot::InternalMethodType::HasProperty); + bool has = object->getPropertySlot(globalObject, propertyKey, probe); + RETURN_IF_EXCEPTION(scope, {}); + if (has && probe.isAccessor() && probe.slotBase() == object) { + JSObject* getter = probe.getterSetter()->getter(); + // A getter in C++ (Map.prototype.size) asks its receiver, which a prototype is not. + auto* function = dynamicDowncast(getter); + if (Zig::isBunDefinedGetter(getter) && !(function && function->isNonBoundHostFunction())) { + probe.getValue(globalObject, propertyKey); + RETURN_IF_EXCEPTION(scope, {}); + } + } + } + JSC::PropertySlot slot(object, JSC::PropertySlot::InternalMethodType::HasProperty); bool hasValue = object->getPropertySlot(globalObject, propertyKey, slot); RETURN_IF_EXCEPTION(scope, {}); diff --git a/src/jsc/bindings/JSSocketHandlers.h b/src/jsc/bindings/JSSocketHandlers.h index 558c478a7756..c53beb94b81a 100644 --- a/src/jsc/bindings/JSSocketHandlers.h +++ b/src/jsc/bindings/JSSocketHandlers.h @@ -16,9 +16,9 @@ using namespace JSC; // callbacks live exactly as long as something that can still invoke them. // Replaces manual gcProtect/gcUnprotect of raw JSValues, and lets `reload` swap // callbacks in place for live sockets. -class JSSocketHandlers final : public JSC::JSInternalFieldObjectImpl<14> { +class JSSocketHandlers final : public JSC::JSInternalFieldObjectImpl<15> { public: - using Base = JSC::JSInternalFieldObjectImpl<14>; + using Base = JSC::JSInternalFieldObjectImpl<15>; // Field order is ABI shared with src/runtime/socket/Handlers.rs. enum class Field : uint32_t { @@ -35,6 +35,7 @@ class JSSocketHandlers final : public JSC::JSInternalFieldObjectImpl<14> { Keylog, ServerName, ALPNCallback, + WriteComplete, // Not a callback: the `Bun.connect` promise, cleared once settled. Promise, }; diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index 5d1d96410904..b4cf6f7f6266 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -4,7 +4,6 @@ #include #include "helpers.h" #include "BunClientData.h" -#include "BunCompiledModules.h" #include #include @@ -30,12 +29,6 @@ using namespace JSC; using namespace WebCore; using namespace std::string_view_literals; -extern "C" EncodedJSValue Server__setAppFlags(JSC::JSGlobalObject*, EncodedJSValue, bool require_host_header, bool use_strict_method_validation, uint8_t lenient_http_flags, bool http_allow_half_open); -extern "C" EncodedJSValue Server__setOnClientError(JSC::JSGlobalObject*, EncodedJSValue, EncodedJSValue); -extern "C" EncodedJSValue Server__setOnConnection(JSC::JSGlobalObject*, EncodedJSValue, EncodedJSValue); -extern "C" EncodedJSValue Server__setMaxHTTPHeaderSize(JSC::JSGlobalObject*, EncodedJSValue, uint64_t); -extern "C" EncodedJSValue Server__setMaxHeadersCount(JSC::JSGlobalObject*, EncodedJSValue, uint32_t); - // Bit layout must stay in sync with kDispatchBits* in src/js/node/_http_server.ts. static constexpr uint32_t kDispatchConnClose = 1 << 0; static constexpr uint32_t kDispatchConnUpgrade = 1 << 1; @@ -85,8 +78,8 @@ using RequestArguments = MarkedArgumentBufferWithSize<16>; // 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, RequestArguments& 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, RequestArguments& args, WTF::Vector& flatHeaders, JSC::JSGlobalObject* globalObject, JSC::VM& vm) { { std::string_view fullURLStdStr = request->getFullUrl(); @@ -105,9 +98,9 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal uint32_t bits = 0; // llhttp's F_CONNECTION_CLOSE / F_CONNECTION_UPGRADE: a whole list item. - if (request->hasConnectionClose(true)) + if (request->getConnectionClose()) bits |= kDispatchConnClose; - if (request->hasConnectionToken("upgrade") || request->isUpgradeRequest()) + if (request->hasConnectionToken("upgrade")) bits |= kDispatchConnUpgrade; for (auto it = request->begin(); it != request->end(); ++it) { auto pair = *it; @@ -163,6 +156,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, @@ -347,38 +351,23 @@ extern "C" EncodedJSValue Bun__NodeHTTP__buildRawHeadersArray(JSC::JSGlobalObjec // them on demand. extern "C" void NodeHTTPResponse__adoptRawRequestHeaders(void* nodeHttpResponse, const uint8_t* data, size_t length); -template -static void assignOnNodeJSCompat(uWS::TemplatedApp* app) -{ - app->enableNodeHttpCompat(); - app->setOnSocketClosed([](void* socketData, int is_ssl, struct us_socket_t* rawSocket, int readError, bool peerEnded) -> void { - auto* socket = reinterpret_cast(socketData); - ASSERT(rawSocket == socket->socket || socket->socket == nullptr); - // A TLS socket's ciphertext that the kernel didn't take is either still held here, or was thrown away on the way here. - socket->closedWithUnsentData = !reinterpret_cast*>(rawSocket)->hasFullyDrained() || us_socket_ssl_spill_dropped(rawSocket); - socket->onClose(readError, peerEnded); - }); - app->setOnSocketDrain([](void* socketData, int is_ssl, struct us_socket_t* rawSocket) -> void { - auto* socket = reinterpret_cast(socketData); - ASSERT(rawSocket == socket->socket || socket->socket == nullptr); - socket->onDrain(); - }); - app->setOnSocketData([](void* socketData, int is_ssl, struct us_socket_t* rawSocket, const char* data, int length, bool last) -> void { - auto* socket = reinterpret_cast(socketData); - ASSERT(rawSocket == socket->socket || socket->socket == nullptr); - socket->onData(data, length, last); - }); - app->setOnSocketUpgraded([](void* socketData, int is_ssl, struct us_socket_t* rawSocket) -> void { - reinterpret_cast(socketData)->onUpgraded(rawSocket); - }); -} +static constexpr uWS::NodeHttpSocketHooks nodeHttpSocketHooks = { + .onClosed = [](void* socketData, int readError, bool peerEnded) { reinterpret_cast(socketData)->onClose(readError, peerEnded); }, + .onDrain = [](void* socketData) { reinterpret_cast(socketData)->onDrain(); }, + .onRawRead = [](void* socketData, const char* data, int length) { reinterpret_cast(socketData)->onRawRead(data, length); }, + .onReadParsed = [](void* socketData) { reinterpret_cast(socketData)->onReadParsed(); }, + .onUpgradeBodyEnded = [](void* socketData) { reinterpret_cast(socketData)->onUpgradeBodyEnded(); }, + .onHandshake = [](void* socketData, int success, us_bun_verify_error_t verifyError) { reinterpret_cast(socketData)->onHandshake(success, verifyError); }, + .onPeerEnded = [](void* socketData) { return reinterpret_cast(socketData)->onPeerEnded(); }, + .halfCloseAfterDrain = [](void* socketData) { reinterpret_cast(socketData)->halfCloseAfterDrain(); }, +}; extern "C" void NodeHTTP_assignOnNodeJSCompat(bool is_ssl, void* uws_app) { if (is_ssl) { - assignOnNodeJSCompat(reinterpret_cast*>(uws_app)); + reinterpret_cast*>(uws_app)->enableNodeHttpCompat(&nodeHttpSocketHooks); } else { - assignOnNodeJSCompat(reinterpret_cast*>(uws_app)); + reinterpret_cast*>(uws_app)->enableNodeHttpCompat(&nodeHttpSocketHooks); } } @@ -397,7 +386,6 @@ template static EncodedJSValue NodeHTTPServer__onRequest( size_t any_server, Zig::GlobalObject* globalObject, - JSValue thisValue, JSValue callback, JSValue methodString, uWS::HttpRequest* request, @@ -409,15 +397,14 @@ static EncodedJSValue NodeHTTPServer__onRequest( JSObject* callbackObject = uncheckedDowncast(callback); RequestArguments args; - args.append(thisValue); // 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). + // Pipelined: an earlier response is in flight, so this one is queued and gets the connection at its turn (startRawResponse). const bool isPipelinedDispatch = (httpResponseData->state & uWS::HttpResponseData::HTTP_NODE_PIPELINED_DISPATCH) != 0; const bool isCurrent = !isPipelinedDispatch || !httpResponseData->socketData; @@ -460,7 +447,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); @@ -476,8 +463,8 @@ static EncodedJSValue NodeHTTPServer__onRequest( return JSValue::encode(returnValue); } -template -static void writeResponseHeader(uWS::HttpResponse* res, const WTF::StringView& name, const WTF::StringView& value) +template +static void writeResponseHeader(Response* res, const WTF::StringView& name, const WTF::StringView& value) { ASSERT(name.containsOnlyASCII()); HTTPLatin1String nameBytes(name); @@ -485,25 +472,7 @@ static void writeResponseHeader(uWS::HttpResponse* res, const WTF::String res->writeHeader(nameBytes.view(), valueBytes.view()); } -// Connection is `1#connection-option` (RFC 9112 §9.3): look for the "close" -// token anywhere in a comma-separated list. Mirrors the /(?:^|\W)close(?:$|\W)/i -// check used by Bun's node:http layer. -static bool connectionValueHasClose(const WTF::String& value) -{ - size_t pos = 0; - while ((pos = value.findIgnoringASCIICase("close"_s, pos)) != WTF::notFound) { - bool leftOk = pos == 0 || !isASCIIAlphanumeric(value[pos - 1]); - size_t end = pos + 5; - bool rightOk = end >= value.length() || !isASCIIAlphanumeric(value[end]); - if (leftOk && rightOk) - return true; - pos = end; - } - return false; -} - -// Headers that user code can't set on a 101 response. HttpResponse::upgrade() writes the handshake headers itself -// (RFC 6455 4.2.2), and a 1xx response must not have Content-Length or Transfer-Encoding (RFC 9110 8.6). +// Headers that user code can't set on a 101 response. HttpResponse::upgrade() writes the handshake headers itself (RFC 6455 4.2.2). static bool isReservedWebSocketUpgradeHeader(WebCore::HTTPHeaderName name) { switch (name) { @@ -514,16 +483,15 @@ static bool isReservedWebSocketUpgradeHeader(WebCore::HTTPHeaderName name) case WebCore::HTTPHeaderName::SecWebSocketVersion: case WebCore::HTTPHeaderName::SecWebSocketProtocol: case WebCore::HTTPHeaderName::SecWebSocketExtensions: - case WebCore::HTTPHeaderName::ContentLength: - case WebCore::HTTPHeaderName::TransferEncoding: return true; default: return false; } } -template -static void writeFetchHeadersToUWSResponse(WebCore::FetchHeaders& headers, uWS::HttpResponse* res) +// A Bun.serve handler's headers, for any protocol. writeHeader() knows which of them uWS would otherwise add, and which it frames by itself. +template +static void writeFetchHeadersToUWSResponse(WebCore::FetchHeaders& headers, Response* res) { auto& internalHeaders = headers.internalHeaders(); @@ -532,67 +500,16 @@ static void writeFetchHeadersToUWSResponse(WebCore::FetchHeaders& headers, uWS:: res->writeHeader(std::string_view("set-cookie", 10), valueBytes.view()); } - auto* data = res->getHttpResponseData(); - for (const auto& header : internalHeaders.commonHeaders()) { if constexpr (forWebSocketUpgrade) { if (isReservedWebSocketUpgradeHeader(header.key)) continue; } - - const auto& name = WebCore::httpHeaderNameString(header.key); - const auto& value = header.value; - - // We have to tell uWS not to automatically insert a TransferEncoding or Date header. - // Otherwise, you get this when using Fastify; - // - // ❯ curl http://localhost:3000 --verbose - // * Trying [::1]:3000... - // * Connected to localhost (::1) port 3000 - // > GET / HTTP/1.1 - // > Host: localhost:3000 - // > User-Agent: curl/8.4.0 - // > Accept: */* - // > - // < HTTP/1.1 200 OK - // < Content-Type: application/json; charset=utf-8 - // < Content-Length: 17 - // < Date: Sun, 06 Oct 2024 13:37:01 GMT - // < Transfer-Encoding: chunked - // < - // - if (header.key == WebCore::HTTPHeaderName::ContentLength) { - if (!(data->state & uWS::HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER)) { - data->state |= uWS::HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER; - res->writeMark(); - } - } - - // Prevent automatic Date header insertion when user provides one - if (header.key == WebCore::HTTPHeaderName::Date) { - data->state |= uWS::HttpResponseData::HTTP_WROTE_DATE_HEADER; - } - - // Prevent automatic Transfer-Encoding: chunked insertion when user provides one - if (header.key == WebCore::HTTPHeaderName::TransferEncoding) { - data->state |= uWS::HttpResponseData::HTTP_WROTE_TRANSFER_ENCODING_HEADER; - } - - // RFC 9112 §9.6: a server that sends the "close" connection option MUST - // 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; - } - writeResponseHeader(res, name, value); + writeResponseHeader(res, WebCore::httpHeaderNameString(header.key), header.value); } for (auto& header : internalHeaders.uncommonHeaders()) { - const auto& name = header.key; - const auto& value = header.value; - - writeResponseHeader(res, name, value); + writeResponseHeader(res, header.key, header.value); } } @@ -607,6 +524,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; +// Bits 5 and 6, no header line: how JS framed the body, in uWS's numbering. 0 leaves it to uWS. +static constexpr uint32_t kAutoHeaderBodyShift = 5; // "Date: \r\n", rebuilt at most once per second. Hand-rolled // (not strftime) so the day/month names are locale-independent. @@ -668,9 +587,11 @@ static void writeAutoHeaders(uWS::HttpResponse* response, uint32_t autoHe if (autoHeaderBits & kAutoHeaderTransferEncodingChunked) { static constexpr const char te[] = "Transfer-Encoding: chunked\r\n"; response->uWS::template AsyncSocket::write(te, sizeof(te) - 1); - // Same state the flat-array path sets when it sees the header, so uWS - // chunk-frames the body. - response->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_WROTE_TRANSFER_ENCODING_HEADER; + } + using Data = uWS::HttpResponseData; + static_assert(Data::HTTP_BODY_UNTIL_CLOSE == 2 * Data::HTTP_BODY_CHUNKED && Data::HTTP_BODY_NONE == 3 * Data::HTTP_BODY_CHUNKED); + if (uint32_t framing = (autoHeaderBits >> kAutoHeaderBodyShift) & 3) { + response->setFraming(framing * Data::HTTP_BODY_CHUNKED); } } @@ -686,11 +607,6 @@ static std::string_view autoConnectionHeaders(uint32_t autoHeaderBits, uint32_t return {}; } -static bool isStatusWithoutBody(std::string_view status, char first, char second, char third) -{ - return status.length() >= 3 && status[0] == first && status[1] == second && status[2] == third && (status.length() == 3 || status[3] == ' '); -} - template static bool tryWriteHeadAtOnce(uWS::HttpResponse* response, std::string_view status, JSC::JSArray* pairs, uint32_t autoHeaderBits, uint32_t keepAliveTimeoutSecs, const std::string_view* body = nullptr) { @@ -700,7 +616,7 @@ static bool tryWriteHeadAtOnce(uWS::HttpResponse* response, std::string_v return false; WTF::Vector texts; - uint32_t state = Data::HTTP_STATUS_CALLED | Data::HTTP_WROTE_DATE_HEADER; + uint32_t state = Data::HTTP_STATUS_CALLED | Data::HTTP_WROTE_DATE_HEADER | Data::HTTP_WROTE_CONNECTION_HEADER; size_t size = "HTTP/1.1 \r\n"sv.length() + status.length(); unsigned length = pairs->length(); for (unsigned i = 0; i + 1 < length; i += 2) { @@ -716,25 +632,12 @@ static bool tryWriteHeadAtOnce(uWS::HttpResponse* response, std::string_v pair[part] = { reinterpret_cast(span.data()), span.size() }; } auto [name, value] = pair; - auto isNamed = [&](std::string_view lowercase) { - return std::ranges::equal(name, lowercase, [](char written, char letter) { - return WTF::toASCIILower(written) == letter; - }); - }; - switch (name.length()) { - case 1: - if (!name[0]) { - state |= value == "2"sv ? Data::HTTP_NO_BODY_STATUS : Data::HTTP_CLOSE_DELIMITED; - continue; - } - break; - case 14: - if (isNamed("content-length"sv)) - state |= Data::HTTP_WROTE_CONTENT_LENGTH_HEADER; + switch (uWS::classifyResponseField(name, value)) { + case uWS::ResponseField::ContentLength: + state |= Data::HTTP_WROTE_CONTENT_LENGTH_HEADER; break; - case 17: - if (isNamed("transfer-encoding"sv)) - state |= Data::HTTP_WROTE_TRANSFER_ENCODING_HEADER; + case uWS::ResponseField::TransferEncoding: + state |= Data::HTTP_WROTE_TRANSFER_ENCODING_HEADER; break; default: break; @@ -749,17 +652,17 @@ static bool tryWriteHeadAtOnce(uWS::HttpResponse* response, std::string_v std::string_view encoding = autoHeaderBits & kAutoHeaderTransferEncodingChunked ? "Transfer-Encoding: chunked\r\n"sv : std::string_view(); size += date.length() + connection.length() + encoding.length(); - if ((status.length() >= 3 && status[0] == '1' && (status.length() == 3 || status[3] == ' ')) || isStatusWithoutBody(status, '2', '0', '4') || isStatusWithoutBody(status, '3', '0', '4')) - state |= Data::HTTP_NO_BODY_STATUS; - if (!encoding.empty()) - state |= Data::HTTP_WROTE_TRANSFER_ENCODING_HEADER; + // What JS decided replaces what the status says, like writeAutoHeaders() after writeStatus(). 0 is undecided. + uint32_t framing = ((autoHeaderBits >> kAutoHeaderBodyShift) & 3) * Data::HTTP_BODY_CHUNKED; + if (!framing && response->isStatusWithoutBody(status)) + framing = Data::HTTP_BODY_NONE; char digits[uWS::utils::U64_MAX_DIGITS]; std::string_view contentLength; if (body) { constexpr uint32_t framedOtherwise = Data::HTTP_WRITE_CALLED | Data::HTTP_END_CALLED | Data::HTTP_CONNECTION_CLOSE | Data::HTTP_WROTE_TRANSFER_ENCODING_HEADER | Data::HTTP_ANCIENT_REQUEST - | Data::HTTP_NO_BODY_STATUS | Data::HTTP_CLOSE_DELIMITED | Data::HTTP_NODE_HAS_RESPONSE_TRAILERS; - if (((httpResponseData->state | state) & framedOtherwise) || httpResponseData->offset) + | Data::HTTP_FRAMING_DECIDED | Data::HTTP_NODE_HAS_RESPONSE_TRAILERS; + if (framing || ((httpResponseData->state | state) & framedOtherwise) || httpResponseData->offset) return false; if (!((httpResponseData->state | state) & Data::HTTP_WROTE_CONTENT_LENGTH_HEADER)) { contentLength = { digits, static_cast(uWS::utils::u64toa(body->length(), digits)) }; @@ -773,6 +676,10 @@ static bool tryWriteHeadAtOnce(uWS::HttpResponse* response, std::string_v if (!at) return false; httpResponseData->state |= state; + if (body) + response->setFraming(Data::HTTP_BODY_LENGTH); + else if (framing) + response->setFraming(framing); auto append = [&](std::string_view text) { if (text.empty()) @@ -866,136 +773,33 @@ static void NodeHTTPServer__writeHead( auto& vm = globalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); - JSObject* headersObject = headersObjectValue.getObject(); if (!response->uWS::template AsyncSocket::isCorked() && response->getBufferedAmount() == 0) { response->uWS::template AsyncSocket::cork(); } - if (auto* pairs = headersObject ? dynamicDowncast(headersObject) : nullptr; pairs && tryWriteHeadAtOnce(response, std::string_view(statusMessage, statusMessageLength), pairs, autoHeaderBits, keepAliveTimeoutSecs)) + // A flat [name, value, ...] array, so that a repeated name (Set-Cookie) gets one header line per value, like in Node.js. + auto* pairsArray = dynamicDowncast(headersObjectValue); + if (pairsArray && tryWriteHeadAtOnce(response, std::string_view(statusMessage, statusMessageLength), pairsArray, autoHeaderBits, keepAliveTimeoutSecs)) return; 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; - - // 204/304 responses must not carry any body framing, even when the user - // explicitly set a Transfer-Encoding header (Node.js suppresses the - // chunked framing and closes the connection for those). - if (statusMessageLength >= 3 && (memcmp(statusMessage, "204", 3) == 0 || memcmp(statusMessage, "304", 3) == 0) - && (statusMessageLength == 3 || statusMessage[3] == ' ')) { - response->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_NO_BODY_STATUS; - } + // 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; - if (headersObject) { - if (auto* fetchHeaders = dynamicDowncast(headersObject)) { - writeFetchHeadersToUWSResponse(fetchHeaders->wrapped(), response); + if (pairsArray) { + unsigned length = pairsArray->length(); + for (unsigned i = 0; i + 1 < length; i += 2) { + JSValue nameValue = pairsArray->getIndex(globalObject, i); RETURN_IF_EXCEPTION(scope, void()); - if (autoHeaderBits) writeAutoHeaders(response, autoHeaderBits, keepAliveTimeoutSecs); - return; - } - - // A flat [name, value, name, value, ...] array. Used by node:http's - // ServerResponse so that repeated header names (multiple Set-Cookie or - // duplicate Content-Length values, etc.) are written as separate - // header lines exactly as Node.js does. - if (auto* pairsArray = dynamicDowncast(headersObject)) { - auto* httpResponseData = response->getHttpResponseData(); - unsigned length = pairsArray->length(); - for (unsigned i = 0; i + 1 < length; i += 2) { - JSValue nameValue = pairsArray->getIndex(globalObject, i); - RETURN_IF_EXCEPTION(scope, void()); - JSValue headerValue = pairsArray->getIndex(globalObject, i + 1); - RETURN_IF_EXCEPTION(scope, void()); - - String name = nameValue.toWTFString(globalObject); - RETURN_IF_EXCEPTION(scope, void()); - String value = headerValue.toWTFString(globalObject); - RETURN_IF_EXCEPTION(scope, void()); - - // node:http marks framing decisions with a NUL-named sentinel - // pair instead of a real header: value "1" = close-delimited - // (the user removed the framing headers), value "2" = no body - // (HEAD - suppress all body framing like 204/304). - if (name.length() == 1 && name[0] == 0) { - if (value == "2"_s) { - httpResponseData->state |= uWS::HttpResponseData::HTTP_NO_BODY_STATUS; - } else { - httpResponseData->state |= uWS::HttpResponseData::HTTP_CLOSE_DELIMITED; - } - continue; - } - - switch (name.length()) { - case 14: - if (equalLettersIgnoringASCIICase(name, "content-length"_s) && !(httpResponseData->state & uWS::HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER)) { - httpResponseData->state |= uWS::HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER; - response->writeMark(); - } - break; - case 4: - if (equalLettersIgnoringASCIICase(name, "date"_s)) - httpResponseData->state |= uWS::HttpResponseData::HTTP_WROTE_DATE_HEADER; - break; - case 17: - if (equalLettersIgnoringASCIICase(name, "transfer-encoding"_s)) - httpResponseData->state |= uWS::HttpResponseData::HTTP_WROTE_TRANSFER_ENCODING_HEADER; - break; - default: - break; - } - - writeResponseHeader(response, name, value); - } + JSValue headerValue = pairsArray->getIndex(globalObject, i + 1); RETURN_IF_EXCEPTION(scope, void()); - if (autoHeaderBits) writeAutoHeaders(response, autoHeaderBits, keepAliveTimeoutSecs); - return; - } - if (headersObject->hasNonReifiedStaticProperties()) [[unlikely]] { - headersObject->reifyAllStaticProperties(globalObject); + String name = nameValue.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, void()); - } - - auto* structure = headersObject->structure(); - - if (structure->canPerformFastPropertyEnumeration()) { - structure->forEachProperty(vm, [&](const auto& entry) { - JSValue headerValue = headersObject->getDirect(entry.offset()); - if (!headerValue.isString()) { - - return true; - } - - String key = entry.key(); - String value = headerValue.toWTFString(globalObject); - RETURN_IF_EXCEPTION(scope, false); - - writeResponseHeader(response, key, value); - - return true; - }); - } else { - PropertyNameArrayBuilder propertyNames(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude); - if (Bun::hasCompiledModules()) [[unlikely]] - headersObject->methodTable()->getOwnPropertyNames(headersObject, globalObject, propertyNames, DontEnumPropertiesMode::Exclude); - else - headersObject->getOwnPropertyNames(headersObject, globalObject, propertyNames, DontEnumPropertiesMode::Exclude); + String value = headerValue.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, void()); - for (unsigned i = 0; i < propertyNames.size(); ++i) { - JSValue headerValue = headersObject->getIfPropertyExists(globalObject, propertyNames[i]); - RETURN_IF_EXCEPTION(scope, void()); - if (!headerValue.isString()) { - continue; - } - - String key = propertyNames[i].string(); - String value = headerValue.toWTFString(globalObject); - RETURN_IF_EXCEPTION(scope, void()); - - writeResponseHeader(response, key, value); - } + writeResponseHeader(response, name, value); } } @@ -1030,7 +834,6 @@ extern "C" void NodeHTTPServer__writeHead_https( extern "C" EncodedJSValue NodeHTTPServer__onRequest_http( size_t any_server, Zig::GlobalObject* globalObject, - EncodedJSValue thisValue, EncodedJSValue callback, EncodedJSValue methodString, uWS::HttpRequest* request, @@ -1040,7 +843,6 @@ extern "C" EncodedJSValue NodeHTTPServer__onRequest_http( return NodeHTTPServer__onRequest( any_server, globalObject, - JSValue::decode(thisValue), JSValue::decode(callback), JSValue::decode(methodString), request, @@ -1051,7 +853,6 @@ extern "C" EncodedJSValue NodeHTTPServer__onRequest_http( extern "C" EncodedJSValue NodeHTTPServer__onRequest_https( size_t any_server, Zig::GlobalObject* globalObject, - EncodedJSValue thisValue, EncodedJSValue callback, EncodedJSValue methodString, uWS::HttpRequest* request, @@ -1061,7 +862,6 @@ extern "C" EncodedJSValue NodeHTTPServer__onRequest_https( return NodeHTTPServer__onRequest( any_server, globalObject, - JSValue::decode(thisValue), JSValue::decode(callback), JSValue::decode(methodString), request, @@ -1069,105 +869,10 @@ extern "C" EncodedJSValue NodeHTTPServer__onRequest_https( nodeHttpResponsePtr); } -// Node's static_cast(double) is undefined for these: NaN or below 1 selects the default limit (0), 2^64 or more selects none. -static uint64_t maxHTTPHeaderSizeFromNumber(double value) -{ - if (!(value >= 1)) return 0; - if (value >= 18446744073709551616.0) return UINT64_MAX; - return static_cast(value); -} - -JSC_DEFINE_HOST_FUNCTION(jsHTTPSetCustomOptions, (JSGlobalObject * globalObject, CallFrame* callFrame)) -{ - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - ASSERT(callFrame->argumentCount() == 8); - // This is an internal binding. - JSValue serverValue = callFrame->uncheckedArgument(0); - JSValue requireHostHeader = callFrame->uncheckedArgument(1); - JSValue useStrictMethodValidation = callFrame->uncheckedArgument(2); - JSValue lenientHttpFlags = callFrame->uncheckedArgument(3); - JSValue maxHeaderSize = callFrame->uncheckedArgument(4); - JSValue callback = callFrame->uncheckedArgument(5); - JSValue onConnectionCallback = callFrame->argument(6); - JSValue httpAllowHalfOpen = callFrame->argument(7); - - double maxHeaderSizeNumber = maxHeaderSize.toNumber(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - int32_t lenientBits = lenientHttpFlags.toInt32(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - - Server__setAppFlags(globalObject, JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), useStrictMethodValidation.toBoolean(globalObject), static_cast(lenientBits & 0x3), httpAllowHalfOpen.toBoolean(globalObject)); - RETURN_IF_EXCEPTION(scope, {}); - - Server__setMaxHTTPHeaderSize(globalObject, JSValue::encode(serverValue), maxHTTPHeaderSizeFromNumber(maxHeaderSizeNumber)); - RETURN_IF_EXCEPTION(scope, {}); - - Server__setOnClientError(globalObject, JSValue::encode(serverValue), JSValue::encode(callback)); - RETURN_IF_EXCEPTION(scope, {}); - - if (onConnectionCallback.isCallable()) { - Server__setOnConnection(globalObject, JSValue::encode(serverValue), JSValue::encode(onConnectionCallback)); - RETURN_IF_EXCEPTION(scope, {}); - } - - return JSValue::encode(jsUndefined()); -} - -// Pushes only the parser/handler flags. Unlike setServerCustomOptions this rebinds no -// callbacks, so it is safe to call on a listening server (server.httpAllowHalfOpen is -// assignable at any time, like Node's). -JSC_DEFINE_HOST_FUNCTION(jsHTTPSetAppFlags, (JSGlobalObject * globalObject, CallFrame* callFrame)) -{ - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - ASSERT(callFrame->argumentCount() == 5); - // This is an internal binding. - JSValue serverValue = callFrame->uncheckedArgument(0); - JSValue requireHostHeader = callFrame->uncheckedArgument(1); - JSValue useStrictMethodValidation = callFrame->uncheckedArgument(2); - JSValue lenientHttpFlags = callFrame->uncheckedArgument(3); - JSValue httpAllowHalfOpen = callFrame->argument(4); - - int32_t lenientBits = lenientHttpFlags.toInt32(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - - Server__setAppFlags(globalObject, JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), useStrictMethodValidation.toBoolean(globalObject), static_cast(lenientBits & 0x3), httpAllowHalfOpen.toBoolean(globalObject)); - RETURN_IF_EXCEPTION(scope, {}); - - return JSValue::encode(jsUndefined()); -} - -// Also called on a listening server. 0 means the option is not set. -JSC_DEFINE_HOST_FUNCTION(jsHTTPSetMaxHeadersCount, (JSGlobalObject * globalObject, CallFrame* callFrame)) -{ - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - ASSERT(callFrame->argumentCount() == 2); - // This is an internal binding. - JSValue serverValue = callFrame->uncheckedArgument(0); - uint32_t maxHeadersCount = callFrame->uncheckedArgument(1).toUInt32(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - - Server__setMaxHeadersCount(globalObject, JSValue::encode(serverValue), maxHeadersCount); - RETURN_IF_EXCEPTION(scope, {}); - - return JSValue::encode(jsUndefined()); -} - JSValue createNodeHTTPInternalBinding(Zig::GlobalObject* globalObject) { auto* obj = constructEmptyObject(globalObject); VM& vm = globalObject->vm(); - obj->putDirect( - vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "setServerCustomOptions"_s)), - JSC::JSFunction::create(vm, globalObject, 2, "setServerCustomOptions"_s, jsHTTPSetCustomOptions, ImplementationVisibility::Public), 0); - obj->putDirect( - vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "setServerAppFlags"_s)), - JSC::JSFunction::create(vm, globalObject, 5, "setServerAppFlags"_s, jsHTTPSetAppFlags, ImplementationVisibility::Public), 0); - obj->putDirect( - vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "setServerMaxHeadersCount"_s)), - JSC::JSFunction::create(vm, globalObject, 2, "setServerMaxHeadersCount"_s, jsHTTPSetMaxHeadersCount, ImplementationVisibility::Public), 0); obj->putDirectNativeFunction( vm, globalObject, JSC::PropertyName(JSC::Identifier::fromString(vm, "drainMicrotasks"_s)), 0, Bun::Bindgen::Generated::HwExports::jsDrainMicrotasks, ImplementationVisibility::Public, Intrinsic::NoIntrinsic, 0); @@ -1175,60 +880,20 @@ JSValue createNodeHTTPInternalBinding(Zig::GlobalObject* globalObject) return obj; } -/* Http2Response and Http3Response share this surface: headers are buffered - * as a list and framed by the transport, so there is no Transfer-Encoding. */ -template -static void writeFetchHeadersToStreamResponse(WebCore::FetchHeaders& headers, Response* res) -{ - auto& internalHeaders = headers.internalHeaders(); - auto* data = res->getHttpResponseData(); - - auto writeOne = [&](const WTF::StringView& name, const WTF::StringView& value) { - ASSERT(name.containsOnlyASCII()); - HTTPLatin1String nameBytes(name); - HTTPLatin1String valueBytes(value); - res->writeHeader(nameBytes.view(), valueBytes.view()); - }; - - for (auto& value : internalHeaders.getSetCookieHeaders()) { - HTTPLatin1String valueBytes(value); - res->writeHeader(std::string_view("set-cookie", 10), valueBytes.view()); - } - - for (const auto& header : internalHeaders.commonHeaders()) { - if (header.key == WebCore::HTTPHeaderName::ContentLength) { - if (!(data->state & ResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER)) { - data->state |= ResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER; - res->writeMark(); - } - } - if (header.key == WebCore::HTTPHeaderName::Date) { - data->state |= ResponseData::HTTP_WROTE_DATE_HEADER; - } - // No Transfer-Encoding on these transports; if a user header reaches - // here it was already stripped by doWriteHeaders(). - writeOne(WebCore::httpHeaderNameString(header.key), header.value); - } - - for (auto& header : internalHeaders.uncommonHeaders()) { - writeOne(header.key, header.value); - } -} - extern "C" void WebCore__FetchHeaders__toUWSResponse(WebCore::FetchHeaders* arg0, UWSResponseKind kind, void* arg2) { switch (kind) { case UWSResponseKind::TCP: - writeFetchHeadersToUWSResponse(*arg0, reinterpret_cast*>(arg2)); + writeFetchHeadersToUWSResponse(*arg0, reinterpret_cast*>(arg2)); break; case UWSResponseKind::SSL: - writeFetchHeadersToUWSResponse(*arg0, reinterpret_cast*>(arg2)); + writeFetchHeadersToUWSResponse(*arg0, reinterpret_cast*>(arg2)); break; case UWSResponseKind::H2: - writeFetchHeadersToStreamResponse(*arg0, reinterpret_cast(arg2)); + writeFetchHeadersToUWSResponse(*arg0, reinterpret_cast(arg2)); break; case UWSResponseKind::H3: - writeFetchHeadersToStreamResponse(*arg0, reinterpret_cast(arg2)); + writeFetchHeadersToUWSResponse(*arg0, reinterpret_cast(arg2)); break; } } @@ -1236,9 +901,9 @@ extern "C" void WebCore__FetchHeaders__toUWSResponse(WebCore::FetchHeaders* arg0 extern "C" void WebCore__FetchHeaders__toUWSResponseForWebSocketUpgrade(WebCore::FetchHeaders* arg0, bool isSSL, void* arg1) { if (isSSL) { - writeFetchHeadersToUWSResponse(*arg0, reinterpret_cast*>(arg1)); + writeFetchHeadersToUWSResponse(*arg0, reinterpret_cast*>(arg1)); } else { - writeFetchHeadersToUWSResponse(*arg0, reinterpret_cast*>(arg1)); + writeFetchHeadersToUWSResponse(*arg0, reinterpret_cast*>(arg1)); } } diff --git a/src/jsc/bindings/ServerRouteList.cpp b/src/jsc/bindings/ServerRouteList.cpp index d9ac812e9f1e..0378ba66cf3f 100644 --- a/src/jsc/bindings/ServerRouteList.cpp +++ b/src/jsc/bindings/ServerRouteList.cpp @@ -134,7 +134,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/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index a23f029f38b6..1a6dba777527 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -627,10 +627,6 @@ class GlobalObject : public Bun::GlobalScope { BunPlugin::OnLoad onLoadPlugins {}; BunPlugin::OnResolve onResolvePlugins {}; - // This increases the cache hit rate for JSC::VM's SourceProvider cache - // It also avoids an extra allocation for the SourceProvider - // The key is a pointer to the source code - WTF::UncheckedKeyHashMap> sourceProviderMap; size_t reloadCount = 0; void reload(); @@ -683,8 +679,6 @@ class GlobalObject : public Bun::GlobalScope { bool hasOverriddenModuleResolveFilenameFunction = false; // De-optimization once `require("module").wrapper` or `require("module").wrap` is written to bool hasOverriddenModuleWrapper = false; - // De-optimization once `require("module").runMain` is written to - bool hasOverriddenModuleRunMain = false; // node:crypto deprecation warnings are emitted at most once per realm, like Node, whose // flags live in per-realm module state (lib/internal/crypto/keys.js). They must not be @@ -831,6 +825,5 @@ extern "C" JSC::EncodedJSValue ZigGlobalObject__readableStreamToText(Zig::Global extern "C" JSC::EncodedJSValue ZigGlobalObject__readableStreamToArrayBuffer(Zig::GlobalObject* globalObject, JSC::EncodedJSValue readableStreamValue); extern "C" JSC::EncodedJSValue ZigGlobalObject__readableStreamToBytes(Zig::GlobalObject* globalObject, JSC::EncodedJSValue readableStreamValue); extern "C" JSC::EncodedJSValue ZigGlobalObject__readableStreamToJSON(Zig::GlobalObject* globalObject, JSC::EncodedJSValue readableStreamValue); -extern "C" JSC::EncodedJSValue ZigGlobalObject__readableStreamToBlob(Zig::GlobalObject* globalObject, JSC::EncodedJSValue readableStreamValue); #endif diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 31ba33ed07ef..7c16010688a6 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -14,6 +14,7 @@ #include "JavaScriptCore/Exception.h" #include "ErrorCode+List.h" #include "ErrorCode.h" +#include "StringSizeLimit.h" #include "JavaScriptCore/ThrowScope.h" #include "JavaScriptCore/JSCast.h" @@ -231,16 +232,8 @@ using namespace WebCore; typedef uint8_t ExpectFlags; // Note: keep this in sync with Flags in src/runtime/test_runner/expect.rs -// clang disable unused warning -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wunused-variable" - -static constexpr int FLAG_PROMISE_RESOLVES = (1 << 0); -static constexpr int FLAG_PROMISE_REJECTS = (1 << 1); static constexpr int FLAG_NOT = (1 << 2); -#pragma clang diagnostic pop - extern "C" bool ExpectCustomAsymmetricMatcher__execute(void* self, JSC::EncodedJSValue thisValue, JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue leftValue); enum class AsymmetricMatcherResult : uint8_t { @@ -2486,9 +2479,9 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark return std::nullopt; } -// The other combinations are instantiated by their uses in this file. This one is -// only reached from `Bun.deepEquals(a, b, true, true)` in BunObject.cpp. +// Only `Bun.deepEquals` in BunObject.cpp uses these two. The others are instantiated by their uses in this file. template bool Bun__deepEquals(JSC::JSGlobalObject*, JSValue, JSValue, MarkedArgumentBuffer&, Vector, 16>&, ThrowScope&, bool); +template bool Bun__deepEquals(JSC::JSGlobalObject*, JSValue, JSValue, MarkedArgumentBuffer&, Vector, 16>&, ThrowScope&, bool); /** * @brief `Bun.deepMatch(a, b)` @@ -2754,7 +2747,7 @@ bool WebCore__FetchHeaders__isEmpty(WebCore::FetchHeaders* arg0) WebCore::FetchHeaders* WebCore__FetchHeaders__createEmpty() { - auto* headers = new WebCore::FetchHeaders({ WebCore::FetchHeaders::Guard::None, {} }); + auto* headers = new WebCore::FetchHeaders(); return headers; } WebCore::FetchHeaders* WebCore__FetchHeaders__cast_(JSC::EncodedJSValue JSValue0, JSC::VM* vm) @@ -2794,7 +2787,7 @@ static std::optional tryCreateFetchHeadersFromCompiledRe return nullptr; auto scope = DECLARE_THROW_SCOPE(vm); - auto* headers = new WebCore::FetchHeaders({ WebCore::FetchHeaders::Guard::None, {} }); + auto* headers = new WebCore::FetchHeaders(); for (auto& [name, string] : record) { auto text = string->value(globalObject); if (!scope.exception()) [[likely]] @@ -2814,6 +2807,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; + } + if (auto headers = tryCreateFetchHeadersFromCompiledRecord(lexicalGlobalObject, argument0.value())) RELEASE_AND_RETURN(throwScope, *headers); @@ -2846,7 +2844,7 @@ WebCore::FetchHeaders* WebCore__FetchHeaders__createFromJS(JSC::JSGlobalObject* } } - auto* headers = new WebCore::FetchHeaders({ WebCore::FetchHeaders::Guard::None, {} }); + auto* headers = new WebCore::FetchHeaders(); // `fill` doesn't set an exception on the VM if it fails, it returns an // ExceptionOr. So we need to check for the exception and, if set, @@ -2883,7 +2881,7 @@ JSC::EncodedJSValue WebCore__FetchHeaders__toJS(WebCore::FetchHeaders* headers, WebCore::FetchHeaders* WebCore__FetchHeaders__cloneThis(WebCore::FetchHeaders* headers, JSC::JSGlobalObject* lexicalGlobalObject) { auto throwScope = DECLARE_THROW_SCOPE(lexicalGlobalObject->vm()); - auto* clone = new WebCore::FetchHeaders({ WebCore::FetchHeaders::Guard::None, {} }); + auto* clone = new WebCore::FetchHeaders(); WebCore::propagateException(*lexicalGlobalObject, throwScope, clone->fill(*headers)); return clone; } @@ -2992,7 +2990,7 @@ typedef struct PicoHTTPHeaders { WebCore::FetchHeaders* WebCore__FetchHeaders__createFromPicoHeaders_(const void* arg1) { PicoHTTPHeaders pico_headers = *reinterpret_cast(arg1); - auto* headers = new WebCore::FetchHeaders({ WebCore::FetchHeaders::Guard::None, {} }); + auto* headers = new WebCore::FetchHeaders(); if (pico_headers.len > 0) { HTTPHeaderMap map = HTTPHeaderMap(); @@ -3038,7 +3036,7 @@ WebCore::FetchHeaders* WebCore__FetchHeaders__createFromUWS(void* arg1) { uWS::HttpRequest& req = *reinterpret_cast(arg1); - auto* headers = new WebCore::FetchHeaders({ WebCore::FetchHeaders::Guard::None, {} }); + auto* headers = new WebCore::FetchHeaders(); HTTPHeaderMap map = HTTPHeaderMap(); @@ -3064,7 +3062,7 @@ WebCore::FetchHeaders* WebCore__FetchHeaders__createFromH3(void* arg1) { auto* req = reinterpret_cast(arg1); - auto* headers = new WebCore::FetchHeaders({ WebCore::FetchHeaders::Guard::None, {} }); + auto* headers = new WebCore::FetchHeaders(); HTTPHeaderMap map = HTTPHeaderMap(); req->forEachHeader([&](std::string_view name, std::string_view val) { @@ -3103,7 +3101,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) { @@ -3238,6 +3240,9 @@ static JSC::EncodedJSValue systemErrorToErrorInstance(const SystemError* arg0, J WTF::String message = WTF::emptyString(); if (err.message.tag != BunStringTag::Empty) { message = err.message.toWTFString(); + // As in BunString__toErrorInstance. + if (message.isNull() || Bun::exceedsStringLimit(message.length())) [[unlikely]] + message = "The error message exceeds the maximum string length"_s; } auto& names = WebCore::builtinNames(vm); @@ -3477,19 +3482,6 @@ JSC::JSPromise* JSC__JSValue__asPromise(JSC::EncodedJSValue JSValue0) return dynamicDowncast(value); } -JSC::EncodedJSValue JSC__JSValue__createInternalPromise(JSC::JSGlobalObject* globalObject) -{ - auto& vm = JSC::getVM(globalObject); - return JSC::JSValue::encode(JSC::JSPromise::create(vm, globalObject->promiseStructure())); -} - -void JSC__JSFunction__optimizeSoon(JSC::EncodedJSValue JSValue0) -{ - JSC::JSValue value = JSC::JSValue::decode(JSValue0); - - JSC::optimizeNextInvocation(value); -} - bool JSC__JSFunction__getSourceCode(JSC::EncodedJSValue JSValue0, BunString* outSourceCode) { JSC::JSValue value = JSC::JSValue::decode(JSValue0); @@ -3538,14 +3530,6 @@ CPP_DECL JSC::JSString* JSC__jsTypeStringForValue(JSC::JSGlobalObject* globalObj return jsTypeStringForValue(globalObject, jsValue); } -JSC::EncodedJSValue JSC__JSPromise__asValue(JSC::JSPromise* arg0, JSC::JSGlobalObject* arg1) -{ - JSValue value = arg0; - ASSERT_WITH_MESSAGE(!value.isEmpty(), "JSPromise.asValue() called on a empty JSValue"); - ASSERT_WITH_MESSAGE(value.inherits(), "JSPromise::asValue() called on a non-promise object"); - return JSC::JSValue::encode(value); -} - JSC::JSPromise* JSC__JSPromise__create(JSC::JSGlobalObject* arg0) { return JSC::JSPromise::create(arg0->vm(), arg0->promiseStructure()); @@ -3577,16 +3561,30 @@ void JSC__VM__collectAsync(JSC::VM* vm, bool full) vm->heap.collectAsync(); } +static void collectAsyncWhileParked(JSC::VM*, JSC::GCRequest); + // The full collection GarbageCollectionController requests because the heap has gone quiet: tagged so JSC may let idle // optimized code age out in it (GCRequest::isIdle), which it never does in a collection the program forces or allocation paces. void JSC__VM__collectAsyncIdle(JSC::VM* vm) +{ + JSC::GCRequest request(JSC::CollectionScope::Full); + request.isIdle = true; + collectAsyncWhileParked(vm, request); +} + +// For a finalizer that something outside the heap waits for. Full: the object may have survived an eden collection. One at a time is enough. +void JSC__VM__collectAsyncForFinalizers(JSC::VM* vm) +{ + if (!WebCore::clientData(*vm)->idleCollectionsPending.load()) + collectAsyncWhileParked(vm, JSC::CollectionScope::Full); +} + +static void collectAsyncWhileParked(JSC::VM* vm, JSC::GCRequest request) { JSC::JSLockHolder lock(*vm); if (!JSC::Options::useGC()) return; auto* clientData = WebCore::clientData(*vm); - JSC::GCRequest request(JSC::CollectionScope::Full); - request.isIdle = true; // See Bun__JSC_onBeforeWait. The end phase may run on the collector thread while the JS thread is parked, and the // epilogue that sweeps what the collection freed runs when the JS thread takes heap access back: wake it for that. A // request with this hook is never coalesced into another, so the count always comes back down. @@ -3636,11 +3634,6 @@ bool JSC__JSValue__isSameValue(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue return JSC::sameValue(globalObject, left, right); } -bool JSC__JSValue__deepEquals(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* globalObject) -{ - return deepEqualsWrapperImpl(JSValue0, JSValue1, globalObject); -} - bool JSC__JSValue__jestDeepEquals(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* globalObject) { return deepEqualsWrapperImpl(JSValue0, JSValue1, globalObject); @@ -3854,12 +3847,6 @@ extern "C" JSC::EncodedJSValue Bun__JSValue__call(JSC::JSGlobalObject* globalObj return JSC::JSValue::encode(result); } -// CPP_DECL size_t JSC__PropertyNameArray__length(JSC__PropertyNameArray* arg0); -// CPP_DECL const JSC__PropertyName* -// JSC__PropertyNameArray__next(JSC__PropertyNameArray* arg0, size_t arg1); -// CPP_DECL void JSC__PropertyNameArray__release(JSC__PropertyNameArray* arg0); -size_t JSC__JSObject__getArrayLength(JSC::JSObject* arg0) { return arg0->getArrayLength(); } - JSC::EncodedJSValue JSC__JSObject__getIndex(JSC::EncodedJSValue jsValue, JSC::JSGlobalObject* globalObject, uint32_t index) { @@ -3881,17 +3868,8 @@ JSC::EncodedJSValue JSC__JSValue__getDirectIndex(JSC::EncodedJSValue jsValue, JS #pragma mark - JSC::JSCell -JSC::JSObject* JSC__JSCell__getObject(JSC::JSCell* arg0) -{ - return arg0->getObject(); -} unsigned char JSC__JSCell__getType(JSC::JSCell* arg0) { return arg0->type(); } -JSC::JSObject* JSC__JSCell__toObject(JSC::JSCell* cell, JSC::JSGlobalObject* globalObject) -{ - return cell->toObject(globalObject); -} - #pragma mark - JSC::JSString // Throws (and returns empty) when resolving a rope runs out of memory. @@ -3995,11 +3973,6 @@ extern "C" JSC::EncodedJSValue JSC__JSString__tryCreateUninitializedLatin1(JSC:: return JSC::JSValue::encode(JSC::jsNontrivialString(JSC::getVM(globalObject), WTF::String(text.releaseNonNull()))); } -JSC::JSObject* JSC__JSString__toObject(JSC::JSString* arg0, JSC::JSGlobalObject* arg1) -{ - return arg0->toObject(arg1); -} - #pragma mark - JSC::JSModuleLoader // JSC::EncodedJSValue @@ -4566,12 +4539,6 @@ JSC::JSPromise* JSC__JSPromise__rejectedPromise(JSC::JSGlobalObject* globalObjec arg0->resolve(arg1, arg1->vm(), JSC::JSValue::decode(JSValue2)); } -// This implementation closely mimics the one in JSC::JSPromise::resolve -void JSC__JSPromise__resolveOnNextTick(JSC::JSPromise* promise, JSC::JSGlobalObject* lexicalGlobalObject, JSC::EncodedJSValue encoedValue) -{ - return JSC__JSPromise__resolve(promise, lexicalGlobalObject, encoedValue); -} - bool JSC__JSValue__isAnyError(JSC::EncodedJSValue JSValue0) { JSC::JSValue value = JSC::JSValue::decode(JSValue0); @@ -4586,46 +4553,6 @@ bool JSC__JSValue__isAnyError(JSC::EncodedJSValue JSValue0) return type == JSC::ErrorInstanceType; } -// This implementation closely mimics the one in JSC::JSPromise::reject -void JSC__JSPromise__rejectOnNextTickWithHandled(JSC::JSPromise* promise, JSC::JSGlobalObject* lexicalGlobalObject, - JSC::EncodedJSValue encoedValue, bool handled) -{ - JSC::JSValue value = JSC::JSValue::decode(encoedValue); - - auto& vm = JSC::getVM(lexicalGlobalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - uint16_t flags = promise->flags(); - if (!(flags & JSC::JSPromise::isFirstResolvingFunctionCalledFlag)) { - if (handled) { - flags |= JSC::JSPromise::isHandledFlag; - } - - promise->setFlags(static_cast(flags | JSC::JSPromise::isFirstResolvingFunctionCalledFlag)); - auto* globalObject = uncheckedDowncast(promise->globalObject()); - auto rejectPromiseFunction = globalObject->rejectPromiseFunction(); - - auto asyncContext = globalObject->m_asyncContextData.get()->getInternalField(0); - -#if ASSERT_ENABLED - ASSERT_WITH_MESSAGE(rejectPromiseFunction, "Invalid microtask callback"); - ASSERT_WITH_MESSAGE(!value.isEmpty(), "Invalid microtask value"); -#endif - - if (asyncContext.isEmpty()) { - asyncContext = jsUndefined(); - } - - if (value.isEmpty()) { - value = jsUndefined(); - } - - // BunPerformMicrotaskJob: rejectPromiseFunction, asyncContext, promise, value - JSC::QueuedTask task { nullptr, JSC::InternalMicrotask::BunPerformMicrotaskJob, 0, globalObject, rejectPromiseFunction, globalObject->m_asyncContextData.get()->getInternalField(0), promise, value }; - globalObject->vm().queueMicrotask(WTF::move(task)); - RETURN_IF_EXCEPTION(scope, ); - } -} - JSC::JSPromise* JSC__JSPromise__resolvedPromise(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue JSValue1) { auto& vm = JSC::getVM(globalObject); @@ -4675,65 +4602,12 @@ JSC::JSPromise* JSC__JSPromise__resolvedPromise(JSC::JSGlobalObject* globalObjec #pragma mark - JSC::JSInternalPromise (now aliased to JSPromise) -JSC::JSPromise* JSC__JSInternalPromise__create(JSC::JSGlobalObject* globalObject) -{ - auto& vm = JSC::getVM(globalObject); - return JSC::JSPromise::create(vm, globalObject->promiseStructure()); -} - -void JSC__JSInternalPromise__rejectAsHandled(JSC::JSPromise* arg0, - JSC::JSGlobalObject* arg1, JSC::EncodedJSValue JSValue2) -{ - auto& vm = JSC::getVM(arg1); - arg0->rejectAsHandled(vm, JSC::JSValue::decode(JSValue2)); -} -void JSC__JSInternalPromise__rejectAsHandledException(JSC::JSPromise* arg0, - JSC::JSGlobalObject* arg1, - JSC::Exception* arg2) -{ - auto& vm = JSC::getVM(arg1); - arg0->rejectAsHandled(vm, arg2); -} - -JSC::JSPromise* JSC__JSInternalPromise__rejectedPromise(JSC::JSGlobalObject* arg0, - JSC::EncodedJSValue JSValue1) -{ - return JSC::JSPromise::rejectedPromise(arg0, JSC::JSValue::decode(JSValue1)); -} - JSC::JSPromise* JSC__JSInternalPromise__resolvedPromise(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1) { return JSC::JSPromise::resolvedPromise(arg0, JSC::JSValue::decode(JSValue1)); } -JSC::EncodedJSValue JSC__JSInternalPromise__result(const JSC::JSPromise* arg0) -{ - return JSC::JSValue::encode(arg0->result()); -} -uint32_t JSC__JSInternalPromise__status(const JSC::JSPromise* arg0) -{ - switch (arg0->status()) { - case JSC::JSPromise::Status::Pending: - return 0; - case JSC::JSPromise::Status::Fulfilled: - return 1; - case JSC::JSPromise::Status::Rejected: - return 2; - default: - return 255; - } -} -bool JSC__JSInternalPromise__isHandled(const JSC::JSPromise* arg0) -{ - return arg0->isHandled(); -} -void JSC__JSInternalPromise__setHandled(JSC::JSPromise* promise, JSC::VM* arg1) -{ - UNUSED_PARAM(arg1); - promise->markAsHandled(); -} - #pragma mark - JSC::JSGlobalObject JSC::EncodedJSValue JSC__JSGlobalObject__generateHeapSnapshot(JSC::JSGlobalObject* globalObject) @@ -4791,10 +4665,6 @@ JSC::JSString* JSC__JSValue__asString(JSC::EncodedJSValue JSValue0) return JSC::asString(value); }; -bool JSC__JSValue__eqlCell(JSC::EncodedJSValue JSValue0, JSC::JSCell* arg1) -{ - return JSC::JSValue::decode(JSValue0) == arg1; -}; bool JSC__JSValue__eqlValue(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1) { return JSC::JSValue::decode(JSValue0) == JSC::JSValue::decode(JSValue1); @@ -4921,11 +4791,6 @@ bool JSC__JSValue__isClass(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* ar } return false; } -bool JSC__JSValue__isError(JSC::EncodedJSValue JSValue0) -{ - JSC::JSObject* obj = JSC::JSValue::decode(JSValue0).getObject(); - return obj != nullptr && obj->isErrorInstance(); -} bool JSC__JSValue__isAggregateError(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* global) { @@ -4984,18 +4849,6 @@ __attribute__((__always_inline__)) JSC::EncodedJSValue JSC__JSValue__jsNumberFro { return JSC::JSValue::encode(JSC::jsNumber(arg0)); } -JSC::EncodedJSValue JSC__JSValue__jsNumberFromInt32(int32_t arg0) -{ - return JSC::JSValue::encode(JSC::jsNumber(arg0)); -} -JSC::EncodedJSValue JSC__JSValue__jsNumberFromInt64(int64_t arg0) -{ - return JSC::JSValue::encode(JSC::jsNumber(arg0)); -} -JSC::EncodedJSValue JSC__JSValue__jsNumberFromUint64(uint64_t arg0) -{ - return JSC::JSValue::encode(JSC::jsNumber(arg0)); -} [[ZIG_EXPORT(nothrow)]] int64_t JSC__JSValue__toInt64(JSC::EncodedJSValue val) { @@ -5105,7 +4958,7 @@ JSC::EncodedJSValue JSC__JSValue__createObject2(JSC::JSGlobalObject* globalObjec // Returns empty for exception, returns deleted if not found. // Be careful when handling the return value. -// Cannot handle numeric index property names! If it is possible that this will be a integer index, use JSC__JSValue__getPropertyValue instead +// Cannot handle numeric index property names! [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue JSC__JSValue__getIfPropertyExistsImpl(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject, const unsigned char* arg1, size_t arg2) @@ -5131,43 +4984,6 @@ JSC::EncodedJSValue JSC__JSValue__createObject2(JSC::JSGlobalObject* globalObjec return JSC::JSValue::encode(Bun::getIfPropertyExistsPrototypePollutionMitigationUnsafe(vm, globalObject, object, property)); } -// Returns empty for exception, returns deleted if not found. -// Be careful when handling the return value. -// Can handle numeric index property names safely. If you know that the property name is not an integer index, use JSC__JSValue__getIfPropertyExistsImpl instead. -JSC::EncodedJSValue JSC__JSValue__getPropertyValue(JSC::EncodedJSValue encodedValue, - JSC::JSGlobalObject* globalObject, - const unsigned char* propertyName, uint32_t propertyNameLength) -{ - - ASSERT_NO_PENDING_EXCEPTION(globalObject); - JSValue value = JSC::JSValue::decode(encodedValue); - ASSERT_WITH_MESSAGE(!value.isEmpty(), "getPropertyValue() must not be called on empty value"); - - auto& vm = JSC::getVM(globalObject); - JSC::JSObject* object = value.getObject(); - if (!object) [[unlikely]] { - return JSValue::encode(JSValue::decode(JSC::JSValue::ValueDeleted)); - } - - // Since Identifier might not ref the string, we need to ensure it doesn't get deref'd until this function returns - const auto propertyString = String(StringImpl::createWithoutCopying({ byteCast(propertyName), propertyNameLength })); - const auto identifier = JSC::Identifier::fromString(vm, propertyString); - const auto property = JSC::PropertyName(identifier); - - auto scope = DECLARE_THROW_SCOPE(vm); - PropertySlot slot(object, PropertySlot::InternalMethodType::Get); - if (!object->getPropertySlot(globalObject, property, slot)) { - RETURN_IF_EXCEPTION(scope, {}); - return JSValue::encode(JSValue::decode(JSC::JSValue::ValueDeleted)); - } - RETURN_IF_EXCEPTION(scope, {}); - - JSValue result = slot.getValue(globalObject, property); - RETURN_IF_EXCEPTION(scope, {}); - - return JSValue::encode(result); -} - extern "C" JSC::EncodedJSValue JSC__JSValue__getOwn(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject, BunString* propertyName) { ASSERT_NO_PENDING_EXCEPTION(globalObject); @@ -5705,16 +5521,6 @@ void JSC__JSString__iterator(JSC::JSString* arg0, JSC::JSGlobalObject* arg1, voi arg0->value(iter); } -void JSC__VM__deleteAllCode(JSC::VM* arg1, JSC::JSGlobalObject* globalObject) -{ - JSC::JSLockHolder locker(globalObject->vm()); - - arg1->drainMicrotasks(); - globalObject->moduleLoader()->clearAll(); // takes the loader's cellLock itself - arg1->deleteAllCode(JSC::DeleteAllCodeEffort::PreventCollectionAndDeleteAllCode); - arg1->heap.reportAbandonedObjectGraph(); -} - void JSC__VM__reportExtraMemory(JSC::VM* arg0, size_t arg1) { arg0->heap.deprecatedReportExtraMemory(arg1); @@ -5782,14 +5588,6 @@ void JSC__VM__notifyNeedTermination(JSC::VM* arg0) { arg0->notifyNeedTermination(); } -void JSC__VM__notifyNeedDebuggerBreak(JSC::VM* arg0) -{ - (*arg0).notifyNeedDebuggerBreak(); -} -void JSC__VM__notifyNeedShellTimeoutCheck(JSC::VM* arg0) -{ - (*arg0).notifyNeedShellTimeoutCheck(); -} NEVER_INLINE void JSC__VM__throwError(JSC::VM* vm_, JSC::JSGlobalObject* arg1, JSC::EncodedJSValue encodedValue) { @@ -5865,6 +5663,7 @@ enum class BuiltinNamesMap : uint8_t { // Private names below: set by builtins via $putByIdDirectPrivate, unreachable from user code. internal, sharedFd, + defaultTimeout, }; static inline const JSC::Identifier& builtinNameMap(JSC::VM& vm, unsigned char name) @@ -5944,6 +5743,9 @@ static inline const JSC::Identifier& builtinNameMap(JSC::VM& vm, unsigned char n case BuiltinNamesMap::sharedFd: { return clientData->builtinNames().sharedFdPrivateName(); } + case BuiltinNamesMap::defaultTimeout: { + return clientData->builtinNames().defaultTimeoutPrivateName(); + } default: { ASSERT_NOT_REACHED(); __builtin_unreachable(); @@ -5951,13 +5753,6 @@ static inline const JSC::Identifier& builtinNameMap(JSC::VM& vm, unsigned char n } } -JSC::EncodedJSValue JSC__JSValue__fastGetDirect_(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject, unsigned char arg2) -{ - JSC::JSValue value = JSC::JSValue::decode(JSValue0); - ASSERT(value.isCell()); - return JSValue::encode(value.getObject()->getDirect(globalObject->vm(), PropertyName(builtinNameMap(globalObject->vm(), arg2)))); -} - // Returns empty for exception, returns deleted if not found. // Be careful when handling the return value. JSC::EncodedJSValue JSC__JSValue__fastGet(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject, unsigned char arg2) @@ -5986,25 +5781,6 @@ JSC::EncodedJSValue JSC__JSValue__fastGet(JSC::EncodedJSValue JSValue0, JSC::JSG return JSC::JSValue::encode(Bun::getIfPropertyExistsPrototypePollutionMitigationUnsafe(vm, globalObject, object, property)); } -extern "C" JSC::EncodedJSValue JSC__JSValue__fastGetOwn(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject, unsigned char arg2) -{ - JSC::JSValue value = JSC::JSValue::decode(JSValue0); - ASSERT(value.isCell()); - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - PropertySlot slot = PropertySlot(value, PropertySlot::InternalMethodType::GetOwnProperty); - const Identifier name = builtinNameMap(globalObject->vm(), arg2); - auto* object = value.getObject(); - - bool hasOwnProperty = object->getOwnPropertySlot(object, globalObject, name, slot); - RETURN_IF_EXCEPTION(scope, {}); - if (hasOwnProperty) { - RELEASE_AND_RETURN(scope, JSValue::encode(slot.getValue(globalObject, name))); - } - - return {}; -} - __attribute__((__always_inline__)) bool JSC__JSValue__toBoolean(JSC::EncodedJSValue JSValue0) { // We count masquerades as undefined as true. @@ -6524,17 +6300,6 @@ bool JSC__JSValue__isInstanceOf(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObjec return result; } -extern "C" JSC::EncodedJSValue JSC__JSValue__createRopeString(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* globalObject) -{ - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - JSString* str0 = JSC::JSValue::decode(JSValue0).toString(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - JSString* str1 = JSC::JSValue::decode(JSValue1).toString(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - RELEASE_AND_RETURN(scope, JSValue::encode(JSC::jsString(globalObject, str0, str1))); -} - extern "C" size_t JSC__VM__blockBytesAllocated(JSC::VM* vm) { #if ENABLE(RESOURCE_USAGE) @@ -6543,14 +6308,6 @@ extern "C" size_t JSC__VM__blockBytesAllocated(JSC::VM* vm) return 0; #endif } -extern "C" size_t JSC__VM__externalMemorySize(JSC::VM* vm) -{ -#if ENABLE(RESOURCE_USAGE) - return vm->heap.externalMemorySize(); -#else - return 0; -#endif -} extern "C" void JSC__JSGlobalObject__queueMicrotaskJob(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1, JSC::EncodedJSValue JSValue3, JSC::EncodedJSValue JSValue4) { @@ -6969,14 +6726,6 @@ extern "C" EncodedJSValue JSC__JSValue__dateInstanceFromNumber(JSC::JSGlobalObje return JSValue::encode(date); } -extern "C" EncodedJSValue JSC__JSValue__dateInstanceFromNullTerminatedString(JSC::JSGlobalObject* globalObject, const Latin1Character* nullTerminatedChars) -{ - double dateSeconds = WTF::parseDate(std::span(nullTerminatedChars, strlen(reinterpret_cast(nullTerminatedChars)))); - JSC::DateInstance* date = JSC::DateInstance::create(globalObject->vm(), globalObject->dateStructure(), dateSeconds); - - return JSValue::encode(date); -} - // Formats a Date's internal time value with JSC's date cache, as // `Date.prototype.toISOString` does (`Bun::toISOString` is copied from it). // Returns -1 when `dateValue` is not a Date or its time value is NaN. @@ -6994,40 +6743,6 @@ extern "C" int JSC__JSValue__toISOString(EncodedJSValue dateValue, JSC::JSGlobal return static_cast(Bun::toISOString(vm, thisDateObj->internalNumber(), buf)); } -extern "C" int JSC__JSValue__DateNowISOString(JSC::JSGlobalObject* globalObject, char* buf) -{ - char buffer[29]; - JSC::DateInstance* thisDateObj = JSC::DateInstance::create(globalObject->vm(), globalObject->dateStructure(), globalObject->jsDateNow()); - - if (!std::isfinite(thisDateObj->internalNumber())) - return -1; - - auto& vm = JSC::getVM(globalObject); - - auto gregorianDateTime = thisDateObj->gregorianDateTimeUTC(vm.dateCache); - if (!gregorianDateTime) - return -1; - - // If the year is outside the bounds of 0 and 9999 inclusive we want to use the extended year format (ES 15.9.1.15.1). - int ms = static_cast(fmod(thisDateObj->internalNumber(), msPerSecond)); - if (ms < 0) - ms += msPerSecond; - - int charactersWritten; - if (gregorianDateTime.year() > 9999 || gregorianDateTime.year() < 0) - charactersWritten = snprintf(buffer, sizeof(buffer), "%+07d-%02d-%02dT%02d:%02d:%02d.%03dZ", gregorianDateTime.year(), gregorianDateTime.month() + 1, gregorianDateTime.monthDay(), gregorianDateTime.hour(), gregorianDateTime.minute(), gregorianDateTime.second(), ms); - else - charactersWritten = snprintf(buffer, sizeof(buffer), "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ", gregorianDateTime.year(), gregorianDateTime.month() + 1, gregorianDateTime.monthDay(), gregorianDateTime.hour(), gregorianDateTime.minute(), gregorianDateTime.second(), ms); - - memcpy(buf, buffer, charactersWritten); - - ASSERT(charactersWritten > 0 && static_cast(charactersWritten) < sizeof(buffer)); - if (static_cast(charactersWritten) >= sizeof(buffer)) - return -1; - - return charactersWritten; -} - #pragma mark - WebCore::DOMFormData // Both append functions throw and return false on failure. @@ -7058,16 +6773,6 @@ CPP_DECL size_t WebCore__DOMFormData__count(WebCore::DOMFormData* arg0) return arg0->count(); } -extern "C" void DOMFormData__toQueryString( - DOMFormData* formData, - void* ctx, - void (*callback)(void* ctx, EncodedSlice* encoded)) -{ - auto str = formData->toURLEncodedString(); - EncodedSlice encoded = toEncodedSlice(str); - callback(ctx, &encoded); -} - CPP_DECL JSC::EncodedJSValue WebCore__DOMFormData__createFromURLQuery(JSC::JSGlobalObject* arg0, const EncodedSlice* arg1) { Zig::GlobalObject* globalObject = static_cast(arg0); @@ -7302,15 +7007,6 @@ CPP_DECL [[ZIG_EXPORT(nothrow)]] void JSC__SourceProvider__deref(JSC::SourceProv provider->deref(); } -CPP_DECL bool Bun__CallFrame__isFromBunMain(JSC::CallFrame* callFrame, JSC::VM* vm) -{ - auto source = callFrame->callerSourceOrigin(*vm); - - if (source.isNull()) - return false; - return source.string() == "builtin://bun/main"_s; -} - CPP_DECL void Bun__CallFrame__getCallerSrcLoc(JSC::CallFrame* callFrame, JSC::JSGlobalObject* globalObject, BunString* outSourceURL, unsigned int* outLine, unsigned int* outColumn) { auto& vm = JSC::getVM(globalObject); @@ -7700,47 +7396,6 @@ extern "C" JSC::EncodedJSValue Bun__REPL__getProperty( return JSC::JSValue::encode(result ? result : JSC::jsUndefined()); } -// Format a value for REPL output using util.inspect style -extern "C" JSC::EncodedJSValue Bun__REPL__formatValue( - JSC::JSGlobalObject* globalObject, - JSC::EncodedJSValue valueEncoded, - int32_t depth, - bool colors) -{ - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - - // Get the util.inspect function from the global object - auto* bunGlobal = uncheckedDowncast(globalObject); - JSC::JSValue inspectFn = bunGlobal->utilInspectFunction(); - RETURN_IF_EXCEPTION(scope, JSC::JSValue::encode(JSC::jsUndefined())); - - if (!inspectFn || !inspectFn.isCallable()) { - // Fallback to toString if util.inspect is not available - JSC::JSValue value = JSC::JSValue::decode(valueEncoded); - JSString* str = value.toString(globalObject); - RETURN_IF_EXCEPTION(scope, JSC::JSValue::encode(JSC::jsUndefined())); - return JSC::JSValue::encode(str); - } - - // Create options object - JSC::JSObject* options = JSC::constructEmptyObject(globalObject); - Bun::putDirectNamed(vm, options, "depth"_s, JSC::jsNumber(depth)); - Bun::putDirectNamed(vm, options, "colors"_s, JSC::jsBoolean(colors)); - Bun::putDirectNamed(vm, options, "maxArrayLength"_s, JSC::jsNumber(100)); - Bun::putDirectNamed(vm, options, "maxStringLength"_s, JSC::jsNumber(10000)); - Bun::putDirectNamed(vm, options, "breakLength"_s, JSC::jsNumber(80)); - - JSC::MarkedArgumentBuffer args; - args.append(JSC::JSValue::decode(valueEncoded)); - args.append(options); - - JSC::JSValue result = JSC::call(globalObject, inspectFn, JSC::ArgList(args), "util.inspect"_s); - RETURN_IF_EXCEPTION(scope, JSC::JSValue::encode(JSC::jsUndefined())); - - return JSC::JSValue::encode(result); -} - // Collects every ArrayBufferView in a JSArray and the (data, byteLength) span // of each. Two passes, mirroring Buffer.concat: the first reads every element // into a MarkedArgumentBuffer, so any user code an indexed read can run diff --git a/src/jsc/bindings/c-bindings.cpp b/src/jsc/bindings/c-bindings.cpp index f9a6ec70194c..dc1c0b7fdedc 100644 --- a/src/jsc/bindings/c-bindings.cpp +++ b/src/jsc/bindings/c-bindings.cpp @@ -108,13 +108,6 @@ extern "C" bool is_executable_file(const char* path) } #endif -extern "C" void bun_ignore_sigpipe() -{ -#if !OS(WINDOWS) - // ignore SIGPIPE - signal(SIGPIPE, SIG_IGN); -#endif -} extern "C" ssize_t bun_sysconf__SC_CLK_TCK() { #ifdef __APPLE__ @@ -770,7 +763,6 @@ typedef struct { const char* value; size_t value_len; bool never_index; - uint16_t hpack_index; } lshpack_header; lshpack_wrapper* lshpack_wrapper_init(lshpack_wrapper_alloc alloc, lshpack_wrapper_free free, unsigned max_capacity) @@ -787,29 +779,39 @@ lshpack_wrapper* lshpack_wrapper_init(lshpack_wrapper_alloc alloc, lshpack_wrapp return coders; } -size_t lshpack_wrapper_encode(lshpack_wrapper* self, - const unsigned char* name, size_t name_len, - const unsigned char* val, size_t val_len, - int never_index, - unsigned char* buffer, size_t buffer_len, size_t buffer_offset) +// 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) { - if (name_len + val_len > LSHPACK_MAX_HEADER_SIZE) + 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; - char* shared_header_buffer = shared_header_buffer_get(); - lsxpack_header_t hdr; - memset(&hdr, 0, sizeof(lsxpack_header_t)); - memcpy(&shared_header_buffer[0], name, name_len); - memcpy(&shared_header_buffer[name_len], val, val_len); - lsxpack_header_set_offset2(&hdr, &shared_header_buffer[0], 0, name_len, name_len, val_len); - if (never_index) { - hdr.indexed_type = 2; + 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; } - auto* start = buffer + buffer_offset; - auto* ptr = lshpack_enc_encode(&self->enc, start, buffer + buffer_len, &hdr); - if (!ptr) - return 0; - return ptr - start; + return cursor - dst; } size_t lshpack_wrapper_decode(lshpack_wrapper* self, @@ -831,11 +833,6 @@ size_t lshpack_wrapper_decode(lshpack_wrapper* self, output->value = lsxpack_header_get_value(&hdr); output->value_len = hdr.val_len; output->never_index = (hdr.flags & LSXPACK_NEVER_INDEX) != 0; - if (hdr.hpack_index != LSHPACK_HDR_UNKNOWN && hdr.hpack_index <= LSHPACK_HDR_WWW_AUTHENTICATE) { - output->hpack_index = hdr.hpack_index - 1; - } else { - output->hpack_index = 255; - } return s - src; } @@ -844,6 +841,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); @@ -1201,26 +1203,6 @@ extern "C" [[ZIG_EXPORT(nothrow)]] size_t Bun__ramSize() return WTF::ramSize(); } -#if !OS(WINDOWS) - -extern "C" void Bun__disableSOLinger(int fd) -{ - struct linger l = { 1, 0 }; - setsockopt(fd, SOL_SOCKET, SO_LINGER, &l, sizeof(l)); -} - -#else - -#include - -extern "C" void Bun__disableSOLinger(SOCKET fd) -{ - struct linger l = { 1, 0 }; - setsockopt(fd, SOL_SOCKET, SO_LINGER, (char*)&l, sizeof(l)); -} - -#endif - extern "C" int ffi_vprintf(const char* fmt, va_list ap) { int ret = vfprintf(stderr, fmt, ap); 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 99211c42105e..681d2f3a99d0 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -15,7 +15,6 @@ #define ZIG_DECL AUTO_EXTERN_C_ZIG #define CPP_DECL AUTO_EXTERN_C -#define CPP_SIZE AUTO_EXTERN_C #include "root.h" #include @@ -44,7 +43,6 @@ class DOMURL; #pragma mark - JSC::JSObject -CPP_DECL size_t JSC__JSObject__getArrayLength(JSC::JSObject* arg0); CPP_DECL JSC::EncodedJSValue JSC__JSObject__getIndex(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1, uint32_t arg2); CPP_DECL JSC::EncodedJSValue EncodedSlice__external(const EncodedSlice* arg0, JSC::JSGlobalObject* arg1, void* arg2, void(* ArgFn3)(void* arg0, void* arg1, size_t arg2)); CPP_DECL JSC::EncodedJSValue EncodedSlice__toExternalValue(const EncodedSlice* arg0, JSC::JSGlobalObject* arg1); @@ -59,11 +57,11 @@ CPP_DECL bool WebCore__DOMFormData__appendBlob(WebCore::DOMFormData* arg0, JSC:: CPP_DECL size_t WebCore__DOMFormData__count(WebCore::DOMFormData* arg0); CPP_DECL JSC::EncodedJSValue WebCore__DOMFormData__create(JSC::JSGlobalObject* arg0); CPP_DECL JSC::EncodedJSValue WebCore__DOMFormData__createFromURLQuery(JSC::JSGlobalObject* arg0, const EncodedSlice* arg1); -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); @@ -86,16 +84,13 @@ CPP_DECL JSC::EncodedJSValue SystemError__toTypeErrorInstance(const SystemError* #pragma mark - JSC::JSCell -CPP_DECL JSC::JSObject* JSC__JSCell__getObject(JSC::JSCell* arg0); CPP_DECL unsigned char JSC__JSCell__getType(JSC::JSCell* arg0); -CPP_DECL JSC::JSObject* JSC__JSCell__toObject(JSC::JSCell* cell, JSC::JSGlobalObject* globalObject); #pragma mark - JSC::JSString CPP_DECL bool JSC__JSString__is8Bit(const JSC::JSString* arg0); CPP_DECL void JSC__JSString__iterator(JSC::JSString* arg0, JSC::JSGlobalObject* arg1, void* arg2); CPP_DECL size_t JSC__JSString__length(const JSC::JSString* arg0); -CPP_DECL JSC::JSObject* JSC__JSString__toObject(JSC::JSString* arg0, JSC::JSGlobalObject* arg1); CPP_DECL BunString JSC__JSString__view(JSC::JSString* arg0, JSC::JSGlobalObject* arg1, const JSC::JSString** owner); #pragma mark - JSC::JSModuleLoader @@ -116,12 +111,10 @@ CPP_DECL void WebCore__AbortSignal__unref(WebCore::AbortSignal* arg0); #pragma mark - JSC::JSPromise -CPP_DECL JSC::EncodedJSValue JSC__JSPromise__asValue(JSC::JSPromise* arg0, JSC::JSGlobalObject* arg1); CPP_DECL JSC::JSPromise* JSC__JSPromise__create(JSC::JSGlobalObject* arg0); CPP_DECL void JSC__JSPromise__reject(JSC::JSPromise* arg0, JSC::JSGlobalObject* arg1, JSC::EncodedJSValue JSValue2); CPP_DECL void JSC__JSPromise__rejectAsHandled(JSC::JSPromise* arg0, JSC::JSGlobalObject* arg1, JSC::EncodedJSValue JSValue2); CPP_DECL JSC::JSPromise* JSC__JSPromise__rejectedPromise(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1); -CPP_DECL void JSC__JSPromise__rejectOnNextTickWithHandled(JSC::JSPromise* arg0, JSC::JSGlobalObject* arg1, JSC::EncodedJSValue JSValue2, bool arg3); CPP_DECL void JSC__JSPromise__resolve(JSC::JSPromise* arg0, JSC::JSGlobalObject* arg1, JSC::EncodedJSValue JSValue2); CPP_DECL JSC::JSPromise* JSC__JSPromise__resolvedPromise(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1); CPP_DECL JSC::EncodedJSValue JSC__JSPromise__resolvedPromiseValue(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1); @@ -131,26 +124,15 @@ CPP_DECL uint32_t JSC__JSPromise__status(const JSC::JSPromise* arg0); #pragma mark - JSC::JSInternalPromise (now aliased to JSPromise) -CPP_DECL JSC::JSPromise* JSC__JSInternalPromise__create(JSC::JSGlobalObject* arg0); -CPP_DECL bool JSC__JSInternalPromise__isHandled(const JSC::JSPromise* arg0); -CPP_DECL void JSC__JSInternalPromise__rejectAsHandled(JSC::JSPromise* arg0, JSC::JSGlobalObject* arg1, JSC::EncodedJSValue JSValue2); -CPP_DECL void JSC__JSInternalPromise__rejectAsHandledException(JSC::JSPromise* arg0, JSC::JSGlobalObject* arg1, JSC::Exception* arg2); -CPP_DECL JSC::JSPromise* JSC__JSInternalPromise__rejectedPromise(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1); CPP_DECL JSC::JSPromise* JSC__JSInternalPromise__resolvedPromise(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1); -CPP_DECL JSC::EncodedJSValue JSC__JSInternalPromise__result(const JSC::JSPromise* arg0); -CPP_DECL void JSC__JSInternalPromise__setHandled(JSC::JSPromise* arg0, JSC::VM* arg1); -CPP_DECL uint32_t JSC__JSInternalPromise__status(const JSC::JSPromise* arg0); #pragma mark - JSC::JSFunction -CPP_DECL void JSC__JSFunction__optimizeSoon(JSC::EncodedJSValue JSValue0); - #pragma mark - REPL Functions CPP_DECL JSC::EncodedJSValue Bun__REPL__evaluate(JSC::JSGlobalObject* globalObject, const unsigned char* sourcePtr, size_t sourceLen, const unsigned char* filenamePtr, size_t filenameLen, JSC::EncodedJSValue* exception); CPP_DECL JSC::EncodedJSValue Bun__REPL__getCompletions(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue targetValue, const unsigned char* prefixPtr, size_t prefixLen); CPP_DECL JSC::EncodedJSValue Bun__REPL__getProperty(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue baseValue, const unsigned char* namePtr, size_t nameLen); -CPP_DECL JSC::EncodedJSValue Bun__REPL__formatValue(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue valueEncoded, int32_t depth, bool colors); #pragma mark - JSC::JSGlobalObject @@ -175,7 +157,6 @@ CPP_DECL uint32_t JSC__JSMap__size(JSC::JSMap* arg0); #pragma mark - JSC::JSValue -CPP_DECL void JSC__JSValue__then(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1, JSC::EncodedJSValue JSValue2, SYSV_ABI JSC::EncodedJSValue(* ArgFn3)(JSC::JSGlobalObject* arg0, JSC::CallFrame* arg1), SYSV_ABI JSC::EncodedJSValue(* ArgFn4)(JSC::JSGlobalObject* arg0, JSC::CallFrame* arg1)); CPP_DECL bool JSC__JSValue__asArrayBuffer(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1, Bun__ArrayBuffer* arg2); CPP_DECL unsigned char JSC__JSValue__asBigIntCompare(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1); CPP_DECL JSC::JSPromise* JSC__JSValue__asInternalPromise(JSC::EncodedJSValue JSValue0); @@ -185,15 +166,10 @@ CPP_DECL int32_t JSC__JSValue__coerceToInt32(JSC::EncodedJSValue JSValue0, JSC:: CPP_DECL int64_t JSC__JSValue__coerceToInt64(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1); CPP_DECL JSC::EncodedJSValue JSC__JSValue__createEmptyArray(JSC::JSGlobalObject* arg0, size_t arg1); CPP_DECL JSC::EncodedJSValue JSC__JSValue__createEmptyObject(JSC::JSGlobalObject* arg0, size_t arg1); -CPP_DECL JSC::EncodedJSValue JSC__JSValue__createInternalPromise(JSC::JSGlobalObject* arg0); CPP_DECL JSC::EncodedJSValue JSC__JSValue__createObject2(JSC::JSGlobalObject* arg0, const EncodedSlice* arg1, const EncodedSlice* arg2, JSC::EncodedJSValue JSValue3, JSC::EncodedJSValue JSValue4); -CPP_DECL JSC::EncodedJSValue JSC__JSValue__createRopeString(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* arg2); CPP_DECL JSC::EncodedJSValue JSC__JSValue__createUninitializedUint8Array(JSC::JSGlobalObject* arg0, size_t arg1); -CPP_DECL bool JSC__JSValue__deepEquals(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* arg2); -CPP_DECL bool JSC__JSValue__eqlCell(JSC::EncodedJSValue JSValue0, JSC::JSCell* arg1); CPP_DECL bool JSC__JSValue__eqlValue(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1); CPP_DECL JSC::EncodedJSValue JSC__JSValue__fastGet(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1, unsigned char arg2); -CPP_DECL JSC::EncodedJSValue JSC__JSValue__fastGetDirect_(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1, unsigned char arg2); CPP_DECL void JSC__JSValue__forEach(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1, void* arg2, void(* ArgFn3)(JSC::VM* arg0, JSC::JSGlobalObject* arg1, void* arg2, JSC::EncodedJSValue JSValue3)); CPP_DECL void JSC__JSValue__forEachProperty(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1, void* arg2, void(* ArgFn3)(JSC::JSGlobalObject* arg0, void* arg1, EncodedSlice* arg2, JSC::EncodedJSValue JSValue3, bool arg4, bool arg5)); CPP_DECL void JSC__JSValue__forEachPropertyOrdered(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1, void* arg2, void(* ArgFn3)(JSC::JSGlobalObject* arg0, void* arg1, EncodedSlice* arg2, JSC::EncodedJSValue JSValue3, bool arg4, bool arg5)); @@ -214,7 +190,6 @@ CPP_DECL bool JSC__JSValue__isBigInt(JSC::EncodedJSValue JSValue0); CPP_DECL bool JSC__JSValue__isCallable(JSC::EncodedJSValue JSValue0); CPP_DECL bool JSC__JSValue__isClass(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1); CPP_DECL bool JSC__JSValue__isConstructor(JSC::EncodedJSValue JSValue0); -CPP_DECL bool JSC__JSValue__isError(JSC::EncodedJSValue JSValue0); CPP_DECL bool JSC__JSValue__isException(JSC::EncodedJSValue JSValue0, JSC::VM* arg1); CPP_DECL bool JSC__JSValue__isHeapBigInt(JSC::EncodedJSValue JSValue0); CPP_DECL bool JSC__JSValue__isInstanceOf(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1, JSC::EncodedJSValue JSValue2); @@ -228,7 +203,6 @@ CPP_DECL bool JSC__JSValue__jestDeepEquals(JSC::EncodedJSValue JSValue0, JSC::En CPP_DECL bool JSC__JSValue__jestDeepMatch(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* arg2, bool arg3, JSC::EncodedJSValue* arg4); CPP_DECL bool JSC__JSValue__jestStrictDeepEquals(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* arg2); CPP_DECL JSC::EncodedJSValue JSC__JSValue__jsNumberFromDouble(double arg0); -CPP_DECL JSC::EncodedJSValue JSC__JSValue__jsNumberFromInt64(int64_t arg0); CPP_DECL void JSC__JSValue__jsonStringify(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1, uint32_t arg2, BunString* arg3); CPP_DECL void JSC__JSValue__jsonStringifyFast(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1, BunString* arg3); CPP_DECL JSC::EncodedJSValue JSC__JSValue__keys(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue arg1); @@ -246,7 +220,6 @@ CPP_DECL int32_t JSC__JSValue__toInt32(JSC::EncodedJSValue JSValue0); CPP_DECL int64_t JSC__JSValue__toInt64(JSC::EncodedJSValue JSValue0); CPP_DECL bool JSC__JSValue__toMatch(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1, JSC::EncodedJSValue JSValue2); CPP_DECL JSC::JSObject* JSC__JSValue__toObject(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1); -CPP_DECL JSC::JSString* JSC__JSValue__toString(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1); CPP_DECL JSC::JSString* JSC__JSValue__toStringOrNull(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1); CPP_DECL JSC::JSString* JSC__JSValue__toJSStringView(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* global, BunString* view, const JSC::JSString** owner); CPP_DECL uint64_t JSC__JSValue__toUInt64NoTruncate(JSC::EncodedJSValue JSValue0); @@ -257,31 +230,25 @@ CPP_DECL void JSC__JSValue__toZigException(JSC::EncodedJSValue JSValue0, JSC::JS CPP_DECL size_t JSC__VM__blockBytesAllocated(JSC::VM* arg0); CPP_DECL void JSC__VM__collectAsync(JSC::VM* arg0, bool full); CPP_DECL void JSC__VM__collectAsyncIdle(JSC::VM* arg0); +CPP_DECL void JSC__VM__collectAsyncForFinalizers(JSC::VM* arg0); CPP_DECL bool JSC__VM__shrinkFootprintNow(JSC::VM* arg0); CPP_DECL void JSC__VM__setStartupJITDeferralScale(JSC::VM* arg0, double scale); -CPP_DECL JSC::VM* JSC__VM__create(unsigned char HeapType0); -CPP_DECL void JSC__VM__deleteAllCode(JSC::VM* arg0, JSC::JSGlobalObject* arg1); CPP_DECL void JSC__VM__drainMicrotasks(JSC::VM* arg0); CPP_DECL bool JSC__VM__executionForbidden(JSC::VM* arg0); -CPP_DECL size_t JSC__VM__externalMemorySize(JSC::VM* arg0); CPP_DECL size_t JSC__VM__heapSize(JSC::VM* arg0); CPP_DECL void JSC__VM__holdAPILock(JSC::VM* arg0, void* arg1, void(* ArgFn2)(void* arg0)); CPP_DECL bool JSC__VM__isEntered(JSC::VM* arg0); -CPP_DECL void JSC__VM__notifyNeedDebuggerBreak(JSC::VM* arg0); -CPP_DECL void JSC__VM__notifyNeedShellTimeoutCheck(JSC::VM* arg0); CPP_DECL void JSC__VM__notifyNeedTermination(JSC::VM* arg0); CPP_DECL void JSC__VM__releaseWeakRefs(JSC::VM* arg0); CPP_DECL size_t JSC__VM__runGC(JSC::VM* arg0, bool arg1); CPP_DECL void JSC__VM__enableControlFlowProfiler(JSC::VM* arg0); CPP_DECL void JSC__VM__shrinkFootprint(JSC::VM* arg0); CPP_DECL void JSC__VM__throwError(JSC::VM* arg0, JSC::JSGlobalObject* arg1, JSC::EncodedJSValue JSValue2); -CPP_DECL void JSC__VM__throwError(JSC::VM* arg0, JSC::JSGlobalObject* arg1, JSC::EncodedJSValue JSValue2); CPP_DECL void FFI__ptr__put(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1); #ifdef __cplusplus -extern "C" JSC::EncodedJSValue SYSV_ABI FFI__ptr__fastpath(JSC::JSGlobalObject* arg0, void* arg1, JSC::JSUint8Array* arg2); extern "C" JSC::EncodedJSValue SYSV_ABI FFI__ptr__slowpath(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1, JSC::EncodedJSValue* arg2, size_t arg3); #endif @@ -289,7 +256,6 @@ CPP_DECL void Reader__u8__put(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSV #ifdef __cplusplus -extern "C" JSC::EncodedJSValue SYSV_ABI Reader__u8__fastpath(JSC::JSGlobalObject* arg0, void* arg1, int64_t arg2, int32_t arg3); extern "C" JSC::EncodedJSValue SYSV_ABI Reader__u8__slowpath(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1, JSC::EncodedJSValue* arg2, size_t arg3); #endif @@ -297,7 +263,6 @@ CPP_DECL void Reader__u16__put(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JS #ifdef __cplusplus -extern "C" JSC::EncodedJSValue SYSV_ABI Reader__u16__fastpath(JSC::JSGlobalObject* arg0, void* arg1, int64_t arg2, int32_t arg3); extern "C" JSC::EncodedJSValue SYSV_ABI Reader__u16__slowpath(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1, JSC::EncodedJSValue* arg2, size_t arg3); #endif @@ -305,7 +270,6 @@ CPP_DECL void Reader__u32__put(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JS #ifdef __cplusplus -extern "C" JSC::EncodedJSValue SYSV_ABI Reader__u32__fastpath(JSC::JSGlobalObject* arg0, void* arg1, int64_t arg2, int32_t arg3); extern "C" JSC::EncodedJSValue SYSV_ABI Reader__u32__slowpath(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1, JSC::EncodedJSValue* arg2, size_t arg3); #endif @@ -313,7 +277,6 @@ CPP_DECL void Reader__ptr__put(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JS #ifdef __cplusplus -extern "C" JSC::EncodedJSValue SYSV_ABI Reader__ptr__fastpath(JSC::JSGlobalObject* arg0, void* arg1, int64_t arg2, int32_t arg3); extern "C" JSC::EncodedJSValue SYSV_ABI Reader__ptr__slowpath(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1, JSC::EncodedJSValue* arg2, size_t arg3); #endif @@ -321,7 +284,6 @@ CPP_DECL void Reader__i8__put(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSV #ifdef __cplusplus -extern "C" JSC::EncodedJSValue SYSV_ABI Reader__i8__fastpath(JSC::JSGlobalObject* arg0, void* arg1, int64_t arg2, int32_t arg3); extern "C" JSC::EncodedJSValue SYSV_ABI Reader__i8__slowpath(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1, JSC::EncodedJSValue* arg2, size_t arg3); #endif @@ -329,7 +291,6 @@ CPP_DECL void Reader__i16__put(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JS #ifdef __cplusplus -extern "C" JSC::EncodedJSValue SYSV_ABI Reader__i16__fastpath(JSC::JSGlobalObject* arg0, void* arg1, int64_t arg2, int32_t arg3); extern "C" JSC::EncodedJSValue SYSV_ABI Reader__i16__slowpath(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1, JSC::EncodedJSValue* arg2, size_t arg3); #endif @@ -337,7 +298,6 @@ CPP_DECL void Reader__i32__put(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JS #ifdef __cplusplus -extern "C" JSC::EncodedJSValue SYSV_ABI Reader__i32__fastpath(JSC::JSGlobalObject* arg0, void* arg1, int64_t arg2, int32_t arg3); extern "C" JSC::EncodedJSValue SYSV_ABI Reader__i32__slowpath(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1, JSC::EncodedJSValue* arg2, size_t arg3); #endif @@ -345,7 +305,6 @@ CPP_DECL void Reader__f32__put(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JS #ifdef __cplusplus -extern "C" JSC::EncodedJSValue SYSV_ABI Reader__f32__fastpath(JSC::JSGlobalObject* arg0, void* arg1, int64_t arg2, int32_t arg3); extern "C" JSC::EncodedJSValue SYSV_ABI Reader__f32__slowpath(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1, JSC::EncodedJSValue* arg2, size_t arg3); #endif @@ -353,7 +312,6 @@ CPP_DECL void Reader__f64__put(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JS #ifdef __cplusplus -extern "C" JSC::EncodedJSValue SYSV_ABI Reader__f64__fastpath(JSC::JSGlobalObject* arg0, void* arg1, int64_t arg2, int32_t arg3); extern "C" JSC::EncodedJSValue SYSV_ABI Reader__f64__slowpath(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1, JSC::EncodedJSValue* arg2, size_t arg3); #endif @@ -361,7 +319,6 @@ CPP_DECL void Reader__i64__put(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JS #ifdef __cplusplus -extern "C" JSC::EncodedJSValue SYSV_ABI Reader__i64__fastpath(JSC::JSGlobalObject* arg0, void* arg1, int64_t arg2, int32_t arg3); extern "C" JSC::EncodedJSValue SYSV_ABI Reader__i64__slowpath(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1, JSC::EncodedJSValue* arg2, size_t arg3); #endif @@ -369,7 +326,6 @@ CPP_DECL void Reader__u64__put(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JS #ifdef __cplusplus -extern "C" JSC::EncodedJSValue SYSV_ABI Reader__u64__fastpath(JSC::JSGlobalObject* arg0, void* arg1, int64_t arg2, int32_t arg3); extern "C" JSC::EncodedJSValue SYSV_ABI Reader__u64__slowpath(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1, JSC::EncodedJSValue* arg2, size_t arg3); #endif @@ -377,7 +333,6 @@ CPP_DECL void Reader__intptr__put(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue #ifdef __cplusplus -extern "C" JSC::EncodedJSValue SYSV_ABI Reader__intptr__fastpath(JSC::JSGlobalObject* arg0, void* arg1, int64_t arg2, int32_t arg3); extern "C" JSC::EncodedJSValue SYSV_ABI Reader__intptr__slowpath(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1, JSC::EncodedJSValue* arg2, size_t arg3); #endif @@ -496,7 +451,6 @@ ZIG_DECL JSC::EncodedJSValue Bun__Process__setCwd(JSC::JSGlobalObject* arg0, con ZIG_DECL JSC::EncodedJSValue Bun__Process__getEval(JSC::JSGlobalObject* arg0); #endif -CPP_DECL ZigException ZigException__fromException(JSC::Exception* arg0); #pragma mark - Bun::ConsoleObject @@ -506,11 +460,6 @@ CPP_DECL ZigException ZigException__fromException(JSC::Exception* arg0); extern "C" SYSV_ABI void Bun__ConsoleObject__count(void* arg0, JSC::JSGlobalObject* arg1, const BunString* arg2); extern "C" SYSV_ABI void Bun__ConsoleObject__countReset(void* arg0, JSC::JSGlobalObject* arg1, const BunString* arg2); extern "C" SYSV_ABI void Bun__ConsoleObject__messageWithTypeAndLevel(void* arg0, uint32_t MessageType1, uint32_t MessageLevel2, JSC::JSGlobalObject* arg3, JSC::EncodedJSValue* arg4, size_t arg5); -extern "C" SYSV_ABI void Bun__ConsoleObject__profile(void* arg0, JSC::JSGlobalObject* arg1, const unsigned char* arg2, size_t arg3); -extern "C" SYSV_ABI void Bun__ConsoleObject__profileEnd(void* arg0, JSC::JSGlobalObject* arg1, const unsigned char* arg2, size_t arg3); -extern "C" SYSV_ABI void Bun__ConsoleObject__record(void* arg0, JSC::JSGlobalObject* arg1, ScriptArguments* arg2); -extern "C" SYSV_ABI void Bun__ConsoleObject__recordEnd(void* arg0, JSC::JSGlobalObject* arg1, ScriptArguments* arg2); -extern "C" SYSV_ABI void Bun__ConsoleObject__screenshot(void* arg0, JSC::JSGlobalObject* arg1, ScriptArguments* arg2); extern "C" SYSV_ABI void Bun__ConsoleObject__takeHeapSnapshot(void* arg0, JSC::JSGlobalObject* arg1); extern "C" SYSV_ABI void Bun__ConsoleObject__time(void* arg0, JSC::JSGlobalObject* arg1, const BunString* arg2); extern "C" SYSV_ABI void Bun__ConsoleObject__timeEnd(void* arg0, JSC::JSGlobalObject* arg1, const BunString* arg2); diff --git a/src/jsc/bindings/linux_perf_tracing.cpp b/src/jsc/bindings/linux_perf_tracing.cpp index 136e4061ce84..8998786da502 100644 --- a/src/jsc/bindings/linux_perf_tracing.cpp +++ b/src/jsc/bindings/linux_perf_tracing.cpp @@ -39,15 +39,6 @@ int Bun__linux_trace_init() return (trace_fd != -1) ? 1 : 0; } -// Close the trace file descriptor -void Bun__linux_trace_close() -{ - if (trace_fd != -1) { - close(trace_fd); - trace_fd = -1; - } -} - // Write a trace event to the trace marker // Format: "C|PID|EventName|DurationInNs" int Bun__linux_trace_emit(const char* event_name, int64_t duration_ns) diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp index 6f09c589e38c..4d1780dec31c 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp @@ -5,35 +5,40 @@ #include "DOMIsoSubspaces.h" #include "ScriptExecutionContext.h" #include "helpers.h" -#include "JSSocketAddressDTO.h" #include #include #include #include -#include #include #include extern "C" void Bun__NodeHTTPResponse_setClosed(void* zigResponse); 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" uint64_t Bun__NodeHTTPResponse_bufferedAmount(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); -extern "C" uint64_t uws_res_get_local_address_info(void* res, const char** dest, int* port, bool* is_ipv6); -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*); +extern "C" void* Bun__NodeHTTPSocket__create(const void* handlers, us_socket_t* socket, bool ssl, bool rejectUnauthorized); +extern "C" void Bun__NodeHTTPSocket__onOpen(void* handle, bool ssl); +extern "C" void Bun__NodeHTTPSocket__onHandshake(void* handle, int success, us_bun_verify_error_t verifyError); +extern "C" void Bun__NodeHTTPSocket__onData(void* handle, bool ssl, const char* data, size_t length); +extern "C" void Bun__NodeHTTPSocket__onEnd(void* handle, bool ssl); +extern "C" void Bun__NodeHTTPSocket__onWritable(void* handle, bool ssl); +extern "C" void Bun__NodeHTTPSocket__forgetConnection(void* handle, bool ssl); +extern "C" void Bun__NodeHTTPSocket__forgetWebSocket(void* handle, bool ssl, bool sentEverything); +extern "C" void Bun__NodeHTTPSocket__onClose(void* handle, bool ssl, int readError, bool runScript); +extern "C" void Bun__NodeHTTPSocket__leaveHttp(void* handle, bool ssl, us_socket_t* socket, const char* unsent, size_t unsentLength, bool readsPaused, uint64_t bytesRead, uint64_t responseBytesWritten); +extern "C" Zig::GlobalObject* Bun__NodeHTTPSocket__globalObject(void* handle, bool ssl); namespace Bun { using namespace JSC; using namespace WebCore; -// Calls ondata / ondrain / onclose in the context of the Bun.ModuleGraph whose script set it, if one -// did, and reports what it throws. +// Calls onclose in the context of the Bun.ModuleGraph whose script set it, if one did, and reports what it throws. static void callStoredCallback(Zig::GlobalObject* globalObject, JSObject* callback, JSValue thisValue, const ArgList& args) { auto scope = DECLARE_TOP_EXCEPTION_SCOPE(globalObject->vm()); @@ -55,7 +60,6 @@ JSNodeHTTPServerSocket* JSNodeHTTPServerSocket::create(JSC::VM& vm, JSC::Structu } auto* object = new (JSC::allocateCell(vm)) JSNodeHTTPServerSocket(vm, structure, socket, is_ssl, response); object->finishCreation(vm); - object->syncPeerCertificateVerification(); return object; } @@ -65,18 +69,6 @@ JSNodeHTTPServerSocket* JSNodeHTTPServerSocket::create(JSC::VM& vm, Zig::GlobalO return create(vm, structure, socket, is_ssl, response); } -template -void JSNodeHTTPServerSocket::clearSocketData(bool upgraded, us_socket_t* socket) -{ - if (upgraded) { - auto* webSocket = (uWS::WebSocketData*)us_socket_ext(socket); - webSocket->socketData = nullptr; - } else { - auto* httpResponseData = (uWS::HttpResponseData*)us_socket_ext(socket); - httpResponseData->socketData = nullptr; - } -} - template static void flushPartialResponseBeforeClose(us_socket_t* socket) { @@ -84,8 +76,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(); @@ -95,7 +86,7 @@ static void flushPartialResponseBeforeClose(us_socket_t* socket) void JSNodeHTTPServerSocket::close() { if (socket) { - if (!upgraded && !us_socket_is_closed(socket) && !us_socket_is_shut_down(socket)) { + if (!us_socket_is_closed(socket) && !us_socket_is_shut_down(socket)) { if (is_ssl) { flushPartialResponseBeforeClose(socket); } else { @@ -103,7 +94,7 @@ void JSNodeHTTPServerSocket::close() } } // uws is parsing this socket: it delivers the rest of the read, then closes (HTTP_NODE_CLOSE_AFTER_MESSAGE). - if (!upgraded && !us_socket_is_closed(socket)) { + if (!us_socket_is_closed(socket)) { bool deferred = is_ssl ? reinterpret_cast*>(socket)->closeAfterMessageIfParsing() : reinterpret_cast*>(socket)->closeAfterMessageIfParsing(); @@ -116,119 +107,65 @@ void JSNodeHTTPServerSocket::close() } } -void JSNodeHTTPServerSocket::reset() +template +static void setSocketReadsPaused(us_socket_t* socket, bool paused) { - if (socket) { - us_socket_close(socket, LIBUS_SOCKET_CLOSE_CODE_CONNECTION_RESET, nullptr); + auto* response = reinterpret_cast*>(socket); + if (paused) { + response->pauseReads(uWS::HttpResponseData::READ_PAUSE_SOCKET); + } else { + response->resumeReads(uWS::HttpResponseData::READ_PAUSE_SOCKET); } } template -static void onNodeHttpReadsResumable(us_socket_t* socket); - -template -static void upgradeToTunnelModeImpl(us_socket_t* socket, bool afterBody) +static void setStateBit(us_socket_t* socket, uint32_t bit, bool value) { - auto* httpResponseData = (uWS::HttpResponseData*)us_socket_ext(socket); - if (afterBody) { - /* The Upgrade request carries a body: keep parsing it as HTTP and only - * switch into tunnel mode once the message completes (Node 26 delivers - * the body through the request before raw data starts flowing). */ - httpResponseData->state |= uWS::HttpResponseData::HTTP_NODE_TUNNEL_AFTER_BODY; - return; - } - httpResponseData->isConnectRequest = true; - /* resume() on a response does nothing in tunnel mode: lift what paused reads before it (req.pause(), flood prevention). */ - onNodeHttpReadsResumable(socket); + auto& state = reinterpret_cast*>(us_socket_ext(socket))->state; + state = value ? state | bit : state & ~bit; } -void JSNodeHTTPServerSocket::upgradeToTunnelMode(bool afterBody, JSNodeHTTPResponse* response) +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)) { + if (!socket || us_socket_is_closed(socket)) { return; } - /* Like Node's http server connections (allowHalfOpen: true): the peer - * finishing its writable side ends the tunnel's readable side without - * tearing the connection down, so the server can still write and decides - * itself when to end the socket. */ - socket->flags.allow_half_open = 1; - /* 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) { - upgradeToTunnelModeImpl(socket, afterBody); + setStateBit(socket, uWS::HttpResponseData::HTTP_NODE_RAW_READS, enabled); } else { - upgradeToTunnelModeImpl(socket, afterBody); - } - /* The exchange leaves HTTP here: let the response release the server's - * pending-request accounting (see Flags::TUNNELED in NodeHTTPResponse.rs). */ - if (response != nullptr) { - Bun__NodeHTTPResponse_markTunneled(response->wrapped()); + setStateBit(socket, uWS::HttpResponseData::HTTP_NODE_RAW_READS, enabled); } } template -static bool isInTunnelMode(us_socket_t* socket) +static void liftParserReadPauses(us_socket_t* socket) { - return reinterpret_cast*>(us_socket_ext(socket))->isConnectRequest; + using Data = uWS::HttpResponseData; + reinterpret_cast*>(socket)->resumeReads(Data::READ_PAUSE_BODY | Data::READ_PAUSE_PARKED | Data::READ_PAUSE_FLOOD); } -static bool isTunnel(const JSNodeHTTPServerSocket* self) +void JSNodeHTTPServerSocket::leaveHttpAfterBody() { - us_socket_t* socket = self->socket; - if (!socket || self->upgraded || us_socket_is_closed(socket)) { - return false; - } - return self->is_ssl ? isInTunnelMode(socket) : isInTunnelMode(socket); -} - -void JSNodeHTTPServerSocket::applyTunnelReads() -{ - if (!isTunnel(this)) { + if (isClosed()) { return; } - if (tunnelReadsPaused()) { - us_socket_pause(socket); + // The body is the request's, not the socket's. + setRawReads(false); + if (is_ssl) { + setStateBit(socket, uWS::HttpResponseData::HTTP_NODE_LEAVE_AFTER_BODY, true); } else { - us_socket_resume(socket); - } -} - -void JSNodeHTTPServerSocket::readStop() -{ - // After the end of the stream no _read() comes to start the reads again. - if (!isTunnel(this) || tunnelReadEnded) { - return; + setStateBit(socket, uWS::HttpResponseData::HTTP_NODE_LEAVE_AFTER_BODY, true); } - tunnelReadsStopped = true; - applyTunnelReads(); } -void JSNodeHTTPServerSocket::readStart() +void JSNodeHTTPServerSocket::detachParser() { - if (!isTunnel(this)) { + if (isClosed()) { return; } - tunnelReadsStopped = false; - applyTunnelReads(); -} - -void JSNodeHTTPServerSocket::didDeliverQueuedTunnelBytes(size_t length) -{ - queuedTunnelBytes -= length; - if (tunnelReadsQueuedFull && queuedTunnelBytes == 0) { - tunnelReadsQueuedFull = false; - applyTunnelReads(); - } -} - -void JSNodeHTTPServerSocket::releaseTunnelReadsForUpgrade() -{ - tunnelReadsStopped = false; - tunnelReadsQueuedFull = false; - if (socket && !us_socket_is_closed(socket)) { - us_socket_resume(socket); - } + stopHTTPParsing(); + setRawReads(true); + is_ssl ? liftParserReadPauses(socket) : liftParserReadPauses(socket); } template @@ -313,8 +250,7 @@ static std::string& responseTrailersFor(us_socket_t* socket) void JSNodeHTTPServerSocket::setResponseTrailers(WTF::StringView trailers) { - /* Once a WebSocket adopts the connection, its ext block is no longer an HttpResponseData. */ - if (!socket || upgraded || us_socket_is_closed(socket) || trailers.isEmpty()) { + if (!socket || us_socket_is_closed(socket) || trailers.isEmpty()) { return; } // Node writes trailers with encoding 'latin1' (lib/_http_outgoing.js); @@ -334,9 +270,9 @@ void JSNodeHTTPServerSocket::setResponseTrailers(WTF::StringView trailers) /* internalEnd() decides the response framing from this base-struct mirror * so the shared path never touches the node-only string. */ if (is_ssl) { - ((uWS::HttpResponseData*)us_socket_ext(socket))->setFlag(uWS::HttpResponseData::HTTP_NODE_HAS_RESPONSE_TRAILERS, !dest.empty()); + setStateBit(socket, uWS::HttpResponseData::HTTP_NODE_HAS_RESPONSE_TRAILERS, !dest.empty()); } else { - ((uWS::HttpResponseData*)us_socket_ext(socket))->setFlag(uWS::HttpResponseData::HTTP_NODE_HAS_RESPONSE_TRAILERS, !dest.empty()); + setStateBit(socket, uWS::HttpResponseData::HTTP_NODE_HAS_RESPONSE_TRAILERS, !dest.empty()); } } @@ -346,84 +282,69 @@ bool JSNodeHTTPServerSocket::isClosed() const } template -static bool deferShutdownUntilResponseDrains(us_socket_t* socket, bool destroySoon) +static bool deferShutdownUntilResponseDrains(us_socket_t* socket) { - /* The end() of a finished response whose request body is still being parsed: Node parses the whole read before destroySoon(). */ - bool bodyStillParsing = destroySoon && reinterpret_cast*>(socket)->isDeliveringBodyAfterResponse(); auto* asyncSocket = reinterpret_cast*>(socket); auto* httpResponseData = reinterpret_cast*>(us_socket_ext(socket)); - if (!bodyStillParsing && asyncSocket->getBufferedAmount() == 0) { + if (asyncSocket->getBufferedAmount() == 0) { /* No response bytes to wait for: the caller ends the write side. */ httpResponseData->state |= uWS::HttpResponseData::HTTP_NODE_HALF_CLOSED; return false; } - /* uWS shuts down after the parse and the flush. HttpContext dispatches nothing behind a complete response that closes the connection. */ - httpResponseData->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE; - if (destroySoon) { - /* And closes it there, even if the response never ends. */ - httpResponseData->state |= uWS::HttpResponseData::HTTP_NODE_CLOSE_AFTER_DRAIN; - } + /* uWS shuts down after the flush. HttpContext dispatches nothing behind a complete response that closes the connection. */ + httpResponseData->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE | uWS::HttpResponseData::HTTP_NODE_SHUTDOWN_AFTER_DRAIN; return true; } -bool JSNodeHTTPServerSocket::shutdownAfterResponseDrains(bool destroySoon) +bool JSNodeHTTPServerSocket::shutdownAfterResponseDrains() { - if (!socket || upgraded || us_socket_is_closed(socket) || us_socket_is_shut_down(socket)) { + if (!socket || us_socket_is_closed(socket) || us_socket_is_shut_down(socket)) { return false; } flushResponseBytesAhead(); if (is_ssl) { - return deferShutdownUntilResponseDrains(socket, destroySoon); + return deferShutdownUntilResponseDrains(socket); } - return deferShutdownUntilResponseDrains(socket, destroySoon); -} - -JSC::EncodedJSValue JSNodeHTTPServerSocket::halfClose(JSC::JSGlobalObject* globalObject) -{ - // onNodeHTTPRequest no longer pauses at dispatch; pause here so the - // shutdown+resume below still cycles kqueue's EVFILT_READ (delete then - // re-add), without which macOS 26 does not deliver the peer's close. - // Not for a tunnel that paused its reads: the resume that ends that pause is the re-add. - const bool cycleReads = !upgraded && !tunnelReadsPaused(); - if (socket && cycleReads) { - us_socket_pause(socket); - } - auto result = us_socket_buffered_js_write(socket, is_ssl, ended, hasUnsentResponseBytes(), flushesStreamBufferOnDrain(), &streamBuffer, globalObject, JSValue::encode(JSC::jsUndefined()), JSValue::encode(JSC::jsUndefined())); - // Undo the pause above after the shutdown so the unread body drains - // and kqueue's one-shot EVFILT_WRITE (which delivers EV_EOF on - // SHUT_WR) is not deleted by a W -> R|W -> R step. - if (socket && cycleReads) { - us_socket_resume(socket); - } - return result; + return deferShutdownUntilResponseDrains(socket); } +#ifdef LIBUS_USE_KQUEUE template -static void closeWhenDrainedImpl(us_socket_t* socket) +static bool socketReadsPaused(us_socket_t* socket) { - auto* httpResponseData = reinterpret_cast*>(us_socket_ext(socket)); - /* uWS's close gates (below, or onWritable after the flush) close a connection marked like this. */ - httpResponseData->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE; - /* A response that ended inside the read being parsed is still in the cork buffer. */ - reinterpret_cast*>(socket)->uncork(); - reinterpret_cast*>(socket)->closeIfDoneAndMarked(httpResponseData); + return (reinterpret_cast*>(us_socket_ext(socket))->readPauseReasons & uWS::HttpResponseData::READ_PAUSE_SOCKET) != 0; } +#endif -void JSNodeHTTPServerSocket::closeWhenDrained() +void JSNodeHTTPServerSocket::halfClose() { - if (!socket || upgraded || us_socket_is_closed(socket)) { + if (isClosed()) { return; } - if (is_ssl) { - closeWhenDrainedImpl(socket); - } else { - closeWhenDrainedImpl(socket); +#ifdef LIBUS_USE_KQUEUE + // A pause and a resume around the shutdown cycle EVFILT_READ, without which macOS 26 does not deliver the peer's close. For a socket that paused its reads, the resume that ends that pause does it. + const bool cycleReads = !(is_ssl ? socketReadsPaused(socket) : socketReadsPaused(socket)); + if (cycleReads) { + is_ssl ? setSocketReadsPaused(socket, true) : setSocketReadsPaused(socket, true); + } +#endif + us_socket_shutdown(socket); +#ifdef LIBUS_USE_KQUEUE + // After the shutdown, so that a W -> R|W -> R step does not delete the one-shot EVFILT_WRITE, which delivers EV_EOF on SHUT_WR. + if (cycleReads && !us_socket_is_closed(socket)) { + is_ssl ? setSocketReadsPaused(socket, false) : setSocketReadsPaused(socket, false); } +#endif +} + +bool JSNodeHTTPServerSocket::finSent() const +{ + return ended && !isClosed() && us_socket_is_shut_down(socket); } bool JSNodeHTTPServerSocket::closeIfIdle() { - if (upgraded || isClosed()) { + if (isClosed()) { return false; } if (is_ssl) { @@ -437,9 +358,8 @@ static bool isRequestTimedOutImpl(us_socket_t* socket, uint64_t headersTimeoutMs { /* node:http compat connections always carry the derived ext block. */ auto* httpResponseData = reinterpret_cast*>(us_socket_ext(socket)); - if (httpResponseData->isConnectRequest) { - // CONNECT/Upgrade tunnels are detached from the HTTP request machinery, - // like Node freeing the parser for upgraded connections. + if (httpResponseData->isParserDetached()) { + // Like Node, which frees the parser of a connection that 'connect' or 'upgrade' got. return false; } if (httpResponseData->requestTimeoutReported) { @@ -463,7 +383,7 @@ static bool isRequestTimedOutImpl(us_socket_t* socket, uint64_t headersTimeoutMs bool JSNodeHTTPServerSocket::isRequestTimedOut(uint64_t headersTimeoutMs, uint64_t requestTimeoutMs) { - if (!socket || upgraded || us_socket_is_closed(socket)) { + if (!socket || us_socket_is_closed(socket)) { return false; } if (is_ssl) { @@ -472,91 +392,78 @@ bool JSNodeHTTPServerSocket::isRequestTimedOut(uint64_t headersTimeoutMs, uint64 return isRequestTimedOutImpl(socket, headersTimeoutMs, requestTimeoutMs); } -bool JSNodeHTTPServerSocket::isAuthorized() const +JSNodeHTTPServerSocket::~JSNodeHTTPServerSocket() { - // is secure means that tls was established successfully - if (!is_ssl || upgraded || !socket) - return false; - - // Check if the handshake callback has fired. If so, use the isAuthorized flag - // which reflects the actual certificate verification result. - if (us_socket_ssl_handshake_callback_has_fired(socket)) { - auto* httpResponseData = reinterpret_cast*>(us_socket_ext(socket)); - if (!httpResponseData) - return false; - return httpResponseData->isAuthorized; - } - - // The handshake callback hasn't fired yet, but we're in an HTTP handler, - // which means we received HTTP data. Check if the TLS handshake has actually - // completed using OpenSSL's state (SSL_is_init_finished). - // - // If the handshake is complete but the callback hasn't fired, we're in a race - // condition. The callback will fire shortly and either: - // 1. Set isAuthorized = true (success) - // 2. Close the socket (if rejectUnauthorized and verification failed) - // - // Since we're in an HTTP handler and the socket isn't closed, we can safely - // assume the handshake will succeed. If it fails, the socket will be closed - // and subsequent operations will fail appropriately. - return us_socket_is_ssl_handshake_finished(socket); -} - -const char* JSNodeHTTPServerSocket::sniServername() const -{ - if (!is_ssl || !socket) { - return nullptr; + // A VM that ends drops the task that onClose() posted without running it. + closeSocketHandle(false); + if (socket && is_ssl) { + reinterpret_cast*>(us_socket_ext(socket))->socketData = nullptr; + } else if (socket) { + reinterpret_cast*>(us_socket_ext(socket))->socketData = nullptr; } - return us_socket_sni_servername(socket); } -void JSNodeHTTPServerSocket::syncPeerCertificateVerification() +JSNodeHTTPServerSocket::JSNodeHTTPServerSocket(JSC::VM& vm, JSC::Structure* structure, us_socket_t* socket, bool is_ssl, JSNodeHTTPResponse* response) + : JSC::JSDestructibleObject(vm, structure) + , socket(socket) + , is_ssl(is_ssl) + , m_currentResponse(response, JSC::WriteBarrierEarlyInit) { - if (!is_ssl || upgraded || !socket || !us_socket_ssl_handshake_callback_has_fired(socket)) - return; - auto* httpResponseData = reinterpret_cast*>(us_socket_ext(socket)); - if (!httpResponseData) - return; - peer_cert_verified = httpResponseData->peerCertVerified; - peerCertVerifyErrorCode = httpResponseData->peerCertVerifyErrorCode; } -bool JSNodeHTTPServerSocket::isPeerCertificateVerified() +bool JSNodeHTTPServerSocket::onPeerEnded() { - syncPeerCertificateVerification(); - return peer_cert_verified; + if (!m_socketHandle) { + return false; + } + Bun__NodeHTTPSocket__onEnd(m_socketHandle, is_ssl); + return true; } -const char* JSNodeHTTPServerSocket::peerCertificateVerificationError() +bool JSNodeHTTPServerSocket::finishParsing() { - if (!is_ssl) - return nullptr; - syncPeerCertificateVerification(); - return peerCertVerifyErrorCode; + if (isClosed()) { + return false; + } + return is_ssl ? reinterpret_cast*>(socket)->finishParsing() : reinterpret_cast*>(socket)->finishParsing(); } -JSNodeHTTPServerSocket::~JSNodeHTTPServerSocket() +void JSNodeHTTPServerSocket::onUpgradeBodyEnded() { - if (socket) { - if (is_ssl) { - clearSocketData(this->upgraded, socket); - } else { - clearSocketData(this->upgraded, socket); - } + if (!m_socketHandle) { + return; } - us_socket_free_stream_buffer(&streamBuffer); + auto* globalObject = defaultGlobalObject(this->globalObject()); + EnsureStillAliveScope keepThis(this); + auto* callback = functionToCallOnClose.get(); + auto* duplex = m_duplex.get(); + MarkedArgumentBuffer args; + if (duplex) { + args.append(duplex); + } + if (!leaveHttp(false)) { + detachParser(); + return; + } + if (!callback || !duplex || globalObject->scriptExecutionStatus(globalObject, this) != ScriptExecutionStatus::Running) { + return; + } + // onclose(socket): the connection is open, and it's the socket's from here on. + Bun__EventLoop__enter(globalObject); + callStoredCallback(globalObject, callback, this, args); + Bun__EventLoop__exit(globalObject); } -JSNodeHTTPServerSocket::JSNodeHTTPServerSocket(JSC::VM& vm, JSC::Structure* structure, us_socket_t* socket, bool is_ssl, JSNodeHTTPResponse* response) - : JSC::JSDestructibleObject(vm, structure) - , socket(socket) - , is_ssl(is_ssl) - , m_currentResponse(response, JSC::WriteBarrierEarlyInit) +void JSNodeHTTPServerSocket::closeSocketHandle(bool runScript) { + if (void* handle = std::exchange(m_socketHandle, nullptr)) { + Bun__NodeHTTPSocket__onClose(handle, is_ssl, closeReadError, runScript); + } } void JSNodeHTTPServerSocket::detach() { + closeSocketHandle(false); this->m_duplex.clear(); this->m_currentResponse.clear(); { @@ -573,54 +480,16 @@ void JSNodeHTTPServerSocket::appendPipelinedResponse(JSC::VM& vm, JSNodeHTTPResp m_pipelinedResponses.last().set(vm, this, response); } -/* Flood prevention gives the reads back. A tunnel that paused them itself (readStop) also resumes them itself. */ -template -static void endFloodPreventionPause(us_socket_t* socket, uWS::NodeHttpResponseData* httpResponseData) -{ - httpResponseData->state &= ~uWS::HttpResponseData::HTTP_NODE_READS_PAUSED; - auto* cell = reinterpret_cast(httpResponseData->socketData); - if (cell && cell->tunnelReadsPaused()) { - return; - } - reinterpret_cast*>(socket)->resume(); -} - -/* A pipelined CONNECT stays queued so that the connection never counts as idle. No request follows it, so it holds no reads. */ -template -static bool queuedResponsesHoldReads(uWS::NodeHttpResponseData* httpResponseData) -{ - return httpResponseData->nodeHttpQueuedPipelinedCount > 0 && !httpResponseData->isConnectRequest; -} - -/* node:http flood prevention, resume half: unsent response bytes and queued responses hold the reads. */ -template -static void onNodeHttpReadsResumable(us_socket_t* socket) -{ - auto* httpResponseData = reinterpret_cast*>(us_socket_ext(socket)); - if ((httpResponseData->state & uWS::HttpResponseData::HTTP_NODE_READS_PAUSED) - && (reinterpret_cast*>(socket)->getBufferedAmount() > 0 || queuedResponsesHoldReads(httpResponseData))) { - return; - } - endFloodPreventionPause(socket, httpResponseData); -} - -/* Pause edge (JS-driven, after the caller paused the socket). */ -template -static void onNodeHttpReadsPaused(us_socket_t* socket) -{ - auto* d = reinterpret_cast*>(us_socket_ext(socket)); - d->state |= uWS::HttpResponseData::HTTP_NODE_READS_PAUSED; -} - -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) { - onNodeHttpReadsPaused(socket); + reinterpret_cast*>(socket)->setParked(true); } else { - onNodeHttpReadsPaused(socket); + reinterpret_cast*>(socket)->setParked(true); } } @@ -644,17 +513,8 @@ extern "C" bool Bun__NodeHTTP__notifyWhenReadParsed(int ssl, us_socket_t* socket return ssl ? notifyWhenNodeHttpReadParsed(socket) : notifyWhenNodeHttpReadParsed(socket); } -extern "C" void Bun__NodeHTTP__onReadsResumable(int ssl, us_socket_t* socket) -{ - if (ssl) { - onNodeHttpReadsResumable(socket); - } else { - onNodeHttpReadsResumable(socket); - } -} - template -static void startPipelinedResponseImpl(us_socket_t* socket, bool isAncient, bool connectionClose) +static void startRawResponseImpl(us_socket_t* socket, bool connectionClose) { /* node:http compat connections always carry the derived ext block. */ auto* httpResponseData = reinterpret_cast*>(us_socket_ext(socket)); @@ -663,28 +523,26 @@ static void startPipelinedResponseImpl(us_socket_t* socket, bool isAncient, bool // response now owns the per-response state the request handler normally // resets per parsed request. httpResponseData->offset = 0; - // Clears the finished response's framing bits and keeps the connection-scoped - // ones (notably HTTP_NODE_READS_PAUSED, read again below). + // Clears the finished response's framing bits and keeps the connection-scoped ones. httpResponseData->resetResponseState(); if (connectionClose) { httpResponseData->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE; } - if (isAncient) { - httpResponseData->state |= uWS::HttpResponseData::HTTP_ANCIENT_REQUEST; - } httpResponseData->nodeHttpResponseTrailers.clear(); if (httpResponseData->nodeHttpQueuedPipelinedCount > 0) { httpResponseData->nodeHttpQueuedPipelinedCount--; } - if (httpResponseData->state & uWS::HttpResponseData::HTTP_NODE_READS_PAUSED) { - onNodeHttpReadsResumable(socket); - } + auto* uwsResponse = reinterpret_cast*>(socket); + uwsResponse->endFloodPauseIfDrained(); + // JavaScript renders this response and writes it with socket.write(): the header section is over, and uWS has no byte of its own to send. + uwsResponse->setFraming(uWS::HttpResponseData::HTTP_BODY_NONE); + httpResponseData->state |= uWS::HttpResponseData::HTTP_WRITE_CALLED; } -bool JSNodeHTTPServerSocket::startPipelinedResponse(JSC::VM& vm, JSNodeHTTPResponse* response, bool isAncient, bool connectionClose) +bool JSNodeHTTPServerSocket::startRawResponse(JSC::VM& vm, JSNodeHTTPResponse* response, bool connectionClose) { - if (!socket || upgraded || us_socket_is_closed(socket)) { + if (!socket || us_socket_is_closed(socket)) { return false; } @@ -701,9 +559,9 @@ bool JSNodeHTTPServerSocket::startPipelinedResponse(JSC::VM& vm, JSNodeHTTPRespo // Before the state reset: the response in flight leaves its last bytes in the buffer and its handler slots empty. setCurrentResponse(vm, response); if (is_ssl) { - startPipelinedResponseImpl(socket, isAncient, connectionClose); + startRawResponseImpl(socket, connectionClose); } else { - startPipelinedResponseImpl(socket, isAncient, connectionClose); + startRawResponseImpl(socket, connectionClose); } Bun__NodeHTTPResponse_grantConnection(response->wrapped()); return true; @@ -711,7 +569,7 @@ bool JSNodeHTTPServerSocket::startPipelinedResponse(JSC::VM& vm, JSNodeHTTPRespo void JSNodeHTTPServerSocket::stopHTTPParsing() { - if (!socket || upgraded || us_socket_is_closed(socket)) { + if (!socket || us_socket_is_closed(socket)) { return; } if (is_ssl) { @@ -751,34 +609,12 @@ static void notifyResponsesOnClose(JSNodeHTTPServerSocket* socket) } } -void JSNodeHTTPServerSocket::onUpgraded(us_socket_t* adopted) -{ - socket = adopted; - upgraded = true; - releaseTunnelReadsForUpgrade(); - - // The HTTP connection is gone for the responses that did not upgrade it. They stay queued: the close still tells them. - if (auto* res = currentResponse()) { - Bun__NodeHTTPResponse_takeBackConnection(res->wrapped(), JSValue::encode(res), true); - } - WTF::Vector pipelined; - { - Locker locker { m_pipelinedResponsesLock }; - for (auto& entry : m_pipelinedResponses) { - if (auto* res = entry.get()) { - pipelined.append(res); - } - } - } - for (auto* res : pipelined) { - Bun__NodeHTTPResponse_takeBackConnection(res->wrapped(), JSValue::encode(res), true); - } -} - void JSNodeHTTPServerSocket::onClose(int readError, bool peerEnded) { - syncPeerCertificateVerification(); this->socket = nullptr; + if (m_socketHandle) { + Bun__NodeHTTPSocket__forgetConnection(m_socketHandle, is_ssl); + } this->closeReadError = readError; this->peer_ended = peerEnded; if (auto* res = currentResponse()) { @@ -830,6 +666,7 @@ void JSNodeHTTPServerSocket::onClose(int readError, bool peerEnded) if (!scope.exception()) { callStoredCallback(globalObject, callbackObject, thisObject, args); RETURN_IF_EXCEPTION(scope, ); + thisObject->closeSocketHandle(true); } else if (!vm.hasPendingTerminationException()) { auto* ptr = scope.exception(); scope.clearException(); @@ -843,7 +680,7 @@ void JSNodeHTTPServerSocket::onClose(int readError, bool peerEnded) void JSNodeHTTPServerSocket::flushResponseBytesAhead() { - if (upgraded || isClosed()) { + if (isClosed()) { return; } if (auto* res = currentResponse()) { @@ -856,152 +693,36 @@ void JSNodeHTTPServerSocket::flushResponseBytesAhead() } } -bool JSNodeHTTPServerSocket::hasUnsentResponseBytes() const -{ - if (upgraded || isClosed()) { - return false; - } - if (is_ssl) { - return reinterpret_cast*>(socket)->getBufferedAmount() > 0; - } - return reinterpret_cast*>(socket)->getBufferedAmount() > 0; -} - template -static bool writeBehindResponse(us_socket_t* socket, const char* data, size_t length) +static void unparkIfBytesAreUnsent(us_socket_t* socket) { - auto* asyncSocket = reinterpret_cast*>(socket); - if (length > 0) { - asyncSocket->write(data, length); + if (!reinterpret_cast*>(socket)->hasFullyDrained()) { + reinterpret_cast*>(socket)->setParked(false); } - return asyncSocket->getBufferedAmount() > 0; } -/* A raw socket.write() takes the path of a 1xx line (HttpResponse::writeRawInformational). Returns whether uWS still holds bytes. */ -extern "C" bool Bun__NodeHTTPServerSocket__writeBehindResponse(us_socket_t* socket, bool is_ssl, const char* data, size_t length) +void JSNodeHTTPServerSocket::updateParked() { - return is_ssl ? writeBehindResponse(socket, data, length) : writeBehindResponse(socket, data, length); -} - -void JSNodeHTTPServerSocket::updateTunnelIdle() -{ - if (!tunnelReadEnded || upgraded || isClosed()) { + if (isClosed()) { return; } - const bool sent = streamBuffer.bufferedSize() == 0; - if (is_ssl) { - reinterpret_cast*>(socket)->setNodeHttpTunnelIdle(sent && reinterpret_cast*>(socket)->hasFullyDrained()); - } else { - reinterpret_cast*>(socket)->setNodeHttpTunnelIdle(sent && reinterpret_cast*>(socket)->hasFullyDrained()); + is_ssl ? unparkIfBytesAreUnsent(socket) : unparkIfBytesAreUnsent(socket); + if (auto* res = currentResponse()) { + Bun__NodeHTTPResponse_updateParked(res->wrapped()); } } void JSNodeHTTPServerSocket::onDrain() { - // This function can be called during GC! - Zig::GlobalObject* globalObject = static_cast(this->globalObject()); - if (!functionToCallOnDrain) { - return; - } - - // A read pause or resume arms the writable event too: nothing was buffered, so nothing drained. - if (this->streamBuffer.bufferedSize() == 0 && !heldWriteAwaitsDrain) { - updateTunnelIdle(); - return; - } - // uWS calls this with its own buffer empty, so the write it held has left. - heldWriteAwaitsDrain = false; - if (this->streamBuffer.bufferedSize() > 0) { - auto* globalObject = defaultGlobalObject(this->globalObject()); - auto scope = DECLARE_TOP_EXCEPTION_SCOPE(globalObject->vm()); - us_socket_buffered_js_write(this->socket, this->is_ssl, this->ended, this->hasUnsentResponseBytes(), this->flushesStreamBufferOnDrain(), &this->streamBuffer, globalObject, JSValue::encode(JSC::jsUndefined()), JSValue::encode(JSC::jsUndefined())); - if (auto* exception = scope.exception()) { - (void)scope.tryClearException(); - globalObject->reportUncaughtExceptionAtEventLoop(globalObject, exception); - RETURN_IF_EXCEPTION(scope, ); - return; - } - - if (this->streamBuffer.bufferedSize() > 0) { - // need to drain more - return; - } - } - updateTunnelIdle(); - WebCore::ScriptExecutionContext* scriptExecutionContext = globalObject->scriptExecutionContext(); - - if (scriptExecutionContext) { - scriptExecutionContext->postTask([self = this](ScriptExecutionContext& context) { - auto* globalObject = defaultGlobalObject(context.globalObject()); - auto* thisObject = self; - auto* callbackObject = thisObject->functionToCallOnDrain.get(); - if (!callbackObject) { - return; - } - MarkedArgumentBuffer args; - EnsureStillAliveScope ensureStillAlive(self); - - if (globalObject->scriptExecutionStatus(globalObject, thisObject) == ScriptExecutionStatus::Running) - callStoredCallback(globalObject, callbackObject, thisObject, args); - }); + if (m_socketHandle) { + Bun__NodeHTTPSocket__onWritable(m_socketHandle, is_ssl); } } -void JSNodeHTTPServerSocket::onData(const char* data, int length, bool last) +void JSNodeHTTPServerSocket::onRawRead(const char* data, int length) { - // This function can be called during GC! - Zig::GlobalObject* globalObject = static_cast(this->globalObject()); - if (last) { - tunnelReadEnded = true; - } - if (!functionToCallOnData) { - return; - } - - WebCore::ScriptExecutionContext* scriptExecutionContext = globalObject->scriptExecutionContext(); - - if (scriptExecutionContext) { - auto scope = DECLARE_TOP_EXCEPTION_SCOPE(globalObject->vm()); - JSC::JSUint8Array* buffer = WebCore::createBuffer(globalObject, std::span(reinterpret_cast(data), length)); - auto chunk = JSC::JSValue(buffer); - if (auto* exception = scope.exception()) { - (void)scope.tryClearException(); - globalObject->reportUncaughtExceptionAtEventLoop(globalObject, exception); - RETURN_IF_EXCEPTION(scope, ); - return; - } - gcProtect(chunk); - // JS gets the chunk in a task, so its readStop() comes after the read loop: bound what the loop queues. - queuedTunnelBytes += length; - if (queuedTunnelBytes >= LIBUS_RECV_BUFFER_LENGTH && !tunnelReadsQueuedFull) { - tunnelReadsQueuedFull = true; - applyTunnelReads(); - } - scriptExecutionContext->postTask([self = this, chunk = chunk, last = last, length = static_cast(length)](ScriptExecutionContext& context) { - auto* globalObject = defaultGlobalObject(context.globalObject()); - auto* thisObject = self; - auto* callbackObject = thisObject->functionToCallOnData.get(); - EnsureStillAliveScope ensureChunkStillAlive(chunk); - gcUnprotect(chunk); - // After the callback: a readStop() from it keeps the reads paused, with no resume in between. - auto delivered = WTF::makeScopeExit([&] { - thisObject->didDeliverQueuedTunnelBytes(length); - if (last) { - thisObject->updateTunnelIdle(); - } - }); - if (!callbackObject) { - return; - } - - MarkedArgumentBuffer args; - args.append(chunk); - args.append(JSC::jsBoolean(last)); - EnsureStillAliveScope ensureStillAlive(self); - - if (globalObject->scriptExecutionStatus(globalObject, thisObject) == ScriptExecutionStatus::Running) - callStoredCallback(globalObject, callbackObject, thisObject, args); - }); + if (m_socketHandle) { + Bun__NodeHTTPSocket__onData(m_socketHandle, is_ssl, data, length); } } @@ -1026,10 +747,6 @@ void JSNodeHTTPServerSocket::visitChildrenImpl(JSCell* cell, Visitor& visitor) visitor.append(fn->m_currentResponse); visitor.append(fn->functionToCallOnClose); - visitor.append(fn->functionToCallOnDrain); - visitor.append(fn->functionToCallOnData); - visitor.append(fn->m_remoteAddress); - visitor.append(fn->m_localAddress); visitor.append(fn->m_duplex); { Locker locker { fn->m_pipelinedResponsesLock }; @@ -1074,24 +791,223 @@ extern "C" JSC::EncodedJSValue Bun__getNodeHTTPServerSocketThisValue(bool is_ssl return JSValue::encode(getNodeHTTPServerSocket(socket)); } -extern "C" void Bun__NodeHTTP__onReadParsed(int ssl, us_socket_t* socket) +void JSNodeHTTPServerSocket::onHandshake(int success, us_bun_verify_error_t verifyError) { - if (us_socket_is_closed(socket)) { + if (m_socketHandle) { + Bun__NodeHTTPSocket__onHandshake(m_socketHandle, success, verifyError); + } +} + +extern "C" uint64_t Bun__NodeHTTPServerSocket__bytesRead(us_socket_t* socket, bool ssl) +{ + if (ssl) { + return reinterpret_cast*>(us_socket_ext(socket))->bytesRead; + } + return reinterpret_cast*>(us_socket_ext(socket))->bytesRead; +} + +template +static void handOverToSocketHandle(us_socket_t* socket, void* handle, bool restOfReadTaken, uint64_t responseBytesWritten) +{ + auto* contextData = uWS::HttpContext::getSocketContextDataS(socket); + auto left = reinterpret_cast*>(socket)->leaveHttp(); + Bun__NodeHTTPSocket__leaveHttp(handle, SSL, socket, left.backpressure.data(), left.backpressure.length(), left.readsPaused, left.bytesRead, responseBytesWritten); + contextData->socketLeft(socket, restOfReadTaken); +} + +bool JSNodeHTTPServerSocket::leaveHttp(bool restOfReadTaken) +{ + if (isClosed() || !m_socketHandle) { + return false; + } + flushResponseBytesAhead(); + if (us_socket_is_closed(socket) || us_socket_is_shut_down(socket)) { + return false; + } + uint64_t responseBytesWritten = 0; + if (auto* res = currentResponse()) { + Bun__NodeHTTPResponse_takeBackConnection(res->wrapped(), JSValue::encode(res)); + responseBytesWritten = Bun__NodeHTTPResponse_bytesWritten(res->wrapped()); + } + // The responses queued behind the one that leaves never get the connection. + WTF::Vector queued; + { + Locker locker { m_pipelinedResponsesLock }; + for (auto& entry : m_pipelinedResponses) { + if (auto* res = entry.get()) { + queued.append(res); + } + } + } + for (auto* res : queued) { + Bun__NodeHTTPResponse_takeBackConnection(res->wrapped(), JSValue::encode(res)); + } + us_socket_t* leaving = std::exchange(socket, nullptr); + void* handle = std::exchange(m_socketHandle, nullptr); + if (is_ssl) { + handOverToSocketHandle(leaving, handle, restOfReadTaken, responseBytesWritten); + } else { + handOverToSocketHandle(leaving, handle, restOfReadTaken, responseBytesWritten); + } + detach(); + return true; +} + +// The WebSocket's close handler runs right behind this, and ws.ts has to hear it before the socket's 'close'. +template +static void onAdoptedWebSocketClosed(void* handle, us_socket_t* socket, int, bool) +{ + Bun__NodeHTTPSocket__forgetWebSocket(handle, SSL, reinterpret_cast*>(socket)->hasFullyDrained() && !us_socket_ssl_spill_dropped(socket)); + auto* globalObject = Bun__NodeHTTPSocket__globalObject(handle, SSL); + auto* context = globalObject->scriptExecutionContext(); + if (!context || globalObject->isShuttingDown()) { + Bun__NodeHTTPSocket__onClose(handle, SSL, 0, false); return; } + context->postTask([handle](ScriptExecutionContext& context) { + auto* globalObject = defaultGlobalObject(context.globalObject()); + Bun__NodeHTTPSocket__onClose(handle, SSL, 0, globalObject->scriptExecutionStatus(globalObject, globalObject) == ScriptExecutionStatus::Running); + }); +} + +template +static us_socket_t* adoptIntoWebSocket(us_socket_t* socket, void* webSocketContext, void* userData, std::string_view handshake, const uWS::PerMessageDeflateParams* deflateParams, void* handle) +{ + return reinterpret_cast*>(webSocketContext)->adoptSocket(socket, sizeof(uWS::HttpResponseData), std::move(userData), handshake, deflateParams != nullptr, deflateParams ? uWS::compressOptionsFor(*deflateParams) : uWS::CompressOptions::DISABLED, handle, onAdoptedWebSocketClosed); +} + +// `socket` left HTTP for `handle` (leaveHttp() above), so its block still has the size of a node:http connection's. +extern "C" us_socket_t* Bun__NodeHTTPSocket__adoptIntoWebSocket(bool ssl, us_socket_t* socket, void* webSocketContext, void* userData, const char* handshake, size_t handshakeLength, const uWS::PerMessageDeflateParams* deflateParams, void* handle) +{ + std::string_view head(handshake, handshakeLength); + return ssl ? adoptIntoWebSocket(socket, webSocketContext, userData, head, deflateParams, handle) : adoptIntoWebSocket(socket, webSocketContext, userData, head, deflateParams, handle); +} + +static JSNodeHTTPServerSocket* openServerSocket(us_socket_t* socket, bool ssl) +{ + auto* serverSocket = ssl ? getNodeHTTPServerSocket(socket) : getNodeHTTPServerSocket(socket); + return serverSocket && !serverSocket->isClosed() ? serverSocket : nullptr; +} + +// The functions below are InternalSocket::UwsHttp in src/uws_sys/socket.rs: what req.socket._handle asks of a connection that uWS owns. +extern "C" void Bun__NodeHTTPServerSocket__write(us_socket_t* socket, bool ssl, const char* data, size_t length) +{ + auto* serverSocket = openServerSocket(socket, ssl); + if (!serverSocket || serverSocket->ended) { + return; + } + serverSocket->flushResponseBytesAhead(); + // The path of a 1xx line (HttpResponse::writeRawInformational): uWS buffers what the kernel doesn't take. + ssl ? (void)reinterpret_cast*>(socket)->write(data, length) : (void)reinterpret_cast*>(socket)->write(data, length); + serverSocket->updateParked(); +} + +extern "C" void Bun__NodeHTTPServerSocket__shutdown(us_socket_t* socket, bool ssl) +{ + auto* serverSocket = openServerSocket(socket, ssl); + if (!serverSocket) { + return; + } + serverSocket->ended = true; + if (!serverSocket->shutdownAfterResponseDrains()) { + serverSocket->halfClose(); + } +} + +template +static bool closeBehindPendingShutdown(us_socket_t* socket) +{ + auto* httpResponseData = reinterpret_cast*>(us_socket_ext(socket)); + if (!(httpResponseData->state & uWS::HttpResponseData::HTTP_NODE_SHUTDOWN_AFTER_DRAIN)) { + return false; + } + httpResponseData->state &= ~uWS::HttpResponseData::HTTP_NODE_SHUTDOWN_AFTER_DRAIN; + httpResponseData->state |= uWS::HttpResponseData::HTTP_NODE_CLOSE_AFTER_DRAIN; + return true; +} + +extern "C" void Bun__NodeHTTPServerSocket__close(us_socket_t* socket, bool ssl, int code) +{ + auto* serverSocket = openServerSocket(socket, ssl); + if (!serverSocket) { + return; + } + if (code == LIBUS_SOCKET_CLOSE_CODE_CONNECTION_RESET) { + us_socket_close(socket, LIBUS_SOCKET_CLOSE_CODE_CONNECTION_RESET, nullptr); + return; + } + // destroySoon(): the FIN still waits for queued bytes, so the close goes behind them too. + if (ssl ? closeBehindPendingShutdown(socket) : closeBehindPendingShutdown(socket)) { + return; + } + serverSocket->close(); +} + +extern "C" uint64_t Bun__NodeHTTPServerSocket__responseBytesWritten(us_socket_t* socket, bool ssl) +{ + auto* serverSocket = ssl ? getNodeHTTPServerSocket(socket) : getNodeHTTPServerSocket(socket); + auto* res = serverSocket ? serverSocket->currentResponse() : nullptr; + return res ? Bun__NodeHTTPResponse_bytesWritten(res->wrapped()) : 0; +} + +extern "C" uint64_t Bun__NodeHTTPServerSocket__writeQueueSize(us_socket_t* socket, bool ssl) +{ auto* serverSocket = ssl ? getNodeHTTPServerSocket(socket) : getNodeHTTPServerSocket(socket); + auto* res = serverSocket ? serverSocket->currentResponse() : nullptr; + uint64_t buffered = ssl ? reinterpret_cast*>(socket)->getBufferedAmount() : reinterpret_cast*>(socket)->getBufferedAmount(); + // The response adds the tail that it still sends from the caller's buffer, and says 0 once it is complete. + uint64_t ofResponse = res ? Bun__NodeHTTPResponse_bufferedAmount(res->wrapped()) : 0; + return (ofResponse > buffered ? ofResponse : buffered) + us_socket_ssl_spill_pending(socket); +} + +extern "C" bool Bun__NodeHTTPServerSocket__hasUnsentBytes(us_socket_t* socket, bool ssl) +{ + return ssl ? !reinterpret_cast*>(socket)->hasFullyDrained() : !reinterpret_cast*>(socket)->hasFullyDrained(); +} + +extern "C" void Bun__NodeHTTPServerSocket__setReadsPaused(us_socket_t* socket, bool ssl, bool paused) +{ + auto* serverSocket = openServerSocket(socket, ssl); if (!serverSocket) { return; } - if (auto* res = serverSocket->currentResponse()) { + ssl ? setSocketReadsPaused(socket, paused) : setSocketReadsPaused(socket, paused); + serverSocket->updateParked(); +} + +void JSNodeHTTPServerSocket::onReadParsed() +{ + if (isClosed()) { + return; + } + if (auto* res = currentResponse()) { Bun__NodeHTTPResponse_onReadParsed(res->wrapped()); } } +template +static bool markHalfClosed(us_socket_t* socket) +{ + 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; + return peerEnded; +} + +void JSNodeHTTPServerSocket::halfCloseAfterDrain() +{ + const bool peerEnded = is_ssl ? markHalfClosed(socket) : markHalfClosed(socket); + halfClose(); + // Both sides have ended, so no later event would close the socket. + if (peerEnded && !isClosed()) { + close(); + } +} + // 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. -extern "C" JSC::EncodedJSValue Bun__getOrCreateNodeHTTPServerSocket(bool isSSL, us_socket_t* us_socket, Zig::GlobalObject* globalObject) +extern "C" JSC::EncodedJSValue Bun__getOrCreateNodeHTTPServerSocket(bool isSSL, us_socket_t* us_socket, Zig::GlobalObject* globalObject, const void* socketHandlers) { auto& vm = globalObject->vm(); @@ -1128,6 +1044,11 @@ extern "C" JSC::EncodedJSValue Bun__getOrCreateNodeHTTPServerSocket(bool isSSL, } if (socket) { socket->strongThis.set(vm, socket); + if (socketHandlers) { + socket->m_socketHandle = Bun__NodeHTTPSocket__create(socketHandlers, us_socket, isSSL, isSSL && us_socket_rejects_unauthorized(us_socket)); + // node:net's accept path, which emits 'connection'. A listener can close the connection. + Bun__NodeHTTPSocket__onOpen(socket->m_socketHandle, isSSL); + } return JSValue::encode(socket); } diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocket.h b/src/jsc/bindings/node/JSNodeHTTPServerSocket.h index be9eff8308ee..3ac44eef7444 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocket.h +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocket.h @@ -10,32 +10,17 @@ #include extern "C" { -struct us_socket_stream_buffer_t { - char* list_ptr = nullptr; - size_t list_cap = 0; - size_t listLen = 0; - size_t total_bytes_written = 0; - size_t cursor = 0; - - size_t bufferedSize() const - { - return listLen - cursor; - } - size_t totalBytesWritten() const - { - return total_bytes_written; - } -}; - struct us_socket_t; +struct us_bun_verify_error_t; -void Bun__NodeHTTPResponse_takeBackConnection(void* zigResponse, JSC::EncodedJSValue jsValue, bool adopted); +void Bun__NodeHTTPResponse_takeBackConnection(void* zigResponse, JSC::EncodedJSValue jsValue); +size_t Bun__NodeHTTPResponse_bytesWritten(void* zigResponse); +void Bun__NodeHTTPSocket__addBytesWritten(void* handle, bool ssl, uint64_t bytes); } namespace uWS { template struct HttpResponseData; -struct WebSocketData; } namespace Bun { @@ -49,28 +34,15 @@ class JSNodeHTTPServerSocket : public JSC::JSDestructibleObject { using Base = JSC::JSDestructibleObject; static constexpr unsigned StructureFlags = Base::StructureFlags; - us_socket_stream_buffer_t streamBuffer = {}; us_socket_t* socket = nullptr; unsigned is_ssl : 1 = 0; + /* socket.end() was called. Later writes are dropped. */ unsigned ended : 1 = 0; - unsigned upgraded : 1 = 0; - unsigned peer_cert_verified : 1 = 0; - /* The JS Duplex of a tunnel is full: readStop() to readStart(). */ - unsigned tunnelReadsStopped : 1 = 0; - /* queuedTunnelBytes reached one recv buffer, until JS has those bytes. */ - unsigned tunnelReadsQueuedFull : 1 = 0; - /* onData() got the end of the stream. The task that tells JS can still be queued. */ - unsigned tunnelReadEnded : 1 = 0; - /* write() returned false for bytes that went into the uWS buffer, and JS waits for ondrain. streamBuffer does not show them. */ - unsigned heldWriteAwaitsDrain : 1 = 0; /* Set by onClose() for the peerEnded / closeError getters: the peer's FIN, the error of a failed read. */ unsigned peer_ended : 1 = 0; int closeReadError = 0; - /* Whether the connection closed before the kernel had taken everything, also after an upgrade to a WebSocket. */ - std::optional closedWithUnsentData; - /* Tunnel bytes that onData() queued for JS in tasks that have not run yet. */ - size_t queuedTunnelBytes = 0; - const char* peerCertVerifyErrorCode = nullptr; + /* req.socket._handle, a NewSocket in src/runtime/socket/node_http_socket.rs. This holds the connection's ref on it. */ + void* m_socketHandle = nullptr; JSC::Strong strongThis = {}; static JSNodeHTTPServerSocket* create(JSC::VM& vm, JSC::Structure* structure, us_socket_t* socket, bool is_ssl, JSNodeHTTPResponse* response); @@ -81,23 +53,8 @@ class JSNodeHTTPServerSocket : public JSC::JSDestructibleObject { static_cast(cell)->JSNodeHTTPServerSocket::~JSNodeHTTPServerSocket(); } - template - static void clearSocketData(bool upgraded, us_socket_t* socket); - void close(); bool isClosed() const; - bool isAuthorized() const; - - /* SNI hostname the client sent in its ClientHello; null when the socket is - * not TLS or the client sent no SNI. The pointer is owned by the SSL - * object — consume it before returning to the event loop. */ - const char* sniServername() const; - - /* X.509 verification error code for the peer certificate (e.g. - * "DEPTH_ZERO_SELF_SIGNED_CERT"), latched from the TLS handshake; null when - * verification succeeded or the socket is not TLS. */ - const char* peerCertificateVerificationError(); - bool isPeerCertificateVerified(); /* node:http server compat: whether the request currently being received on * this connection has exceeded server.headersTimeout / server.requestTimeout @@ -107,64 +64,61 @@ class JSNodeHTTPServerSocket : public JSC::JSDestructibleObject { /* node:http server compat - HTTP/1.1 pipelining. Responses for requests * that were parsed while an earlier response on this connection was still * in flight are queued here (in arrival order) and become the connection's - * current response one at a time via startPipelinedResponse(). On socket + * current response one at a time via startRawResponse(). On socket * close every queued response is notified just like the current one. */ void appendPipelinedResponse(JSC::VM& vm, JSNodeHTTPResponse* response); - /* Make a previously queued pipelined response the connection's current - * response: reset the per-response uWS state (the part the request handler - * normally resets per parsed request) and, when the queue drained, resume - * socket reads. Returns false when the connection is already gone. */ - bool startPipelinedResponse(JSC::VM& vm, JSNodeHTTPResponse* response, bool isAncient, bool connectionClose); + /* Makes a queued response the connection's current one, and resets the per-response state of uWS. JavaScript renders it and writes it with socket.write(), so its end() writes nothing: it only completes the response. Returns false if the connection is gone or didn't queue it. */ + bool startRawResponse(JSC::VM& vm, JSNodeHTTPResponse* response, bool connectionClose); /* The response that answers on this connection now. A close reaches it and the queued ones. */ JSNodeHTTPResponse* currentResponse() const { return m_currentResponse.get(); } /* A close does not reach the response that leaves the slot, so the connection is taken back from it first. */ void setCurrentResponse(JSC::VM& vm, JSNodeHTTPResponse* response) { if (auto* replaced = m_currentResponse.get(); replaced != nullptr && replaced != response) { - Bun__NodeHTTPResponse_takeBackConnection(replaced->wrapped(), JSC::JSValue::encode(replaced), false); + Bun__NodeHTTPResponse_takeBackConnection(replaced->wrapped(), JSC::JSValue::encode(replaced)); + if (m_socketHandle) { + Bun__NodeHTTPSocket__addBytesWritten(m_socketHandle, is_ssl, Bun__NodeHTTPResponse_bytesWritten(replaced->wrapped())); + } } m_currentResponse.set(vm, this, response); } /* Stop parsing further HTTP requests on this connection (Node frees the * parser when 'close' is emitted on the socket). */ void stopHTTPParsing(); + /* 'connect' or 'upgrade' has the socket, and responses ahead of that request are still owed: every read goes to the socket's handle, and only its own pause holds the reads. */ + void detachParser(); - /* socket.end(): true when uWS will shut down later, after the buffered response. destroySoon also waits for the body parse and closes behind the FIN. */ - bool shutdownAfterResponseDrains(bool destroySoon); + /* socket.end(): true when uWS will shut down later, after the buffered response. */ + bool shutdownAfterResponseDrains(); /* socket.end() with nothing left to send: the FIN goes out now and the reads stay armed, so that the peer's FIN closes the socket. */ - JSC::EncodedJSValue halfClose(JSC::JSGlobalObject*); + void halfClose(); + /* The FIN of socket.end() has left. It waits for bytes that are still queued. */ + bool finSent() const; - /* Close once the bytes of the responses that ended have left. close() discards them, end() waits for the peer. */ - void closeWhenDrained(); /* Closes the connection if uWS counts it as idle (HttpResponse::closeIfIdle). Returns whether it closed it. */ bool closeIfIdle(); - /* Switch the connection into CONNECT-style tunnel mode after an accepted - * Upgrade: subsequent bytes bypass the HTTP parser and stream to the - * ondata callback as opaque data. With afterBody, the switch is deferred - * until the request body has been fully parsed (Upgrade requests with a - * 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. */ - 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. */ - 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; + /* An accepted Upgrade request has a body. It's parsed and delivered through the request, and the connection leaves HTTP behind its last chunk. */ + void leaveHttpAfterBody(); + + /* The socket's handle gets the bytes of every read before the HTTP parser does (Node's parser.unconsume()). */ + void setRawReads(bool enabled); + void onRawRead(const char* data, int length); + void onReadParsed(); + bool onPeerEnded(); + /* Node's parser.finish(), for the socket's 'end' listener. Returns whether the stream ended inside a message. */ + bool finishParsing(); + void onUpgradeBodyEnded(); + void onHandshake(int success, us_bun_verify_error_t verifyError); + /* Hands the connection to the socket handle for good, for the request of the current response. Returns false if it's closed. */ + bool leaveHttp(bool restOfReadTaken); + /* The handle's 'close', after the HTTP side has heard of it. */ + void closeSocketHandle(bool runScript); + void halfCloseAfterDrain(); + /* The reads or the unsent bytes changed: tells the response in flight, whose request can be parked (NodeHTTPResponse::update_parked). */ + void updateParked(); /* Sends the response bytes that are not in the uWS buffer (the zero-copy tail of a res.write(), the cork buffer) to the kernel or into it. A raw write or a FIN then goes out behind them. */ void flushResponseBytesAhead(); - /* Only a tunnel gets the drain call that flushes streamBuffer. On any other socket the uWS buffer takes what the kernel does not. */ - bool flushesStreamBufferOnDrain() const { return !!functionToCallOnDrain; } - /* The WebSocket that adopted the connection reads from here on. */ - void releaseTunnelReadsForUpgrade(); - - /* Trailer fields received after the current request's chunked body, as a - * flat [name, value, ...] JS array preserving wire casing; jsUndefined() - * when there are none. Clears the captured section. */ - /* Set the trailer fields (pre-rendered "name: value\r\n" lines) to write * between the terminating 0 chunk and the final CRLF of the current * response's chunked body. */ @@ -175,10 +129,6 @@ class JSNodeHTTPServerSocket : public JSC::JSDestructibleObject { JSNodeHTTPServerSocket(JSC::VM& vm, JSC::Structure* structure, us_socket_t* socket, bool is_ssl, JSNodeHTTPResponse* response); mutable JSC::WriteBarrier functionToCallOnClose; - mutable JSC::WriteBarrier functionToCallOnDrain; - mutable JSC::WriteBarrier functionToCallOnData; - mutable JSC::WriteBarrier m_remoteAddress; - mutable JSC::WriteBarrier m_localAddress; mutable JSC::WriteBarrier m_duplex; /* Queued pipelined responses (see appendPipelinedResponse). The lock keeps @@ -200,15 +150,8 @@ class JSNodeHTTPServerSocket : public JSC::JSDestructibleObject { } void detach(); - void reset(); - void syncPeerCertificateVerification(); void onClose(int readError, bool peerEnded); - /* A WebSocket adopted the connection. `adopted` is its socket: the adoption can move it. */ - void onUpgraded(us_socket_t* adopted); void onDrain(); - void onData(const char* data, int length, bool last); - void applyTunnelReads(); - void didDeliverQueuedTunnelBytes(size_t length); static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject); void finishCreation(JSC::VM& vm); diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp index 1d73c52dd2e7..a7389fe11018 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp @@ -1,6 +1,5 @@ #include "JSNodeHTTPServerSocketPrototype.h" #include "JSNodeHTTPServerSocket.h" -#include "JSSocketAddressDTO.h" #include "AsyncContextFrame.h" #include "ZigGlobalObject.h" #include "helpers.h" @@ -8,10 +7,6 @@ #include #include -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" 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" JSC::EncodedJSValue Bun__socketReadErrorFromCloseCode(JSC::JSGlobalObject* globalObject, int code); namespace Bun { @@ -19,9 +14,7 @@ namespace Bun { using namespace JSC; using namespace WebCore; -// ondata / ondrain / onclose continue the Bun.ModuleGraph whose script set them (nothing, outside a -// graph: the socket's events arrive in nobody's async context, as in node). Reading one back gives -// the function that was set. +// onclose continues the Bun.ModuleGraph whose script set it, if one did. Reading it back gives the function that was set. static JSValue storedCallback(JSObject* stored) { if (auto* wrapper = dynamicDowncast(stored)) @@ -31,40 +24,25 @@ static JSValue storedCallback(JSObject* stored) // Declare custom getters/setters and host functions JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterOnClose); -JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterOnDrain); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterClosed); JSC_DECLARE_CUSTOM_SETTER(jsNodeHttpServerSocketSetterOnClose); -JSC_DECLARE_CUSTOM_SETTER(jsNodeHttpServerSocketSetterOnDrain); -JSC_DECLARE_CUSTOM_SETTER(jsNodeHttpServerSocketSetterOnData); -JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterOnData); -JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterBytesWritten); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketClose); -JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketReset); -JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketWrite); -JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketEnd); -JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketUpgradeToTunnel); -JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketReadStop); -JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketReadStart); +JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketLeaveHttpAfterBody); +JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketSetRawReads); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketSetResponseTrailers); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketIsRequestTimedOut); -JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketStartPipelinedResponse); +JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketStartRawResponse); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketStopParsing); -JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketCloseWhenDrained); +JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketFinishParsing); +JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketDetachParser); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketCloseIfIdle); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterResponse); -JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterRemoteAddress); -JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterLocalAddress); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterDuplex); JSC_DECLARE_CUSTOM_SETTER(jsNodeHttpServerSocketSetterDuplex); -JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterIsSecureEstablished); -JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterServername); -JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterAuthorizationError); -JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterPeerCertVerified); -JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterHasQueuedTunnelReads); -JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterClosedWithUnsentData); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterCloseError); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterPeerEnded); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterFinSent); +JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketLeaveHttp); JSC_DEFINE_CUSTOM_SETTER(noOpSetter, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue value, JSC::PropertyName propertyName)) { @@ -76,36 +54,23 @@ const JSC::ClassInfo JSNodeHTTPServerSocketPrototype::s_info = { "NodeHTTPServer static const JSC::HashTableValue JSNodeHTTPServerSocketPrototypeTableValues[] = { { "onclose"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterOnClose, jsNodeHttpServerSocketSetterOnClose } }, - { "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 } }, { "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 } }, - { "remoteAddress"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterRemoteAddress, noOpSetter } }, - { "localAddress"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterLocalAddress, noOpSetter } }, { "close"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketClose, 0 } }, - { "reset"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketReset, 0 } }, - { "write"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketWrite, 2 } }, - { "end"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketEnd, 0 } }, - { "upgradeToTunnel"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketUpgradeToTunnel, 0 } }, - { "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 } }, + { "leaveHttpAfterBody"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketLeaveHttpAfterBody, 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 } }, + { "startRawResponse"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketStartRawResponse, 2 } }, { "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 } }, + { "detachParser"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketDetachParser, 0 } }, + { "finishParsing"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketFinishParsing, 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 } }, - { "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 } }, - { "peerCertVerified"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterPeerCertVerified, noOpSetter } }, - { "hasQueuedTunnelReads"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterHasQueuedTunnelReads, noOpSetter } }, - { "closedWithUnsentData"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterClosedWithUnsentData, noOpSetter } }, { "closeError"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterCloseError, noOpSetter } }, { "peerEnded"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterPeerEnded, noOpSetter } }, { "finSent"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterFinSent, noOpSetter } }, + { "leaveHttp"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketLeaveHttp, 1 } }, }; void JSNodeHTTPServerSocketPrototype::finishCreation(JSC::VM& vm) @@ -131,42 +96,24 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketClose, (JSC::JSGlobalObje return JSValue::encode(JSC::jsUndefined()); } -JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketReset, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketLeaveHttp, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { auto* thisObject = dynamicDowncast(callFrame->thisValue()); - if (!thisObject) [[unlikely]] { - return JSValue::encode(JSC::jsUndefined()); - } - if (!thisObject->isClosed()) { - thisObject->reset(); - } - return JSValue::encode(JSC::jsUndefined()); -} - -JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketUpgradeToTunnel, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) -{ - auto* thisObject = dynamicDowncast(callFrame->thisValue()); - if (!thisObject) [[unlikely]] { - return JSValue::encode(JSC::jsUndefined()); - } - // upgradeToTunnel(afterBody, response): with a truthy afterBody the switch happens only - // once the request body has been fully parsed (Upgrade requests with a body). - thisObject->upgradeToTunnelMode(callFrame->argument(0).toBoolean(globalObject), dynamicDowncast(callFrame->argument(1))); - return JSValue::encode(JSC::jsUndefined()); + return JSValue::encode(JSC::jsBoolean(thisObject && thisObject->leaveHttp(callFrame->argument(0).isTrue()))); } -JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketReadStop, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketLeaveHttpAfterBody, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { if (auto* thisObject = dynamicDowncast(callFrame->thisValue())) [[likely]] { - thisObject->readStop(); + thisObject->leaveHttpAfterBody(); } return JSValue::encode(JSC::jsUndefined()); } -JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketReadStart, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketSetRawReads, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { if (auto* thisObject = dynamicDowncast(callFrame->thisValue())) [[likely]] { - thisObject->readStart(); + thisObject->setRawReads(callFrame->argument(0).isTrue()); } return JSValue::encode(JSC::jsUndefined()); } @@ -219,24 +166,15 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketIsRequestTimedOut, (JSC:: return JSValue::encode(JSC::jsBoolean(thisObject->isRequestTimedOut(headersTimeoutMs, requestTimeoutMs))); } -// node:http HTTP/1.1 pipelining: make a queued pipelined response the -// connection's current response right before its buffered output is flushed. -// Arguments: (responseHandle, isAncient, connectionClose). Returns false when -// the connection is already gone. -JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketStartPipelinedResponse, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +// (responseHandle, connectionClose). Returns false if the connection is gone or didn't queue that response. +JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketStartRawResponse, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { - auto& vm = globalObject->vm(); auto* thisObject = dynamicDowncast(callFrame->thisValue()); - if (!thisObject) [[unlikely]] { - return JSValue::encode(JSC::jsBoolean(false)); - } auto* response = dynamicDowncast(callFrame->argument(0)); - if (!response) [[unlikely]] { + if (!thisObject || !response) [[unlikely]] { return JSValue::encode(JSC::jsBoolean(false)); } - bool isAncient = callFrame->argument(1).toBoolean(globalObject); - bool connectionClose = callFrame->argument(2).toBoolean(globalObject); - return JSValue::encode(JSC::jsBoolean(thisObject->startPipelinedResponse(vm, response, isAncient, connectionClose))); + return JSValue::encode(JSC::jsBoolean(thisObject->startRawResponse(globalObject->vm(), response, callFrame->argument(1).toBoolean(globalObject)))); } // node:http: stop parsing further HTTP requests on this connection (the user @@ -250,141 +188,24 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketStopParsing, (JSC::JSGlob return JSValue::encode(JSC::jsUndefined()); } -JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketCloseWhenDrained, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketDetachParser, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { - auto* thisObject = dynamicDowncast(callFrame->thisValue()); - if (thisObject) [[likely]] { - thisObject->closeWhenDrained(); + if (auto* thisObject = dynamicDowncast(callFrame->thisValue())) [[likely]] { + thisObject->detachParser(); } return JSValue::encode(JSC::jsUndefined()); } -JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketCloseIfIdle, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketFinishParsing, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { auto* thisObject = dynamicDowncast(callFrame->thisValue()); - return JSValue::encode(JSC::jsBoolean(thisObject && thisObject->closeIfIdle())); + return JSValue::encode(JSC::jsBoolean(thisObject && thisObject->finishParsing())); } -JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketWrite, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) -{ - auto* thisObject = dynamicDowncast(callFrame->thisValue()); - if (!thisObject) [[unlikely]] { - return JSValue::encode(JSC::jsNumber(0)); - } - if (thisObject->isClosed() || thisObject->ended) { - return JSValue::encode(JSC::jsNumber(0)); - } - - thisObject->flushResponseBytesAhead(); - const bool hold = thisObject->hasUnsentResponseBytes(); - auto result = us_socket_buffered_js_write(thisObject->socket, thisObject->is_ssl, thisObject->ended, hold, thisObject->flushesStreamBufferOnDrain(), &thisObject->streamBuffer, globalObject, JSValue::encode(callFrame->argument(0)), JSValue::encode(callFrame->argument(1))); - // JS parks the write callback on false only when it has an ondrain (_write in _http_server.ts). - if (hold && thisObject->functionToCallOnDrain && JSValue::decode(result).isFalse()) { - thisObject->heldWriteAwaitsDrain = true; - } - thisObject->updateTunnelIdle(); - return result; -} - -JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketEnd, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketCloseIfIdle, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { auto* thisObject = dynamicDowncast(callFrame->thisValue()); - if (!thisObject) [[unlikely]] { - return JSValue::encode(JSC::jsUndefined()); - } - if (thisObject->isClosed()) { - return JSValue::encode(JSC::jsUndefined()); - } - - thisObject->ended = true; - // end(true) is Node's destroySoon() after a finished response: uWS waits for the body parse and the flush, then closes behind the FIN. - bool destroySoon = callFrame->argument(0).isTrue(); - if (thisObject->shutdownAfterResponseDrains(destroySoon)) { - return JSValue::encode(JSC::jsUndefined()); - } - auto bufferedSize = thisObject->streamBuffer.bufferedSize(); - if (destroySoon) { - // One flush for raw socket.write() bytes; the close drops the rest, as destroy() did. - if (bufferedSize == 0) { - us_socket_shutdown(thisObject->socket); - } else { - us_socket_buffered_js_write(thisObject->socket, thisObject->is_ssl, thisObject->ended, thisObject->hasUnsentResponseBytes(), thisObject->flushesStreamBufferOnDrain(), &thisObject->streamBuffer, globalObject, JSValue::encode(JSC::jsUndefined()), JSValue::encode(JSC::jsUndefined())); - } - thisObject->close(); - return JSValue::encode(JSC::jsUndefined()); - } - if (bufferedSize == 0) { - return thisObject->halfClose(globalObject); - } - return JSValue::encode(JSC::jsUndefined()); -} - -// Implementation of custom getters -JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterIsSecureEstablished, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) -{ - auto* thisObject = dynamicDowncast(JSC::JSValue::decode(thisValue)); - if (!thisObject) [[unlikely]] { - return JSValue::encode(JSC::jsUndefined()); - } - return JSValue::encode(JSC::jsBoolean(thisObject->isAuthorized())); -} - -// SNI hostname the client sent, as a string; null when not TLS / no SNI. -JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterServername, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) -{ - auto* thisObject = dynamicDowncast(JSC::JSValue::decode(thisValue)); - if (!thisObject) [[unlikely]] { - return JSValue::encode(JSC::jsUndefined()); - } - const char* servername = thisObject->sniServername(); - if (!servername || !*servername) { - return JSValue::encode(JSC::jsNull()); - } - return JSValue::encode(JSC::jsString(globalObject->vm(), WTF::String::fromUTF8(servername))); -} - -// X.509 verification error code for the peer certificate; null when verified -// (or when there is nothing to verify). -JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterAuthorizationError, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) -{ - auto* thisObject = dynamicDowncast(JSC::JSValue::decode(thisValue)); - if (!thisObject) [[unlikely]] { - return JSValue::encode(JSC::jsUndefined()); - } - const char* code = thisObject->peerCertificateVerificationError(); - if (!code) { - return JSValue::encode(JSC::jsNull()); - } - return JSValue::encode(JSC::jsString(globalObject->vm(), WTF::String::fromLatin1(code))); -} - -JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterHasQueuedTunnelReads, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) -{ - auto* thisObject = dynamicDowncast(JSC::JSValue::decode(thisValue)); - if (!thisObject) [[unlikely]] { - return JSValue::encode(JSC::jsUndefined()); - } - // The peer's FIN is a queued chunk of length 0. Once JS has it, the Duplex isn't readable and nobody asks. - return JSValue::encode(JSC::jsBoolean(thisObject->queuedTunnelBytes > 0 || thisObject->tunnelReadEnded)); -} - -// undefined until the connection has closed. -JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterClosedWithUnsentData, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) -{ - auto* thisObject = dynamicDowncast(JSC::JSValue::decode(thisValue)); - if (!thisObject || !thisObject->closedWithUnsentData) [[unlikely]] { - return JSValue::encode(JSC::jsUndefined()); - } - return JSValue::encode(JSC::jsBoolean(*thisObject->closedWithUnsentData)); -} - -JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterPeerCertVerified, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) -{ - auto* thisObject = dynamicDowncast(JSC::JSValue::decode(thisValue)); - if (!thisObject) [[unlikely]] { - return JSValue::encode(JSC::jsUndefined()); - } - return JSValue::encode(JSC::jsBoolean(thisObject->isPeerCertificateVerified())); + return JSValue::encode(JSC::jsBoolean(thisObject && thisObject->closeIfIdle())); } // The error of the read that closed the connection (`read ECONNRESET` for a peer RST), or undefined. @@ -413,7 +234,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterFinSent, (JSC::JSGlobalObje if (!thisObject) [[unlikely]] { return JSValue::encode(JSC::jsUndefined()); } - return JSValue::encode(JSC::jsBoolean(thisObject->ended && !thisObject->isClosed() && us_socket_is_shut_down(thisObject->socket))); + return JSValue::encode(JSC::jsBoolean(thisObject->finSent())); } JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterDuplex, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) @@ -445,78 +266,6 @@ JSC_DEFINE_CUSTOM_SETTER(jsNodeHttpServerSocketSetterDuplex, (JSC::JSGlobalObjec return true; } -JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterRemoteAddress, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) -{ - auto& vm = globalObject->vm(); - auto* thisObject = dynamicDowncast(JSC::JSValue::decode(thisValue)); - if (!thisObject) [[unlikely]] { - return JSValue::encode(JSC::jsUndefined()); - } - if (thisObject->m_remoteAddress) { - return JSValue::encode(thisObject->m_remoteAddress.get()); - } - - us_socket_t* socket = thisObject->socket; - if (!socket) { - return JSValue::encode(JSC::jsNull()); - } - - const char* address = nullptr; - int port = 0; - bool is_ipv6 = false; - - uws_res_get_remote_address_info(socket, &address, &port, &is_ipv6); - - if (address == nullptr) { - return JSValue::encode(JSC::jsNull()); - } - - auto addressString = WTF::String::fromUTF8(address); - if (addressString.isEmpty()) { - return JSValue::encode(JSC::jsNull()); - } - - auto* object = JSSocketAddressDTO::create(defaultGlobalObject(globalObject), jsString(vm, addressString), port, is_ipv6); - thisObject->m_remoteAddress.set(vm, thisObject, object); - return JSValue::encode(object); -} - -JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterLocalAddress, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) -{ - auto& vm = globalObject->vm(); - auto* thisObject = dynamicDowncast(JSC::JSValue::decode(thisValue)); - if (!thisObject) [[unlikely]] { - return JSValue::encode(JSC::jsUndefined()); - } - if (thisObject->m_localAddress) { - return JSValue::encode(thisObject->m_localAddress.get()); - } - - us_socket_t* socket = thisObject->socket; - if (!socket) { - return JSValue::encode(JSC::jsNull()); - } - - const char* address = nullptr; - int port = 0; - bool is_ipv6 = false; - - uws_res_get_local_address_info(socket, &address, &port, &is_ipv6); - - if (address == nullptr) { - return JSValue::encode(JSC::jsNull()); - } - - auto addressString = WTF::String::fromUTF8(address); - if (addressString.isEmpty()) { - return JSValue::encode(JSC::jsNull()); - } - - auto* object = JSSocketAddressDTO::create(defaultGlobalObject(globalObject), jsString(vm, addressString), port, is_ipv6); - thisObject->m_localAddress.set(vm, thisObject, object); - return JSValue::encode(object); -} - JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterOnClose, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) { auto* thisObject = dynamicDowncast(JSC::JSValue::decode(thisValue)); @@ -531,82 +280,6 @@ JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterOnClose, (JSC::JSGlobalObje return JSValue::encode(JSC::jsUndefined()); } -JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterOnDrain, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) -{ - auto* thisObject = dynamicDowncast(JSC::JSValue::decode(thisValue)); - if (!thisObject) [[unlikely]] { - return JSValue::encode(JSC::jsUndefined()); - } - - if (thisObject->functionToCallOnDrain) { - return JSValue::encode(storedCallback(thisObject->functionToCallOnDrain.get())); - } - - return JSValue::encode(JSC::jsUndefined()); -} - -JSC_DEFINE_CUSTOM_SETTER(jsNodeHttpServerSocketSetterOnDrain, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue encodedValue, JSC::PropertyName propertyName)) -{ - auto& vm = globalObject->vm(); - auto scope = DECLARE_THROW_SCOPE(vm); - - auto* thisObject = dynamicDowncast(JSC::JSValue::decode(thisValue)); - if (!thisObject) [[unlikely]] { - return false; - } - JSValue value = JSC::JSValue::decode(encodedValue); - - if (value.isUndefined() || value.isNull()) { - thisObject->functionToCallOnDrain.clear(); - return true; - } - - if (!value.isCallable()) { - return false; - } - - thisObject->functionToCallOnDrain.set(vm, thisObject, AsyncContextFrame::withGraphContextIfNeeded(globalObject, value).getObject()); - return true; -} - -JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterOnData, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) -{ - auto* thisObject = dynamicDowncast(JSC::JSValue::decode(thisValue)); - if (!thisObject) [[unlikely]] { - return JSValue::encode(JSC::jsUndefined()); - } - - if (thisObject->functionToCallOnData) { - return JSValue::encode(storedCallback(thisObject->functionToCallOnData.get())); - } - - return JSValue::encode(JSC::jsUndefined()); -} - -JSC_DEFINE_CUSTOM_SETTER(jsNodeHttpServerSocketSetterOnData, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue encodedValue, JSC::PropertyName propertyName)) -{ - auto& vm = globalObject->vm(); - auto scope = DECLARE_THROW_SCOPE(vm); - - auto* thisObject = dynamicDowncast(JSC::JSValue::decode(thisValue)); - if (!thisObject) [[unlikely]] { - return false; - } - JSValue value = JSC::JSValue::decode(encodedValue); - - if (value.isUndefined() || value.isNull()) { - thisObject->functionToCallOnData.clear(); - return true; - } - - if (!value.isCallable()) { - return false; - } - - thisObject->functionToCallOnData.set(vm, thisObject, AsyncContextFrame::withGraphContextIfNeeded(globalObject, value).getObject()); - return true; -} - JSC_DEFINE_CUSTOM_SETTER(jsNodeHttpServerSocketSetterOnClose, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue encodedValue, JSC::PropertyName propertyName)) { auto& vm = globalObject->vm(); @@ -640,16 +313,6 @@ JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterClosed, (JSC::JSGlobalObjec return JSValue::encode(JSC::jsBoolean(thisObject->isClosed())); } -JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterBytesWritten, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName propertyName)) -{ - auto* thisObject = dynamicDowncast(JSC::JSValue::decode(thisValue)); - if (!thisObject) [[unlikely]] { - return JSValue::encode(JSC::jsUndefined()); - } - // Like Node's bytesWritten, this counts bytes still buffered as well as bytes already sent. - return JSValue::encode(JSC::jsNumber(thisObject->streamBuffer.totalBytesWritten() + thisObject->streamBuffer.bufferedSize())); -} - 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/JSConnectionsList.cpp b/src/jsc/bindings/node/http/JSConnectionsList.cpp index ac5d5b6a43d2..f3cc975b6682 100644 --- a/src/jsc/bindings/node/http/JSConnectionsList.cpp +++ b/src/jsc/bindings/node/http/JSConnectionsList.cpp @@ -98,7 +98,7 @@ JSArray* JSConnectionsList::idle(JSGlobalObject* globalObject) continue; } - if (parser->impl()->lastMessageStart() == 0) { + if (parser->impl()->lastMessageStart() == 0 || !parser->impl()->receivedData()) { result->putDirectIndex(globalObject, i++, parser); RETURN_IF_EXCEPTION(scope, nullptr); } 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/node/http/NodeHTTPParser.cpp b/src/jsc/bindings/node/http/NodeHTTPParser.cpp index 1ae83eece8b5..d47b2e83cc11 100644 --- a/src/jsc/bindings/node/http/NodeHTTPParser.cpp +++ b/src/jsc/bindings/node/http/NodeHTTPParser.cpp @@ -257,6 +257,8 @@ JSValue HTTPParser::remove(JSGlobalObject* globalObject, JSCell* thisParser) RETURN_IF_EXCEPTION(scope, {}); connections->popActive(globalObject, thisParser); RETURN_IF_EXCEPTION(scope, {}); + // remove() can run inside execute(), and the callbacks for the rest of the buffer would add the parser again. + m_connectionsList.clear(); } return jsUndefined(); @@ -271,6 +273,7 @@ JSValue HTTPParser::initialize(JSGlobalObject* globalObject, JSCell* thisParser, if (connections) { m_connectionsList.set(vm, thisParser, connections); + m_receivedData = false; // This protects from a DoS attack where an attacker establishes // the connection without sending any data on applications where @@ -355,6 +358,7 @@ int HTTPParser::onMessageBegin() m_numValues = 0; m_headersCompleted = false; m_chunkExtensionsNread = 0; + m_receivedData = true; m_lastMessageStart = uv_hrtime(); m_url.reset(); m_statusMessage.reset(); @@ -367,7 +371,7 @@ int HTTPParser::onMessageBegin() RETURN_IF_EXCEPTION(scope, stopForPendingException()); } - JSValue onMessageBeginCallback = thisParser->get(globalObject, Identifier::from(vm, kOnMessageBegin)); + JSValue onMessageBeginCallback = thisParser->get(globalObject, kOnMessageBegin); RETURN_IF_EXCEPTION(scope, stopForPendingException()); if (onMessageBeginCallback.isCallable()) { CallData callData = getCallData(onMessageBeginCallback); @@ -513,7 +517,7 @@ int HTTPParser::onHeadersComplete() } }); - JSValue onHeadersCompleteCallback = thisParser->get(globalObject, Identifier::from(vm, kOnHeadersComplete)); + JSValue onHeadersCompleteCallback = thisParser->get(globalObject, kOnHeadersComplete); RETURN_IF_EXCEPTION(scope, stopForPendingException()); if (!onHeadersCompleteCallback.isCallable()) { @@ -576,7 +580,7 @@ int HTTPParser::onBody(const char* at, size_t length) auto& vm = lexicalGlobalObject->vm(); auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - JSValue onBodyCallback = m_thisParser->get(lexicalGlobalObject, Identifier::from(vm, kOnBody)); + JSValue onBodyCallback = m_thisParser->get(lexicalGlobalObject, kOnBody); RETURN_IF_EXCEPTION(scope, stopForPendingException()); if (!onBodyCallback.isCallable()) { return 0; @@ -625,7 +629,7 @@ int HTTPParser::onMessageComplete() m_headerPairs = 0; - JSValue onMessageCompleteCallback = thisParser->get(globalObject, Identifier::from(vm, kOnMessageComplete)); + JSValue onMessageCompleteCallback = thisParser->get(globalObject, kOnMessageComplete); RETURN_IF_EXCEPTION(scope, stopForPendingException()); if (!onMessageCompleteCallback.isCallable()) { @@ -700,7 +704,7 @@ void HTTPParser::flush() JSHTTPParser* thisParser = m_thisParser; - JSValue onHeadersCallback = thisParser->get(globalObject, Identifier::from(vm, kOnHeaders)); + JSValue onHeadersCallback = thisParser->get(globalObject, kOnHeaders); RETURN_IF_EXCEPTION(scope, ); if (!onHeadersCallback.isCallable()) { diff --git a/src/jsc/bindings/node/http/NodeHTTPParser.h b/src/jsc/bindings/node/http/NodeHTTPParser.h index e482685333f2..2ef83aa2ca26 100644 --- a/src/jsc/bindings/node/http/NodeHTTPParser.h +++ b/src/jsc/bindings/node/http/NodeHTTPParser.h @@ -97,7 +97,6 @@ struct StringPtr { size_t m_size; }; -#define HTTP_BOTH 0 #define HTTP_REQUEST 1 #define HTTP_RESPONSE 2 @@ -172,6 +171,7 @@ struct HTTPParser { inline bool headersCompleted() const { return m_headersCompleted; } inline uint64_t lastMessageStart() const { return m_lastMessageStart; } + inline bool receivedData() const { return m_receivedData; } JSC::WriteBarrier m_connectionsList; @@ -201,6 +201,7 @@ struct HTTPParser { // Read once per header section; -1 until then. double m_maxHeaderPairs = -1; bool m_pendingPause = false; + bool m_receivedData = false; // Set while execute() is running llhttp over a buffer. Owned exclusively // by execute(); finish() must never clear it. bool m_inExecute = false; 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.cpp b/src/jsc/bindings/webcore/FetchHeaders.cpp index 663177e2e409..01958f9733d0 100644 --- a/src/jsc/bindings/webcore/FetchHeaders.cpp +++ b/src/jsc/bindings/webcore/FetchHeaders.cpp @@ -40,12 +40,6 @@ namespace WebCore { DEFINE_ALLOCATOR_WITH_HEAP_IDENTIFIER(FetchHeaders); -// https://fetch.spec.whatwg.org/#concept-headers-remove-privileged-no-cors-request-headers -static void removePrivilegedNoCORSRequestHeaders(HTTPHeaderMap& headers) -{ - headers.remove(HTTPHeaderName::Range); -} - // String::trim takes a function pointer and dispatches through // StringImpl::trimMatchedCharacters, which is an indirect call per probed // character. Header values are almost always already free of leading/trailing @@ -70,29 +64,25 @@ static inline const String& trimHTTPSpaceIfNeeded(const String& value, String& s return storage; } -static ExceptionOr canWriteHeader(const HTTPHeaderName name, const String& value, const String& combinedValue, FetchHeaders::Guard guard) +static ExceptionOr validateHeader(const HTTPHeaderName name, const String& value) { ASSERT(value.isEmpty() || (!isHTTPSpace(value[0]) && !isHTTPSpace(value[value.length() - 1]))); if (!isValidHTTPHeaderValue((value))) return Exception { TypeError, makeString("Header '"_s, httpHeaderNameString(name), "' has invalid value"_s) }; - if (guard == FetchHeaders::Guard::Immutable) - return Exception { TypeError, "Headers object's guard is 'immutable'"_s }; - return true; + return {}; } -static ExceptionOr canWriteHeader(const String& name, const String& value, const String& combinedValue, FetchHeaders::Guard guard) +static ExceptionOr validateHeader(const String& name, const String& value) { if (!isValidHTTPToken(name)) return exceptionWithMessage(TypeError, "Invalid header name: '"_s, name, "'"_s); ASSERT(value.isEmpty() || (!isHTTPSpace(value[0]) && !isHTTPSpace(value[value.length() - 1]))); if (!isValidHTTPHeaderValue((value))) return exceptionWithMessage(TypeError, "Header '"_s, name, "' has invalid value"_s); - if (guard == FetchHeaders::Guard::Immutable) - return Exception { TypeError, "Headers object's guard is 'immutable'"_s }; - return true; + return {}; } -static ExceptionOr appendToHeaderMap(const String& name, const String& value, HTTPHeaderMap& headers, FetchHeaders::Guard guard) +static ExceptionOr appendToHeaderMap(const String& name, const String& value, HTTPHeaderMap& headers) { // The common path here is a brand-new header with no leading/trailing HTTP // whitespace. Avoid taking ownership (and the atomic ref-count round-trip @@ -120,12 +110,9 @@ static ExceptionOr appendToHeaderMap(const String& name, const String& val } } - auto canWriteResult = canWriteHeader(headerName, normalizedValue, *valueToSet, guard); - - if (canWriteResult.hasException()) - return canWriteResult.releaseException(); - if (!canWriteResult.releaseReturnValue()) - return {}; + auto valid = validateHeader(headerName, normalizedValue); + if (valid.hasException()) + return valid.releaseException(); if (headerName != HTTPHeaderName::SetCookie) { if (!headers.setIndex(index, *valueToSet)) @@ -143,30 +130,23 @@ static ExceptionOr appendToHeaderMap(const String& name, const String& val return Exception { OutOfMemoryError }; valueToSet = &combinedTemp; } - auto canWriteResult = canWriteHeader(name, normalizedValue, *valueToSet, guard); - if (canWriteResult.hasException()) - return canWriteResult.releaseException(); - if (!canWriteResult.releaseReturnValue()) - return {}; + auto valid = validateHeader(name, normalizedValue); + if (valid.hasException()) + return valid.releaseException(); if (!headers.setIndex(index, *valueToSet)) headers.set(name, *valueToSet); - // if (guard == FetchHeaders::Guard::RequestNoCors) - // removePrivilegedNoCORSRequestHeaders(headers); - return {}; } -static ExceptionOr appendToHeaderMap(const HTTPHeaderMap::HTTPHeaderMapConstIterator::KeyValue& header, HTTPHeaderMap& headers, FetchHeaders::Guard guard) +static ExceptionOr appendToHeaderMap(const HTTPHeaderMap::HTTPHeaderMapConstIterator::KeyValue& header, HTTPHeaderMap& headers) { String trimStorage; const String& normalizedValue = trimHTTPSpaceIfNeeded(header.value, trimStorage); - auto canWriteResult = canWriteHeader(header.key, normalizedValue, header.value, guard); - if (canWriteResult.hasException()) - return canWriteResult.releaseException(); - if (!canWriteResult.releaseReturnValue()) - return {}; + auto valid = validateHeader(header.key, normalizedValue); + if (valid.hasException()) + return valid.releaseException(); if (header.keyAsHTTPHeaderName) headers.add(header.keyAsHTTPHeaderName.value(), header.value); else @@ -176,21 +156,21 @@ static ExceptionOr appendToHeaderMap(const HTTPHeaderMap::HTTPHeaderMapCon } // https://fetch.spec.whatwg.org/#concept-headers-fill -static ExceptionOr fillHeaderMap(HTTPHeaderMap& headers, const FetchHeaders::Init& headersInit, FetchHeaders::Guard guard) +static ExceptionOr fillHeaderMap(HTTPHeaderMap& headers, const FetchHeaders::Init& headersInit) { if (std::holds_alternative>>(headersInit)) { auto& sequence = std::get>>(headersInit); for (auto& header : sequence) { if (header.size() != 2) return Exception { TypeError, "Header sub-sequence must contain exactly two items"_s }; - auto result = appendToHeaderMap(header[0], header[1], headers, guard); + auto result = appendToHeaderMap(header[0], header[1], headers); if (result.hasException()) return result.releaseException(); } } else { auto& record = std::get>>(headersInit); for (auto& header : record) { - auto result = appendToHeaderMap(header.key, header.value, headers, guard); + auto result = appendToHeaderMap(header.key, header.value, headers); if (result.hasException()) return result.releaseException(); } @@ -204,17 +184,17 @@ ExceptionOr> FetchHeaders::create(std::optional&& header HTTPHeaderMap headers; if (headersInit) { - auto result = fillHeaderMap(headers, *headersInit, Guard::None); + auto result = fillHeaderMap(headers, *headersInit); if (result.hasException()) return result.releaseException(); } - return adoptRef(*new FetchHeaders { Guard::None, WTF::move(headers) }); + return adoptRef(*new FetchHeaders { WTF::move(headers) }); } ExceptionOr FetchHeaders::fill(const Init& headerInit) { - return fillHeaderMap(m_headers.willChange(), headerInit, m_guard); + return fillHeaderMap(m_headers.willChange(), headerInit); } ExceptionOr FetchHeaders::fill(const FetchHeaders& otherHeaders) @@ -229,7 +209,7 @@ ExceptionOr FetchHeaders::fill(const FetchHeaders& otherHeaders) } for (auto& header : otherHeaders.internalHeaders()) { - auto result = appendToHeaderMap(header, m_headers.willChange(), m_guard); + auto result = appendToHeaderMap(header, m_headers.willChange()); if (result.hasException()) return result.releaseException(); } @@ -239,14 +219,12 @@ ExceptionOr FetchHeaders::fill(const FetchHeaders& otherHeaders) ExceptionOr FetchHeaders::append(const String& name, const String& value) { - return appendToHeaderMap(name, value, m_headers.willChange(), m_guard); + return appendToHeaderMap(name, value, m_headers.willChange()); } // https://fetch.spec.whatwg.org/#dom-headers-delete ExceptionOr FetchHeaders::remove(const StringView name) { - ASSERT_WITH_MESSAGE(m_guard == FetchHeaders::Guard::None, "We don't use guards in Bun"); - HTTPHeaderName headerName; if (findHTTPHeaderName(name, headerName)) { m_headers.willChange().remove(headerName); @@ -299,34 +277,24 @@ ExceptionOr FetchHeaders::has(const StringView name) const ExceptionOr FetchHeaders::set(const HTTPHeaderName name, const String& value) { String normalizedValue = trimHTTPSpaceIfNeeded(value); - auto canWriteResult = canWriteHeader(name, normalizedValue, normalizedValue, m_guard); - if (canWriteResult.hasException()) - return canWriteResult.releaseException(); - if (!canWriteResult.releaseReturnValue()) - return {}; + auto valid = validateHeader(name, normalizedValue); + if (valid.hasException()) + return valid.releaseException(); m_headers.willChange().set(name, normalizedValue); - if (m_guard == FetchHeaders::Guard::RequestNoCors) - removePrivilegedNoCORSRequestHeaders(m_headers.willChange()); - return {}; } ExceptionOr FetchHeaders::set(const String& name, const String& value) { String normalizedValue = trimHTTPSpaceIfNeeded(value); - auto canWriteResult = canWriteHeader(name, normalizedValue, normalizedValue, m_guard); - if (canWriteResult.hasException()) - return canWriteResult.releaseException(); - if (!canWriteResult.releaseReturnValue()) - return {}; + auto valid = validateHeader(name, normalizedValue); + if (valid.hasException()) + return valid.releaseException(); m_headers.willChange().set(name, normalizedValue); - if (m_guard == FetchHeaders::Guard::RequestNoCors) - removePrivilegedNoCORSRequestHeaders(m_headers.willChange()); - return {}; } diff --git a/src/jsc/bindings/webcore/FetchHeaders.h b/src/jsc/bindings/webcore/FetchHeaders.h index 4d8c1267fc61..cf376c0167d5 100644 --- a/src/jsc/bindings/webcore/FetchHeaders.h +++ b/src/jsc/bindings/webcore/FetchHeaders.h @@ -44,18 +44,9 @@ class FetchHeaders : public RefCounted { WTF_DEPRECATED_MAKE_FAST_ALLOCATED_WITH_HEAP_IDENTIFIER(FetchHeaders, FetchHeaders); public: - enum class Guard : uint8_t { - None, - Immutable, - Request, - RequestNoCors, - Response - }; - using Init = std::variant>, Vector>>; static ExceptionOr> create(std::optional&&); - static Ref create(Guard guard = Guard::None, HTTPHeaderMap&& headers = {}) { return adoptRef(*new FetchHeaders { guard, WTF::move(headers) }); } static Ref create(const FetchHeaders& headers) { return adoptRef(*new FetchHeaders { headers }); } ExceptionOr append(const String& name, const String& value); @@ -130,10 +121,7 @@ class FetchHeaders : public RefCounted { void setInternalHeaders(HTTPHeaderMap&& headers) { m_headers.willChange() = WTF::move(headers); } const HTTPHeaderMap& internalHeaders() const { return m_headers.get(); } - void setGuard(Guard); - Guard guard() const { return m_guard; } - - FetchHeaders(Guard, HTTPHeaderMap&&); + explicit FetchHeaders(HTTPHeaderMap&& = {}); explicit FetchHeaders(const FetchHeaders&); private: @@ -158,43 +146,20 @@ class FetchHeaders : public RefCounted { uint64_t m_updateCounter { 0 }; }; - Guard m_guard; // 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; }; -inline FetchHeaders::FetchHeaders(Guard guard, HTTPHeaderMap&& headers) - : m_guard(guard) - , m_headers(WTF::move(headers)) +inline FetchHeaders::FetchHeaders(HTTPHeaderMap&& headers) + : m_headers(WTF::move(headers)) { } inline FetchHeaders::FetchHeaders(const FetchHeaders& other) : RefCounted() - , m_guard(other.m_guard) , m_headers(other.internalHeaders()) { } -inline void FetchHeaders::setGuard(Guard guard) -{ - ASSERT(!internalHeaders().size()); - m_guard = guard; -} - } // namespace WebCore - -namespace WTF { - -template<> struct EnumTraits { - using values = EnumValues< - WebCore::FetchHeaders::Guard, - WebCore::FetchHeaders::Guard::None, - WebCore::FetchHeaders::Guard::Immutable, - WebCore::FetchHeaders::Guard::Request, - WebCore::FetchHeaders::Guard::RequestNoCors, - WebCore::FetchHeaders::Guard::Response>; -}; - -} diff --git a/src/jsc/bindings/webcore/JSFetchHeaders.cpp b/src/jsc/bindings/webcore/JSFetchHeaders.cpp index 0a2fa9c39cdc..ca696c2ca7dd 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" @@ -134,15 +135,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())); } @@ -192,14 +195,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); @@ -258,6 +257,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); @@ -284,15 +288,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 */ @@ -755,4 +758,35 @@ 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)); } diff --git a/src/jsc/bindings/webcore/JSFetchHeaders.h b/src/jsc/bindings/webcore/JSFetchHeaders.h index aa53ddaabd6f..43914c7f645e 100644 --- a/src/jsc/bindings/webcore/JSFetchHeaders.h +++ b/src/jsc/bindings/webcore/JSFetchHeaders.h @@ -39,6 +39,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 b0d401185b3c..b159416a334b 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 }); } @@ -963,12 +963,6 @@ WebSocket* JSWebSocket::toWrapped(JSC::VM&, JSC::JSValue value) return nullptr; } -// https://github.com/oven-sh/bun/issues/11866 -JSC::JSValue getWebSocketConstructor(Zig::GlobalObject* globalObject) -{ - return WebCore::JSWebSocket::getConstructor(globalObject->vm(), globalObject); -} - // Parses the `ws` module options shared by createWebSocket() and createWebSocketOverStream(). static void convertConnectedOptionsForWs(JSGlobalObject* lexicalGlobalObject, JSObject* object, WebSocketConnectedOptions& options) { diff --git a/src/jsc/bindings/webcore/JSWebSocket.h b/src/jsc/bindings/webcore/JSWebSocket.h index 97badf5b4c65..f4782ee7da9f 100644 --- a/src/jsc/bindings/webcore/JSWebSocket.h +++ b/src/jsc/bindings/webcore/JSWebSocket.h @@ -97,7 +97,6 @@ template<> struct JSDOMWrapperConverterTraits { using ToWrappedReturnType = WebSocket*; }; -JSC::JSValue getWebSocketConstructor(Zig::GlobalObject* globalObject); // Native binding for the built-in `ws` module: // { createWebSocket, createWebSocketOverStream, sendFrame, setRef, detach, remoteAddress, feed, streamClosed } JSC::JSValue createWebSocketBindingForWs(Zig::GlobalObject* globalObject); 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 bacc19d0f128..854f4eaff5c2 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp @@ -238,8 +238,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)); @@ -729,8 +728,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); @@ -861,13 +859,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, {}); @@ -916,7 +914,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)); } @@ -932,7 +930,7 @@ JSValue tryUseReadableStreamBufferedFastPath(JSGlobalObject* globalObject, WebCo JSObject* handle = nativePtr.getObject(); if (!handle) return {}; - if (stream->m_disturbed) + if (!stream->hasPendingNativeSource()) return {}; JSValue methodValue = handle->get(globalObject, method); RETURN_IF_EXCEPTION(scope, {}); @@ -1003,7 +1001,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)); @@ -1172,7 +1170,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); @@ -1180,7 +1178,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 @@ -1259,7 +1257,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)); } @@ -1276,7 +1274,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)); } @@ -1325,7 +1323,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]] { @@ -1338,12 +1336,11 @@ JSValue readableStreamToBlob(JSGlobalObject* globalObject, WebCore::JSReadableSt return derived; } -JSValue readableStreamToFormData(JSGlobalObject* globalObject, WebCore::JSReadableStream* stream, JSValue contentType) +// readableStreamToBlob(stream).then(blob => onFulfilled(blob, context)) +static JSValue readableStreamToBlobThen(JSGlobalObject* globalObject, WebCore::JSReadableStream* stream, JSFunction* onFulfilled, JSValue context) { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - if (auto* error = unusableStreamError(globalObject, stream)) - RELEASE_AND_RETURN(scope, promiseRejectedWith(globalObject, error)); JSValue blobResult = readableStreamToBlob(globalObject, stream); RETURN_IF_EXCEPTION(scope, {}); auto* blobPromise = dynamicDowncast(blobResult); @@ -1351,12 +1348,22 @@ JSValue readableStreamToFormData(JSGlobalObject* globalObject, WebCore::JSReadab blobPromise = promiseFulfilledWith(globalObject, blobResult); RETURN_IF_EXCEPTION(scope, {}); } - auto* runtime = JSStreamsRuntime::from(globalObject); auto* derived = JSPromise::create(vm, globalObject->promiseStructure()); - blobPromise->performPromiseThenWithContext(vm, globalObject, runtime->onReadableStreamToFormDataFulfilled(), jsUndefined(), derived, contentType); + blobPromise->performPromiseThenWithContext(vm, globalObject, onFulfilled, jsUndefined(), derived, context); return derived; } +JSValue readableStreamToFormData(JSGlobalObject* globalObject, WebCore::JSReadableStream* stream, JSValue contentType) +{ + return readableStreamToBlobThen(globalObject, stream, JSStreamsRuntime::from(globalObject)->onReadableStreamToFormDataFulfilled(), contentType); +} + +JSValue readableStreamToBodyBlob(JSGlobalObject* globalObject, WebCore::JSReadableStream* stream, JSValue contentType) +{ + auto* context = InternalFieldTuple::create(getVM(globalObject), defaultGlobalObject(globalObject)->internalFieldTupleStructure(), stream, contentType); + return readableStreamToBlobThen(globalObject, stream, JSStreamsRuntime::from(globalObject)->onReadableStreamToBodyBlobFulfilled(), context); +} + } // namespace WebStreams } // namespace Bun @@ -1386,7 +1393,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)) @@ -1446,11 +1453,15 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionReadableStreamToFormData, (JSGlobalObject * g JSC_DEFINE_HOST_FUNCTION(jsFunctionTransferToNativeReadableStream, (JSGlobalObject*, CallFrame* callFrame)) { - if (auto* stream = dynamicDowncast(callFrame->argument(0))) { - stream->m_transferred = true; - stream->m_disturbed = true; - } - return JSValue::encode(jsUndefined()); + auto* stream = dynamicDowncast(callFrame->argument(0)); + if (!stream || !stream->hasPendingNativeSource()) + return JSValue::encode(jsUndefined()); + JSValue handle = stream->nativePtrForJS(); + if (!handle || !handle.isObject()) + return JSValue::encode(jsUndefined()); + stream->m_transferred = true; + stream->m_disturbed = true; + return JSValue::encode(handle); } // [reaction-convention] handlers (FOR_EACH_WEB_STREAMS_REACTION_HANDLER_BUN_CONSUMERS). @@ -1541,6 +1552,19 @@ JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onReadableStreamToBlobFulfilled, (J return JSValue::encode(blob); } +extern "C" void Body__finishBlobOfStream(JSGlobalObject*, EncodedJSValue blob, EncodedJSValue contentType, uint8_t facts); + +JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onReadableStreamToBodyBlobFulfilled, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* context = uncheckedDowncast(callFrame->uncheckedArgument(1)); + auto* stream = uncheckedDowncast(context->getInternalField(0)); + Body__finishBlobOfStream(globalObject, JSValue::encode(callFrame->argument(0)), JSValue::encode(context->getInternalField(1)), stream->m_bodyFacts); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(callFrame->argument(0)); +} + JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onReadableStreamToFormDataFulfilled, (JSGlobalObject * globalObject, CallFrame* callFrame)) { auto& vm = getVM(globalObject); diff --git a/src/jsc/bindings/webcore/streams/BunStreamConsumers.h b/src/jsc/bindings/webcore/streams/BunStreamConsumers.h index d1d21017032a..775bce12c677 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamConsumers.h +++ b/src/jsc/bindings/webcore/streams/BunStreamConsumers.h @@ -17,7 +17,7 @@ JSC_DECLARE_HOST_FUNCTION(jsFunctionReadableStreamToBytes); JSC_DECLARE_HOST_FUNCTION(jsFunctionReadableStreamToJSON); JSC_DECLARE_HOST_FUNCTION(jsFunctionReadableStreamToBlob); JSC_DECLARE_HOST_FUNCTION(jsFunctionReadableStreamToFormData); -// body: dynamicDowncast(arg0)->{m_transferred = true, m_disturbed = true}. +// If arg0 hasPendingNativeSource(): {m_transferred = true, m_disturbed = true} and returns the handle. Else undefined. // Referenced by src/js/internal/streams/native-readable.ts via $newCppFunction. JSC_DECLARE_HOST_FUNCTION(jsFunctionTransferToNativeReadableStream); diff --git a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp index f4867711b82c..605e5f16edc0 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp @@ -184,6 +184,17 @@ static void setNativeSourceCloseAdapter(JSC::VM& vm, JSObject* handle, JSValue a ASSERT_NOT_REACHED(); } +extern "C" void Bun__FileReader__resume(void* source); + +void resumeNativeSource(JSReadableStream* stream) +{ + JSValue handle = stream->m_nativePtr.get(); + if (handle.isEmpty() || !handle.isCell()) + return; + if (auto* file = dynamicDowncast(handle.asCell())) + Bun__FileReader__resume(file->wrapped()); +} + // object.(...args) with a real [[Get]], as the replaced builtins did. static JSValue invokeMethod(JSC::VM& vm, JSGlobalObject* globalObject, JSObject* object, const Identifier& name, const MarkedArgumentBuffer& args) { @@ -427,13 +438,15 @@ void materializeNativeSource(JSGlobalObject* globalObject, JSReadableStream* str auto scope = DECLARE_THROW_SCOPE(vm); if (stream->nativeHandleDetached()) return; + // No chunk can come out of a stream that was errored or cancelled first, so its source stays unstarted. + if (stream->m_state != ReadableStreamState::Readable) + return; JSObject* handle = stream->m_nativePtr.get().getObject(); if (!handle) return; auto* domGlobalObject = defaultGlobalObject(globalObject); auto* runtime = WebCore::JSStreamsRuntime::from(globalObject); - stream->m_disturbed = true; size_t autoAllocateChunkSize = stream->m_autoAllocateChunkSize ? static_cast(stream->m_autoAllocateChunkSize) : nativeSourceDefaultChunkSize; MarkedArgumentBuffer startArgs; @@ -933,11 +946,11 @@ static std::optional rsisWriteChunk(JSC::VM& vm, JSGlobalObject* globalObj return true; } -// Writes values[start..length); false = suspended on backpressure (or an exception is pending). -static bool rsisWriteChunkArrayFrom(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamIntoSinkOperation* op, JSObject* values, unsigned start, unsigned length) +// Writes values[0..length); false = suspended on backpressure (or an exception is pending). +static bool rsisWriteChunkArray(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamIntoSinkOperation* op, JSObject* values, unsigned length) { auto scope = DECLARE_THROW_SCOPE(vm); - for (unsigned i = start; i < length; i++) { + for (unsigned i = 0; i < length; i++) { JSValue chunk = values->getIndex(globalObject, i); RETURN_IF_EXCEPTION(scope, false); auto step = rsisWriteChunk(vm, globalObject, op, chunk, values, i + 1, length); @@ -971,7 +984,7 @@ static void rsisContinueAfterReady(JSC::VM& vm, JSGlobalObject* globalObject, JS if (!tail) { RELEASE_AND_RETURN(scope, rsisIssueRead(globalObject, op)); } - bool completed = rsisWriteChunkArrayFrom(vm, globalObject, op, tail, 0, tail->length()); + bool completed = rsisWriteChunkArray(vm, globalObject, op, tail, tail->length()); RETURN_IF_EXCEPTION(scope, ); if (!completed) return; @@ -1022,7 +1035,7 @@ static void rsisContinueWithMany(JSC::VM& vm, JSGlobalObject* globalObject, JSRe RETURN_IF_EXCEPTION(scope, ); } if (length) { - bool completed = rsisWriteChunkArrayFrom(vm, globalObject, op, values, 0, length); + bool completed = rsisWriteChunkArray(vm, globalObject, op, values, length); RETURN_IF_EXCEPTION(scope, ); if (!completed) return; @@ -1079,7 +1092,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..32f70bb1e94a 100644 --- a/src/jsc/bindings/webcore/streams/JSReadableStream.cpp +++ b/src/jsc/bindings/webcore/streams/JSReadableStream.cpp @@ -526,7 +526,7 @@ void JSReadableStream::materializeIfNeeded(JSGlobalObject* globalObject) const auto mode = m_bunMode; if (mode == BunStreamMode::Default) [[likely]] return; - // A native sink locked this stream without a reader (ReadableStream__lockNative) and didn't start it. + // A native sink locked this stream without a reader (ReadableStream__tryLockNative) and didn't start it. // Don't start its source here. The caller's acquire will fail on the lock. if (m_lockedWithoutReader) return; @@ -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 96b2183d525e..ad30511be6b7 100644 --- a/src/jsc/bindings/webcore/streams/JSReadableStream.h +++ b/src/jsc/bindings/webcore/streams/JSReadableStream.h @@ -66,6 +66,10 @@ 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 }; + // Rust's `BodyFacts` for the bytes of a Request or Response body. tee() copies them to both branches. + uint8_t m_bodyFacts : 3 { 0 }; // [[reader]] — a default reader, a BYOB reader, or null (undefined). JSC::WriteBarrier m_reader; @@ -123,6 +127,11 @@ class JSReadableStream final : public JSC::JSNonFinalObject { { return m_transferred || m_consumedAsBody || (m_nativePtr.get().isInt32() && m_nativePtr.get().asInt32() == -1); } + // Nothing has started or locked the native source: every byte is behind its handle, none in a controller's queue. + bool hasPendingNativeSource() const + { + return m_bunMode == BunStreamMode::NativePending && m_state == ReadableStreamState::Readable && !m_reader && !m_lockedWithoutReader && !nativeHandleDetached(); + } // A Body consumer holds this stream now. It stays disturbed and locked after the consumer lets go of it. void markConsumedAsBody() { 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/JSStreamsRuntime.h b/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h index c52fd5b8cb2f..2f59da562019 100644 --- a/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h +++ b/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h @@ -211,6 +211,7 @@ namespace WebCore { // onReadableStreamTo*Fulfilled: the generic-path promise chains // (toArrayBuffer/toBytes/toBlob: value = the chunk array; toJSON: value = the text; // toFormData: value = the Blob, context = the contentType JSString). +// onReadableStreamToBodyBlobFulfilled: value = the Blob; context = an InternalFieldTuple{stream, contentType}. // onIntoArrayReadMany*: readableStreamIntoArray's readMany() continuation (readMany may // return a Promise); context = an InternalFieldTuple{reader, resultArray}. // onDirectConsumeLoopRead*: the readableStreamTo{Text,Array}Direct read loop; @@ -226,6 +227,7 @@ namespace WebCore { V(onReadableStreamToTextChunksFulfilled) \ V(onReadableStreamToJSONFulfilled) \ V(onReadableStreamToBlobFulfilled) \ + V(onReadableStreamToBodyBlobFulfilled) \ V(onReadableStreamToFormDataFulfilled) \ V(onIntoArrayReadManyFulfilled) /* append value; !done => readMany() again; done => release + resolve */ \ V(onIntoArrayReadManyRejected) /* release the reader, reject the result promise */ \ diff --git a/src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp b/src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp index e414c220b3ab..d174bc9c2db6 100644 --- a/src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp +++ b/src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp @@ -600,7 +600,9 @@ void setUpReadableStreamDefaultReader(JSGlobalObject* globalObject, JSReadableSt throwException(globalObject, scope, Bun::createError(globalObject, Bun::ErrorCode::ERR_INVALID_STATE_TypeError, "Invalid state: ReadableStream is locked"_s)); return; } - RELEASE_AND_RETURN(scope, readableStreamReaderGenericInitialize(globalObject, reader, stream)); + readableStreamReaderGenericInitialize(globalObject, reader, stream); + RETURN_IF_EXCEPTION(scope, void()); + resumeNativeSource(stream); } // SetUpReadableStreamBYOBReader(reader, stream) @@ -620,7 +622,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); @@ -628,6 +630,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; } @@ -990,7 +994,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)); @@ -1214,6 +1218,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) @@ -1223,10 +1245,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)); @@ -1259,8 +1287,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); @@ -1268,13 +1296,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); @@ -1314,7 +1347,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); @@ -1501,7 +1534,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)); @@ -1528,17 +1561,23 @@ 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); std::pair failure { nullptr, nullptr }; stream->materializeIfNeeded(globalObject); 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)); + auto branches = stream->m_controllerKind == ControllerKind::Byte + ? readableByteStreamTee(globalObject, stream) + : readableStreamDefaultTee(globalObject, stream, cloneForBranch2); + RETURN_IF_EXCEPTION(scope, failure); + // Branches that script asked for are the program's streams, so they lose `BodyFacts::NATIVE_STREAM`. A clone's keep it. + uint8_t facts = stream->m_bodyFacts & (cloneForBranch2 ? 0x7 : 0x3); + branches.first->m_bodyFacts = facts; + branches.second->m_bodyFacts = facts; + return branches; } // ReadableStreamPipeTo(source, dest, preventClose, preventAbort, preventCancel[, signal]). @@ -1555,7 +1594,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 b4a46e0f6c26..c19aa7aeeceb 100644 --- a/src/jsc/bindings/webcore/streams/StreamsForward.h +++ b/src/jsc/bindings/webcore/streams/StreamsForward.h @@ -112,6 +112,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) @@ -247,9 +253,6 @@ static_assert(sizeof(StreamingUTF8DecodeState) == 4); // dictionary conversion. enum class ReadableStreamType : uint8_t { Bytes }; -// WebIDL `enum ReadableStreamReaderMode { "byob" }` (getReader(options).mode) -enum class ReadableStreamReaderMode : uint8_t { Byob }; - } // namespace WebStreams } // namespace Bun @@ -259,7 +262,6 @@ namespace WebCore { using Bun::WebStreams::BunStreamMode; using Bun::WebStreams::ControllerKind; using Bun::WebStreams::DirectSinkKind; -using Bun::WebStreams::ReadableStreamReaderMode; using Bun::WebStreams::ReadableStreamState; using Bun::WebStreams::ReadableStreamType; using Bun::WebStreams::ReaderType; diff --git a/src/jsc/bindings/webcore/streams/WebStreamsExports.cpp b/src/jsc/bindings/webcore/streams/WebStreamsExports.cpp index 240c4129334c..4b8991aed757 100644 --- a/src/jsc/bindings/webcore/streams/WebStreamsExports.cpp +++ b/src/jsc/bindings/webcore/streams/WebStreamsExports.cpp @@ -81,6 +81,15 @@ extern "C" int32_t ReadableStreamTag__taggedStream(JSC::EncodedJSValue value, vo return stream ? tagOfStream(stream, ptr) : -1; } +// The tag for a consumer that would read the native source itself. A stream that was started or locked reads as a JS stream. +extern "C" int32_t ReadableStreamTag__unstarted(JSC::EncodedJSValue value, void** ptr) +{ + auto* stream = dynamicDowncast(JSValue::decode(value)); + if (!stream) + return -1; + return stream->hasPendingNativeSource() ? tagOfStream(stream, ptr) : 0; +} + extern "C" int32_t ReadableStreamTag__tagged(Zig::GlobalObject* globalObject, JSC::EncodedJSValue* possibleReadableStream, void** ptr) { JSValue value = JSValue::decode(*possibleReadableStream); @@ -117,10 +126,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); @@ -188,6 +195,18 @@ extern "C" [[ZIG_EXPORT(check_slow)]] void ReadableStream__error(JSC::EncodedJSV Bun::WebStreams::webStreamControllerError(globalObject, stream, JSValue::decode(reason)); } +extern "C" uint8_t ReadableStream__bodyFacts(JSC::EncodedJSValue possibleReadableStream) +{ + auto* stream = dynamicDowncast(JSValue::decode(possibleReadableStream)); + return stream ? stream->m_bodyFacts : 0; +} + +extern "C" void ReadableStream__setBodyFacts(JSC::EncodedJSValue possibleReadableStream, uint8_t facts) +{ + if (auto* stream = dynamicDowncast(JSValue::decode(possibleReadableStream))) + stream->m_bodyFacts = facts & 0x7; +} + // Closed with nothing ever read or attached: the queue was empty at close, so no byte can come out. extern "C" bool ReadableStream__isClosedUnread(JSC::EncodedJSValue possibleReadableStream, Zig::GlobalObject*) { @@ -238,30 +257,17 @@ static void lockNative(JSC::VM& vm, JSReadableStream* stream) slot->set(vm, handle.asCell(), stream); } -// A native sink (fetch body / S3 / FileSink) has attached directly without a reader. -// Mark the stream disturbed+locked so .locked, .getReader(), and the body-mixin -// disturbed checks behave as they do after readStreamIntoSink acquires a reader. -extern "C" void ReadableStream__lockNative(JSC::EncodedJSValue possibleReadableStream, Zig::GlobalObject* globalObject) -{ - auto* stream = dynamicDowncast(JSValue::decode(possibleReadableStream)); - if (!stream) [[unlikely]] - return; - lockNative(JSC::getVM(globalObject), stream); -} - -// Like ReadableStream__lockNative, for a sink that's about to connect itself to the native source. It only -// locks a stream that hasn't started and isn't locked, because a started source already feeds a controller. -// Otherwise it returns false without changing anything, and the caller falls back to readStreamIntoSink. +// Locks the stream for a native sink about to attach to its source. Returns false, changing nothing, if it was started or locked: the caller reads it through a reader. extern "C" bool ReadableStream__tryLockNative(JSC::EncodedJSValue possibleReadableStream, Zig::GlobalObject* globalObject) { auto* stream = dynamicDowncast(JSValue::decode(possibleReadableStream)); - if (!stream || stream->m_bunMode != BunStreamMode::NativePending || isReadableStreamLocked(stream)) + if (!stream || !stream->hasPendingNativeSource()) return false; lockNative(JSC::getVM(globalObject), stream); return true; } -// The source of a stream that ReadableStream__lockNative locked dropped its sink: error the stream with `reason`, or close it if `reason` is empty. +// The source of a stream that ReadableStream__tryLockNative locked dropped its sink: error the stream with `reason`, or close it if `reason` is empty. extern "C" [[ZIG_EXPORT(check_slow)]] void Bun__NativeStreamSource__endLockedStream(JSC::EncodedJSValue encodedHandle, Zig::GlobalObject* globalObject, JSC::EncodedJSValue reason) { auto* slot = lockedStreamSlot(JSValue::decode(encodedHandle).asCell()); @@ -300,7 +306,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); } @@ -407,13 +413,13 @@ extern "C" JSC::EncodedJSValue ZigGlobalObject__readableStreamToJSON(Zig::Global RELEASE_AND_RETURN(scope, JSValue::encode(readableStreamToJSON(globalObject, stream))); } -extern "C" JSC::EncodedJSValue ZigGlobalObject__readableStreamToBlob(Zig::GlobalObject* globalObject, JSC::EncodedJSValue streamValue) +extern "C" JSC::EncodedJSValue ZigGlobalObject__readableStreamToBodyBlob(Zig::GlobalObject* globalObject, JSC::EncodedJSValue streamValue, JSC::EncodedJSValue contentType) { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); auto* stream = toReadableStream(globalObject, scope, streamValue); RETURN_IF_EXCEPTION(scope, {}); - RELEASE_AND_RETURN(scope, JSValue::encode(readableStreamToBlob(globalObject, stream))); + RELEASE_AND_RETURN(scope, JSValue::encode(readableStreamToBodyBlob(globalObject, stream, JSValue::decode(contentType)))); } extern "C" JSC::EncodedJSValue ZigGlobalObject__readableStreamToFormData(Zig::GlobalObject* globalObject, JSC::EncodedJSValue streamValue, JSC::EncodedJSValue contentType) diff --git a/src/jsc/bindings/webcore/streams/WebStreamsInternals.h b/src/jsc/bindings/webcore/streams/WebStreamsInternals.h index 1921ddf53d00..dd43960f6957 100644 --- a/src/jsc/bindings/webcore/streams/WebStreamsInternals.h +++ b/src/jsc/bindings/webcore/streams/WebStreamsInternals.h @@ -125,7 +125,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 @@ -142,8 +143,6 @@ QueuingStrategyDict convertQueuingStrategyDict(JSC::JSGlobalObject*, JSC::JSValu // (undefined / true / ...) are exempt. Do NOT "optimize" a fulfillment site to skip // re-validation on the grounds that the resolution value is internally constructed. JSC::JSPromise* promiseFulfilledWith(JSC::JSGlobalObject*, JSC::JSValue); // userJS: no — WebStreamsMisc.cpp -// [bound-convention] wrapper: target(contextCell, ...callArgs). userJS: no — WebStreamsMisc.cpp -JSC::JSBoundFunction* createStreamsBoundHandler(JSC::JSGlobalObject*, JSC::JSFunction* target, JSC::JSCell* context); // obj.name(...args); returns the EMPTY value when `name` is not callable. userJS: yes — WebStreamsMisc.cpp JSC::JSValue invokeOptionalMethod(JSC::JSGlobalObject*, JSC::JSObject*, const JSC::Identifier& name, const JSC::MarkedArgumentBuffer&); // WebIDL "invoke a callback function" whose declared return type is Promise (underlying @@ -274,9 +273,9 @@ 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 setUpReadableStreamDefaultReader(JSC::JSGlobalObject*, JSReadableStreamDefaultReader*, JSReadableStream*); // userJS: no (also runs Bun's resumeNativeSource) — ReadableStreamOperations.cpp void setUpReadableStreamBYOBReader(JSC::JSGlobalObject*, JSReadableStreamBYOBReader*, JSReadableStream*); // userJS: no — ReadableStreamOperations.cpp JSC::JSPromise* readableStreamReaderGenericCancel(JSC::JSGlobalObject*, JSReadableStreamReaderBase*, JSC::JSValue reason); // userJS: yes — ReadableStreamOperations.cpp void readableStreamReaderGenericInitialize(JSC::JSGlobalObject*, JSReadableStreamReaderBase*, JSReadableStream*); // userJS: no, never throws — ReadableStreamOperations.cpp @@ -305,10 +304,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 @@ -362,6 +360,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 @@ -604,6 +604,8 @@ void pipeToReadRequestErrorSteps(JSC::JSGlobalObject*, JSStreamPipeToOperation*, // lazyLoadStream: installs the Native default controller (or the empty fast path). void materializeNativeSource(JSC::JSGlobalObject*, JSReadableStream*); // userJS: yes — BunStreamSource.cpp +// process.stdin pauses the file source it shares with Bun.stdin.stream(); the next reader makes it read again. +void resumeNativeSource(JSReadableStream*); // userJS: no (a paused source reads at once; a pull that settles reacts in a microtask, as in nativeSourcePull) — BunStreamSource.cpp // The SourceKind::Native algorithm ARMS. The pull/cancel dispatch is a TOTAL // `switch (m_algorithms.kind)` in JSReadableStreamDefaultController.cpp (a Native source is @@ -632,12 +634,14 @@ 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 JSC::JSValue readableStreamToBlob(JSC::JSGlobalObject*, JSReadableStream*); // userJS: yes — BunStreamConsumers.cpp JSC::JSValue readableStreamToFormData(JSC::JSGlobalObject*, JSReadableStream*, JSC::JSValue contentType); // userJS: yes — BunStreamConsumers.cpp +JSC::JSValue readableStreamToBodyBlob(JSC::JSGlobalObject*, JSReadableStream*, JSC::JSValue contentType); // userJS: yes — BunStreamConsumers.cpp // The buffered fast path: returns the native handle's own .text()/.arrayBuffer()/... promise, // or the EMPTY JSValue if the fast path does not apply. `method` is the property name to @@ -649,8 +653,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. @@ -703,6 +707,8 @@ bool ReadableStream__isLocked(JSC::EncodedJSValue possibleReadableStream, Zig::G void ReadableStream__cancel(JSC::EncodedJSValue possibleReadableStream, Zig::GlobalObject*); // userJS: yes // NO sentinel guard (reachable on a NativeSink-controlled stream). void ReadableStream__cancelWithReason(JSC::EncodedJSValue possibleReadableStream, Zig::GlobalObject*, JSC::EncodedJSValue reason); // userJS: yes +uint8_t ReadableStream__bodyFacts(JSC::EncodedJSValue possibleReadableStream); // userJS: no +void ReadableStream__setBodyFacts(JSC::EncodedJSValue possibleReadableStream, uint8_t facts); // userJS: no bool ReadableStream__isClosedUnread(JSC::EncodedJSValue possibleReadableStream, Zig::GlobalObject*); // userJS: no void ReadableStream__markConsumedAsBody(JSC::EncodedJSValue possibleReadableStream, Zig::GlobalObject*); // userJS: no void ReadableStream__closeConsumedAsBody(JSC::EncodedJSValue possibleReadableStream, Zig::GlobalObject*); // userJS: no @@ -717,7 +723,7 @@ JSC::EncodedJSValue ZigGlobalObject__readableStreamToArrayBuffer(Zig::GlobalObje JSC::EncodedJSValue ZigGlobalObject__readableStreamToBytes(Zig::GlobalObject*, JSC::EncodedJSValue stream); // userJS: yes JSC::EncodedJSValue ZigGlobalObject__readableStreamToText(Zig::GlobalObject*, JSC::EncodedJSValue stream); // userJS: yes JSC::EncodedJSValue ZigGlobalObject__readableStreamToJSON(Zig::GlobalObject*, JSC::EncodedJSValue stream); // userJS: yes -JSC::EncodedJSValue ZigGlobalObject__readableStreamToBlob(Zig::GlobalObject*, JSC::EncodedJSValue stream); // userJS: yes +JSC::EncodedJSValue ZigGlobalObject__readableStreamToBodyBlob(Zig::GlobalObject*, JSC::EncodedJSValue stream, JSC::EncodedJSValue contentType); // userJS: yes JSC::EncodedJSValue ZigGlobalObject__readableStreamToFormData(Zig::GlobalObject*, JSC::EncodedJSValue stream, JSC::EncodedJSValue contentType); // userJS: yes } // extern "C" diff --git a/src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp b/src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp index 50807f6a2016..634653340f60 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) { @@ -420,17 +476,6 @@ JSPromise* invokeCallbackReturningPromiseFast(JSGlobalObject* globalObject, JSOb }); } -// Shared [bound-convention] wrapper: target(contextCell, ...callArgs). -JSC::JSBoundFunction* createStreamsBoundHandler(JSGlobalObject* globalObject, JSFunction* target, JSCell* context) -{ - auto& vm = getVM(globalObject); - MarkedArgumentBuffer boundArgs; - boundArgs.append(context); - ASSERT(!boundArgs.hasOverflowed()); - return JSBoundFunction::create(vm, globalObject, target, jsUndefined(), ArgList(boundArgs), 1, nullptr, - makeSource("streamsBoundHandler"_s, SourceOrigin(), SourceTaintedOrigin::Untainted)); -} - JSPromise* promiseFulfilledWith(JSGlobalObject* globalObject, JSValue value) { auto& vm = getVM(globalObject); diff --git a/src/jsc/event_loop.bind.ts b/src/jsc/event_loop.bind.ts index c80afd6efa28..e3b35e3f41b7 100644 --- a/src/jsc/event_loop.bind.ts +++ b/src/jsc/event_loop.bind.ts @@ -12,6 +12,8 @@ export const ActiveTasks = b.dictionary({ concurrentRef: b.i32, numPolls: b.i32, iteration: b.u64, + sweepTimerCount: b.i32, + sweepDeadline: b.f64.optional, }); export const getActiveTasks = b.fn({ returns: ActiveTasks, capability: "heap" }); diff --git a/src/jsc/event_loop.rs b/src/jsc/event_loop.rs index cd7dcb68b555..4eceec32ae60 100644 --- a/src/jsc/event_loop.rs +++ b/src/jsc/event_loop.rs @@ -475,6 +475,11 @@ impl EventLoop { self.deferred_tasks.run(); vm.is_inside_deferred_task_queue.set(false); + // Drain again, or the microtasks that JS queued wait for the next event and are lost if the loop exits first. + if vm.deferred_task_called_js.take() { + global_object.drain_microtasks_and_next_ticks()?; + } + // Guard on `event_loop_handle` being set, but drain via `uws_loop_ref()`: // on Windows the uSockets loop (`uws::Loop::get()`) is NOT // `event_loop_handle` (which is the libuv loop). @@ -1572,6 +1577,21 @@ pub(crate) fn get_active_tasks( num_polls, // SAFETY: usockets_loop() returns the live process-global loop. iteration: unsafe { (*event_loop.usockets_loop()).iteration_number() }, + // SAFETY: as above. + sweep_timer_count: unsafe { + (*event_loop.usockets_loop()) + .internal_loop_data + .sweep_timer_count() + }, + #[cfg(windows)] + sweep_deadline: None, + #[cfg(not(windows))] + // SAFETY: as above. + sweep_deadline: Some(unsafe { + (*event_loop.usockets_loop()) + .internal_loop_data + .sweep_next_tick_ns() + } as f64), } } diff --git a/src/jsc/lib.rs b/src/jsc/lib.rs index 3ccce0d6a95e..8b9ce066d662 100644 --- a/src/jsc/lib.rs +++ b/src/jsc/lib.rs @@ -7,7 +7,7 @@ //! Those targets live in `bun_runtime`, which depends on this crate — //! re-exporting them here would create a cycle. Callers reference //! `bun_runtime::{webcore,api,node}` directly; lower-tier consumers that -//! constructed those types (e.g. `output_file_jsc`, `BlobArrayBuffer_deallocator`) +//! constructed those types (e.g. `output_file_jsc`) //! have been moved up into `bun_runtime`, and the few that only need an opaque //! borrow (e.g. `DOMFormData::for_each`) are generic over the caller's `Blob`. @@ -903,6 +903,8 @@ pub enum BuiltinName { internal, /// Private name (`$sharedFd` in builtins); user code cannot set it. sharedFd, + /// Private name (`$defaultTimeout` in builtins); user code cannot set it. + defaultTimeout, } #[allow(non_upper_case_globals)] diff --git a/src/jsc/rare_data.rs b/src/jsc/rare_data.rs index af06e0dc345d..cad855f03db1 100644 --- a/src/jsc/rare_data.rs +++ b/src/jsc/rare_data.rs @@ -232,6 +232,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`. @@ -249,8 +251,8 @@ pub struct RareData { pub pipe_read_scratch: Box, - /// `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`]. @@ -268,10 +270,12 @@ pub struct RareData { pub node_quic_callbacks: Strong, pub(crate) default_csrf_secret: Box<[u8]>, - /// Owned NUL-terminated buffer. `len()` includes the trailing 0; - /// [`Self::tls_default_ciphers`] strips it. + /// The TLS 1.2 list assigned to `tls.DEFAULT_CIPHERS`. 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>, @@ -299,11 +303,12 @@ 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()), 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, @@ -311,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(), } @@ -354,8 +360,12 @@ impl PathBuf { // Drop is automatic for Option> fields — no explicit deinit needed. -/// 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 @@ -639,14 +649,17 @@ impl SocketGroups { /// /// # Safety /// `this` is a live, boxed set. - pub(crate) unsafe fn close_all(this: *mut Self) { + pub(crate) unsafe fn close_all(this: *mut Self, reason: crate::StopReason) { for i in 0..Self::COUNT { // SAFETY: fn contract; the borrow ends before `close_all` dispatches. let group: *mut SocketGroup = unsafe { &mut *this }.each()[i]; // SAFETY: `group` is embedded in the live set. unsafe { if !(*group).loop_.is_null() { - (*group).close_all(); + match reason { + crate::StopReason::VmTeardown => (*group).close_all_as_thread_ends(), + crate::StopReason::Disposed => (*group).close_all(), + } } } } @@ -731,18 +744,22 @@ impl RareData { // ── lazy-init: misc heap slots ──────────────────────────────────────── - /// Take the padded-frame scratch out of its slot (lazily allocated). By value rather - /// than borrowed: the socket write it feeds can re-enter JS and reach this path - /// again, and that nested caller then finds the slot empty and allocates its own. - pub fn take_h2_padded_frame_buffer(&mut self) -> Box { - self.h2_padded_frame_buffer - .take() - .unwrap_or_else(bun_core::boxed_zeroed::) + /// Taken by value: a header value's `toString` can re-enter, and the nested caller then finds the slot empty and makes its own. + pub fn take_h2_header_scratch(&mut self) -> H2HeaderScratch { + self.h2_header_scratch.take().unwrap_or_default() } - /// Hand a taken buffer back; the slot keeps the first one returned. - pub fn put_back_h2_padded_frame_buffer(&mut self, buffer: Box) { - self.h2_padded_frame_buffer.get_or_insert(buffer); + /// 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. @@ -760,7 +777,7 @@ impl RareData { } /// Empty `Vec` with whatever capacity the last WebSocket client inflate left behind. By value, - /// like [`Self::take_h2_padded_frame_buffer`]: delivering the payload runs JS, which can reach + /// like [`Self::take_h2_header_scratch`]: delivering the payload runs JS, which can reach /// this path again before the buffer comes back. pub fn take_websocket_inflate_scratch(&mut self) -> Vec { self.websocket_inflate_scratch.take().unwrap_or_default() @@ -794,23 +811,28 @@ impl RareData { } pub(crate) fn tls_default_ciphers(&self) -> Option<&[u8]> { - // The stored buffer is NUL-terminated (set_tls_default_ciphers - // appends 0); the trailing NUL is stripped from the returned slice's - // length. Callers needing a C string can still take `.as_ptr()` — the - // NUL byte remains in storage one-past-the-end. - self.tls_default_ciphers - .as_deref() - .map(|s| &s[..s.len() - 1]) + self.tls_default_ciphers.as_deref() } pub(crate) fn set_tls_default_ciphers(&mut self, ciphers: &[u8]) { - // Old value (if any) drops here via Box<[u8]> Drop. - let mut owned = Vec::with_capacity(ciphers.len() + 1); - 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.tls_default_ciphers = Some(ciphers.into()); + self.drop_client_tls_defaults(); + } + + 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: Box<[Box<[u8]>]>) { + self.tls_default_ca = Some(certs); + 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( @@ -1048,7 +1070,7 @@ extern "C" fn Bun__Process__getStdinFdType(vm: &VirtualMachine, fd: i32) -> Stdi } // ────────────────────────────────────────────────────────────────────────── -// TLS default ciphers JS bindings +// `rare_data.bind.ts` and `node_tls_binding.bind.ts` // ────────────────────────────────────────────────────────────────────────── pub(crate) fn set_tls_default_ciphers(heap: &JsHeap<'_>, ciphers: &[u8]) { @@ -1074,13 +1096,29 @@ pub(crate) fn get_tls_default_ciphers<'a>(heap: &JsHeap<'a>) -> &'a [u8] { } } +pub fn get_default_ca() -> Option<&'static [Box<[u8]>]> { + VirtualMachine::get().tls_default_ca() +} + +pub fn set_default_ca(certs: Vec>) { + VirtualMachine::get() + .as_mut() + .rare_data() + .set_tls_default_ca( + certs + .into_iter() + .map(|cert| cert.into_vec().into()) + .collect(), + ); +} + // ────────────────────────────────────────────────────────────────────────── // Drop // ────────────────────────────────────────────────────────────────────────── impl Drop for RareData { fn drop(&mut self) { - // pipe_read_scratch / h2_padded_frame_buffer / spawn_sync_event_loop_ / + // pipe_read_scratch / spawn_sync_event_loop_ / // s3_default_client / default_csrf_secret / cleanup_hooks / path_buf / // tls_default_ciphers: // all dropped automatically via field Drop. diff --git a/src/jsc/virtual_machine_exports.rs b/src/jsc/virtual_machine_exports.rs index 1b5b69a1b9a7..9c9471d02824 100644 --- a/src/jsc/virtual_machine_exports.rs +++ b/src/jsc/virtual_machine_exports.rs @@ -248,17 +248,6 @@ pub fn set_verbose_fetch_value(value: i32) { })); } -// HOST_EXPORT(Bun__getVerboseFetchValue, c) -pub fn get_verbose_fetch_value() -> i32 { - use bun_http::HTTPVerboseLevel; - // SAFETY: VM singleton is process-lifetime. - match VirtualMachine::get().get_verbose_fetch() { - HTTPVerboseLevel::None => 0, - HTTPVerboseLevel::Headers => 1, - HTTPVerboseLevel::Curl => 2, - } -} - // `Bun__{add,remove}BakeSourceProviderSourceMap` / // `Bun__{add,remove}DevServerSourceProvider` live in // `bun_runtime::bake::source_provider_exports` (their callers are bake's C++ diff --git a/src/jsc/webcore_types.rs b/src/jsc/webcore_types.rs index 57c11091e05c..d059ae3f1616 100644 --- a/src/jsc/webcore_types.rs +++ b/src/jsc/webcore_types.rs @@ -139,6 +139,19 @@ impl From for BlobContentType { } } +bitflags::bitflags! { + /// What is known about a body's bytes. It moves with them through every form a body takes. + #[derive(Clone, Copy, Default, PartialEq, Eq)] + pub struct BodyFacts: u8 { + /// They are a JS string's, which `Bun.serve` sends as `text/plain;charset=utf-8`. + const STRING = 1 << 0; + /// `fetch()` decoded them, so the response's `Content-Encoding` no longer describes them. + const DECODED = 1 << 1; + /// They reach a reader through a stream that Bun made, not one the program made. + const NATIVE_STREAM = 1 << 2; + } +} + // ────────────────────────────────────────────────────────────────────────── // Blob // ────────────────────────────────────────────────────────────────────────── @@ -162,6 +175,8 @@ pub struct Blob { pub charset: Cell, /// Was it created via the `File` constructor? pub is_jsdom_file: Cell, + /// Set while these are, or were, the bytes of a Request or Response body. + pub body_facts: Cell, /// Zero for a by-value `Blob`. On a heap one, the JS wrapper, `BlobRefPtr`s and `ExternalShared`s each hold a ref. /// /// Public so `bun_runtime` can construct `Blob { ref_count: …, .. }` @@ -196,6 +211,7 @@ impl Default for Blob { content_type_was_set: Cell::new(false), charset: Cell::new(AsciiStatus::Unknown), is_jsdom_file: Cell::new(false), + body_facts: Cell::new(BodyFacts::empty()), ref_count: bun_ptr::RawRefCount::init(0), global_this: Cell::new(core::ptr::null()), last_modified: Cell::new(0.0), @@ -364,21 +380,6 @@ impl Blob { /// New view onto the same store, +1 ref. #[inline] pub fn dupe(&self) -> Blob { - self.dupe_with_content_type(false) - } - - /// Alias for [`Self::dupe`]. - #[inline] - pub fn borrowed_view(&self) -> Blob { - self.dupe() - } - - /// Ignores - /// `include_content_type` and **always** deep-copies a heap-allocated - /// `content_type` so freeing one side does not dangle the other (the old - /// borrow path was removed because it dropped user-supplied parameters - /// like multipart boundaries on a static-mime miss). - pub fn dupe_with_content_type(&self, _include_content_type: bool) -> Blob { Blob { reported_estimated_size: Cell::new(self.reported_estimated_size.get()), size: Cell::new(self.size.get()), @@ -388,6 +389,7 @@ impl Blob { content_type_was_set: Cell::new(self.content_type_was_set.get()), charset: Cell::new(self.charset.get()), is_jsdom_file: Cell::new(self.is_jsdom_file.get()), + body_facts: Cell::new(self.body_facts.get()), ref_count: bun_ptr::RawRefCount::init(0), // setNotHeapAllocated global_this: Cell::new(self.global_this.get()), last_modified: Cell::new(self.last_modified.get()), @@ -403,15 +405,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/libuv_sys/libuv.rs b/src/libuv_sys/libuv.rs index 238a7a43a84c..4a41aa565c87 100644 --- a/src/libuv_sys/libuv.rs +++ b/src/libuv_sys/libuv.rs @@ -68,10 +68,6 @@ pub type NTSTATUS = i32; /// `Option` an unwanted niche (None ↔ 0 collides with socket 0) and /// force int-to-ptr provenance for `INVALID_SOCKET`. pub type SOCKET = usize; -type LPFN_ACCEPTEX = *const c_void; -type LPFN_CONNECTEX = *const c_void; -type LPFN_WSARECV = *const c_void; -type LPFN_WSARECVFROM = *const c_void; pub type uv_mutex_t = CRITICAL_SECTION; // Socket address types (ws2def.h). The canonical `#[repr(C)]` definitions live @@ -264,7 +260,6 @@ pub enum HandleType { pub type uv_handle_type = HandleType; pub const UV_TTY: HandleType = HandleType::Tty; pub const UV_NAMED_PIPE: HandleType = HandleType::NamedPipe; -pub const UV_UNKNOWN_HANDLE: HandleType = HandleType::Unknown; /// Safe `uv_guess_handle` wrapper. The FFI symbol returns `c_int` (see /// [`uv_guess_handle_raw`]); range-check before producing a [`HandleType`] so an @@ -281,7 +276,6 @@ pub fn uv_guess_handle(file: uv_file) -> uv_handle_type { HandleType::Unknown } } -pub const UV_HANDLE_TYPE_MAX: c_int = 18; /// `RunMode` — `uv_run` mode argument. #[repr(C)] @@ -313,9 +307,6 @@ pub(crate) type uv_walk_cb = Option; pub type uv_fs_event_cb = Option; -pub type uv_udp_send_cb = Option; -pub type uv_udp_recv_cb = - Option; pub type uv_getaddrinfo_cb = Option; pub type uv_work_cb = Option; @@ -759,9 +750,7 @@ pub unsafe trait UvHandle: Sized { unsafe impl UvHandle for Handle {} unsafe impl UvHandle for uv_stream_t {} unsafe impl UvHandle for Pipe {} -unsafe impl UvHandle for uv_tcp_t {} unsafe impl UvHandle for uv_tty_t {} -unsafe impl UvHandle for uv_udp_t {} unsafe impl UvHandle for Timer {} unsafe impl UvHandle for uv_async_t {} unsafe impl UvHandle for uv_prepare_t {} @@ -771,21 +760,15 @@ unsafe impl UvHandle for uv_poll_t {} unsafe impl UvHandle for uv_signal_t {} unsafe impl UvHandle for Process {} unsafe impl UvHandle for uv_fs_event_t {} -unsafe impl UvHandle for uv_fs_poll_t {} /// Marker for `#[repr(C)]` structs prefixed with `UV_STREAM_FIELDS` -/// (`uv_stream_t`, `Pipe`, `uv_tcp_t`, `uv_tty_t`). +/// (`uv_stream_t`, `Pipe`, `uv_tty_t`). pub unsafe trait UvStream: UvHandle { #[inline] fn as_stream(&mut self) -> *mut uv_stream_t { (self as *mut Self).cast() } #[inline] - fn get_write_queue_size(&self) -> usize { - // SAFETY: stream prefix invariant. - unsafe { uv_stream_get_write_queue_size((self as *const Self).cast()) } - } - #[inline] fn read_start(&mut self, alloc_cb: uv_alloc_cb, read_cb: uv_read_cb) -> ReturnCode { // SAFETY: stream prefix invariant. unsafe { uv_read_start(self.as_stream(), alloc_cb, read_cb) } @@ -899,7 +882,6 @@ pub trait StreamReader: Sized { // SAFETY: all of these are `#[repr(C)]` with `UV_STREAM_FIELDS` prefix. unsafe impl UvStream for uv_stream_t {} unsafe impl UvStream for Pipe {} -unsafe impl UvStream for uv_tcp_t {} unsafe impl UvStream for uv_tty_t {} // ────────────────────────────────────────────────────────────────────────── @@ -1046,104 +1028,6 @@ pub struct uv_connect_t { pub handle: *mut uv_stream_t, } -// ────────────────────────────────────────────────────────────────────────── -// `uv_tcp_t`. -// ────────────────────────────────────────────────────────────────────────── -#[repr(C)] -#[derive(Clone, Copy)] -pub struct uv_tcp_accept_t { - pub data: *mut c_void, - pub type_: uv_req_type, - pub reserved: [*mut c_void; 6], - pub u: req_u, - pub next_req: *mut uv_req_t, - pub accept_socket: SOCKET, - pub accept_buffer: [u8; 288], - pub event_handle: HANDLE, - pub wait_handle: HANDLE, - pub next_pending: *mut uv_tcp_accept_t, -} -#[repr(C)] -#[derive(Clone, Copy)] -struct tcp_serv { - accept_reqs: *mut uv_tcp_accept_t, - processed_accepts: c_uint, - pending_accepts: *mut uv_tcp_accept_t, - func_acceptex: LPFN_ACCEPTEX, -} -#[repr(C)] -#[derive(Clone, Copy)] -struct tcp_conn { - read_buffer: uv_buf_t, - func_connectex: LPFN_CONNECTEX, -} -#[repr(C)] -#[derive(Clone, Copy)] -union tcp_u { - serv: tcp_serv, - conn: tcp_conn, -} -#[repr(C)] -pub struct uv_tcp_t { - pub data: *mut c_void, - pub loop_: *mut Loop, - pub type_: HandleType, - pub close_cb: uv_close_cb, - pub handle_queue: uv__queue, - pub u: handle_u, - pub endgame_next: *mut uv_handle_t, - pub flags: c_uint, - pub write_queue_size: usize, - pub alloc_cb: uv_alloc_cb, - pub read_cb: uv_read_cb, - pub reqs_pending: c_uint, - pub activecnt: c_int, - pub read_req: uv_read_t, - stream: stream_conn_serv, - pub socket: SOCKET, - pub delayed_error: c_int, - tcp: tcp_u, -} - -// ────────────────────────────────────────────────────────────────────────── -// `uv_udp_t`. -// ────────────────────────────────────────────────────────────────────────── -#[repr(C)] -pub struct uv_udp_t { - pub data: *mut c_void, - pub loop_: *mut Loop, - pub type_: HandleType, - pub close_cb: uv_close_cb, - pub handle_queue: uv__queue, - pub u: handle_u, - pub endgame_next: *mut uv_handle_t, - pub flags: c_uint, - pub send_queue_size: usize, - pub send_queue_count: usize, - pub socket: SOCKET, - pub reqs_pending: c_uint, - pub activecnt: c_int, - pub recv_req: uv_req_t, - pub recv_buffer: uv_buf_t, - pub recv_from: sockaddr_storage, - pub recv_from_len: c_int, - pub recv_cb: uv_udp_recv_cb, - pub alloc_cb: uv_alloc_cb, - pub func_wsarecv: LPFN_WSARECV, - pub func_wsarecvfrom: LPFN_WSARECVFROM, -} -#[repr(C)] -#[derive(Clone, Copy)] -pub struct uv_udp_send_t { - pub data: *mut c_void, - pub type_: uv_req_type, - pub reserved: [*mut c_void; 6], - pub u: req_u, - pub next_req: *mut uv_req_t, - pub handle: *mut uv_udp_t, - pub cb: uv_udp_send_cb, -} - // ────────────────────────────────────────────────────────────────────────── // `Pipe` (`uv_pipe_t`). // ────────────────────────────────────────────────────────────────────────── @@ -1710,7 +1594,7 @@ pub struct uv_process_options_t { } // ────────────────────────────────────────────────────────────────────────── -// `uv_fs_event_t` / `uv_fs_poll_t` / `uv_signal_t`. +// `uv_fs_event_t` / `uv_signal_t`. // ────────────────────────────────────────────────────────────────────────── #[repr(C)] #[derive(Clone, Copy)] @@ -1765,18 +1649,6 @@ impl uv_fs_event_t { } } #[repr(C)] -pub struct uv_fs_poll_t { - pub data: *mut c_void, - pub loop_: *mut Loop, - pub type_: HandleType, - pub close_cb: uv_close_cb, - pub handle_queue: uv__queue, - pub u: handle_u, - pub endgame_next: *mut uv_handle_t, - pub flags: c_uint, - pub poll_ctx: *mut c_void, -} -#[repr(C)] #[derive(Clone, Copy)] struct signal_tree_entry { rbe_left: *mut uv_signal_t, @@ -2311,6 +2183,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 {} @@ -2399,8 +2272,6 @@ pub mod O { pub const RDONLY: i32 = 0x0000; pub const RDWR: i32 = 0x0002; pub const SEQUENTIAL: i32 = 0x0020; - pub const SHORT_LIVED: i32 = 0x1000; - pub const TEMPORARY: i32 = 0x0040; pub const TRUNC: i32 = 0x0200; pub const WRONLY: i32 = 0x0001; pub(crate) const DIRECT: i32 = 0x0200_0000; @@ -2408,7 +2279,6 @@ pub mod O { pub(crate) const SYNC: i32 = 0x0800_0000; // No-ops on Windows. pub const DIRECTORY: i32 = 0; - pub const EXLOCK: i32 = 0x1000_0000; pub const NOATIME: i32 = 0; pub const NOCTTY: i32 = 0; pub(crate) const NOFOLLOW: i32 = 0; @@ -2524,10 +2394,6 @@ pub mod O { // ────────────────────────────────────────────────────────────────────────── // Error constants (uv-errno.h, Windows values). // ────────────────────────────────────────────────────────────────────────── -pub const UV__EOF: c_int = -4095; -pub const UV__UNKNOWN: c_int = -4094; -pub const UV__ECHARSET: c_int = -4080; - pub const UV_E2BIG: c_int = -4093; pub const UV_EACCES: c_int = -4092; pub const UV_EADDRINUSE: c_int = -4091; @@ -2629,22 +2495,17 @@ pub const UV_DIRENT_BLOCK: c_int = 7; // Misc flag constants. pub const UV_READABLE: c_int = 1; pub const UV_WRITABLE: c_int = 2; -pub const UV_DISCONNECT: c_int = 4; -pub const UV_PRIORITIZED: c_int = 8; pub(crate) const UV_PIPE_NO_TRUNCATE: c_uint = 1; pub const UV_FS_SYMLINK_DIR: c_int = 0x0001; pub const UV_FS_SYMLINK_JUNCTION: c_int = 0x0002; pub const UV_RENAME: c_int = 1; pub const UV_CHANGE: c_int = 2; -pub const UV_FS_EVENT_WATCH_ENTRY: c_int = 1; -pub const UV_FS_EVENT_STAT: c_int = 2; pub const UV_FS_EVENT_RECURSIVE: c_int = 4; // Stdio / process flags. pub const UV_IGNORE: c_uint = 0; pub const UV_CREATE_PIPE: c_uint = 1; pub const UV_INHERIT_FD: c_uint = 2; -pub const UV_INHERIT_STREAM: c_uint = 4; pub const UV_READABLE_PIPE: c_uint = 16; pub const UV_WRITABLE_PIPE: c_uint = 32; pub const UV_NONBLOCK_PIPE: c_uint = 64; @@ -2656,8 +2517,6 @@ pub const UV_PROCESS_SETGID: c_uint = 2; pub const UV_PROCESS_WINDOWS_VERBATIM_ARGUMENTS: c_uint = 4; pub const UV_PROCESS_DETACHED: c_uint = 8; pub const UV_PROCESS_WINDOWS_HIDE: c_uint = 16; -pub const UV_PROCESS_WINDOWS_HIDE_CONSOLE: c_uint = 32; -pub const UV_PROCESS_WINDOWS_HIDE_GUI: c_uint = 64; pub const SIGHUP: c_int = 1; pub const SIGQUIT: c_int = 3; @@ -2712,7 +2571,6 @@ unsafe extern "C" { pub fn uv_cancel(req: *mut uv_req_t) -> c_int; // stream - pub fn uv_stream_get_write_queue_size(stream: *const uv_stream_t) -> usize; pub fn uv_listen(stream: *mut uv_stream_t, backlog: c_int, cb: uv_connection_cb) -> ReturnCode; pub fn uv_accept(server: *mut uv_stream_t, client: *mut uv_stream_t) -> ReturnCode; pub fn uv_read_start( @@ -2721,6 +2579,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, @@ -3111,7 +2974,6 @@ const _: () = { assert_size!(uv_connect_t, 128); assert_size!(uv_write_t, 176); assert_size!(uv_process_exit_t, 112); - assert_size!(uv_udp_send_t, 128); // `UV_HANDLE_FIELDS` header. assert_size!(handle_u, 32); @@ -3143,7 +3005,6 @@ const _: () = { assert_size!(uv_check_t, 120); assert_size!(uv_idle_t, 120); assert_size!(uv_async_t, 224); - assert_size!(uv_fs_poll_t, 104); assert_offset!(Loop, active_handles, 8); assert_offset!(Loop, iocp, 56); assert_offset!(Loop, wq_async, 248); @@ -3162,12 +3023,10 @@ const _: () = { // `UvHandle` trait invariant: every implementor has `data` at offset 0. assert_offset!(Pipe, data, 0); - assert_offset!(uv_tcp_t, data, 0); assert_offset!(uv_tty_t, data, 0); assert_offset!(Timer, data, 0); assert_offset!(uv_async_t, data, 0); assert_offset!(Process, data, 0); assert_offset!(uv_signal_t, data, 0); assert_offset!(uv_poll_t, data, 0); - assert_offset!(uv_udp_t, data, 0); }; diff --git a/src/lsquic_sys/lib.rs b/src/lsquic_sys/lib.rs index 3778f222f490..1bc7a3487b3f 100644 --- a/src/lsquic_sys/lib.rs +++ b/src/lsquic_sys/lib.rs @@ -21,17 +21,8 @@ pub struct lsquic_stream { pub struct lsquic_engine_settings { _opaque: [u8; 0], } -#[repr(C)] -pub struct lsquic_out_spec { - _opaque: [u8; 0], -} pub type sockaddr = c_void; #[repr(C)] -pub struct iovec { - pub iov_base: *mut c_void, - pub iov_len: usize, -} -#[repr(C)] pub struct SSL_CTX { _opaque: [u8; 0], } @@ -45,10 +36,10 @@ 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; - -pub const LSQUIC_GLOBAL_CLIENT: c_int = 1; -pub const LSQUIC_GLOBAL_SERVER: c_int = 2; +/// `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; /// **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 @@ -82,8 +73,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, @@ -96,24 +86,17 @@ pub struct NqVtable { ), pub on_origin: unsafe extern "C" fn(conn_ctx: *mut c_void, chunk: *const u8, len: usize, fin: c_int), - pub get_ssl_ctx: - unsafe extern "C" fn(owner: *mut c_void, local: *const sockaddr) -> *mut SSL_CTX, - pub get_client_ssl_ctx: - unsafe extern "C" fn(owner: *mut c_void, local: *const sockaddr) -> *mut SSL_CTX, pub lookup_cert: unsafe extern "C" fn( owner: *mut c_void, local: *const sockaddr, sni: *const c_char, ) -> *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( engine: *mut lsquic_engine, @@ -129,34 +112,16 @@ unsafe extern "C" { peer_ctx: *mut c_void, ecn: c_int, ) -> c_int; - pub fn lsquic_engine_process_conns(engine: *mut lsquic_engine); - pub fn lsquic_engine_set_idle_timeout_ms(engine: *mut lsquic_engine, ms: c_uint); - pub fn lsquic_engine_earliest_adv_tick(engine: *mut lsquic_engine, diff: *mut c_int) -> c_int; - pub fn lsquic_engine_has_unsent_packets(engine: *mut lsquic_engine) -> c_int; - pub fn lsquic_engine_send_unsent_packets(engine: *mut lsquic_engine); - pub fn lsquic_engine_connect( - engine: *mut lsquic_engine, - version: c_int, - local_sa: *const sockaddr, - peer_sa: *const sockaddr, - peer_ctx: *mut c_void, - conn_ctx: *mut c_void, - hostname: *const c_char, - base_plpmtu: u16, - sess_resume: *const u8, - sess_resume_len: usize, - token: *const u8, - token_sz: usize, - ) -> *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, @@ -174,7 +139,7 @@ unsafe extern "C" { reason: *const c_char, ); pub fn lsquic_conn_get_info(c: *mut lsquic_conn, info: *mut ConnInfo) -> c_int; - pub fn us_nq_conn_transport_params( + pub fn lsquic_conn_transport_params( c: *const lsquic_conn, peer: c_int, out: *mut NqTransportParams, @@ -191,23 +156,18 @@ 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 safe fn us_nq_vtable_size() -> usize; pub safe 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); + /// `driver` is the `us_quic_driver_t` that owns the new engine. pub fn us_nq_engine_new( + driver: *mut c_void, is_server: c_int, is_http: c_int, vt: *mut NqVtable, settings: *const lsquic_engine_settings, - alpn: *const c_char, ) -> *mut lsquic_engine; - pub fn us_nq_spec_dest(s: *const lsquic_out_spec) -> *const sockaddr; - pub fn us_nq_spec_local(s: *const lsquic_out_spec) -> *const sockaddr; - pub fn us_nq_spec_peer_ctx(s: *const lsquic_out_spec) -> *mut c_void; - pub fn us_nq_spec_iov(s: *const lsquic_out_spec, n: *mut usize) -> *const iovec; - pub safe fn us_nq_spec_stride() -> usize; - pub fn us_nq_stream_reset(s: *mut lsquic_stream, code: u64); + pub fn lsquic_stream_force_reset_ext(s: *mut lsquic_stream, code: u64); pub fn us_nq_hset_pairs(hset: *mut c_void, len: *mut usize) -> *const c_char; pub fn us_nq_hset_free(hset: *mut c_void); pub fn us_nq_stream_send_headers( @@ -257,12 +217,11 @@ unsafe extern "C" { blob: *const u8, len: usize, ); - fn us_nq_settings_set_scid_len(s: *mut lsquic_engine_settings, v: c_uint); fn us_nq_settings_set_silent_close(s: *mut lsquic_engine_settings, v: c_int); fn us_nq_settings_set_cc_algo(s: *mut lsquic_engine_settings, v: c_uint); - fn us_nq_settings_set_delay_onclose(s: *mut lsquic_engine_settings, v: c_int); } +#[derive(PartialEq, Eq)] pub struct Settings { bytes: Vec, } @@ -368,10 +327,43 @@ settings_setters! { max_h3_header_pairs => us_nq_settings_set_max_h3_header_pairs : u16, max_h3_header_bytes => us_nq_settings_set_max_h3_header_bytes : c_uint, allow_migration => us_nq_settings_set_allow_migration : c_int, - scid_len => us_nq_settings_set_scid_len : c_uint, silent_close => us_nq_settings_set_silent_close : c_int, cc_algo => us_nq_settings_set_cc_algo : c_uint, - 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 @@ -381,15 +373,18 @@ settings_setters! { pub struct Conn(NonNull); 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 { NonNull::new(raw).map(Self) } pub fn raw(&self) -> *mut lsquic_conn { self.0.as_ptr() } + /// 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.as_ptr()) } + } pub fn close(&self) { // SAFETY: `self.0` is live (caller contract). unsafe { lsquic_conn_close(self.0.as_ptr()) } @@ -428,6 +423,17 @@ impl Conn { // SAFETY: as above. unsafe { lsquic_conn_make_uni_stream(self.0.as_ptr()) } } + /// 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.as_ptr(), 1); + } else { + lsquic_conn_cancel_pending_streams(self.0.as_ptr(), 1); + } + } + } pub fn want_datagram_write(&self, want: bool) -> c_int { // SAFETY: as above. unsafe { lsquic_conn_want_datagram_write(self.0.as_ptr(), want as c_int) } @@ -443,13 +449,6 @@ impl Conn { // SAFETY: `self.0` is a live conn (constructor contract). unsafe { lsquic_conn_pings_received(self.0.as_ptr()) } } - pub fn set_ping_period_us(&self, usec: u64) { - unsafe extern "C" { - fn lsquic_conn_set_ping_period_us(c: *mut lsquic_conn, usec: u64); - } - // SAFETY: `self.0` is a live conn (constructor contract). - unsafe { lsquic_conn_set_ping_period_us(self.0.as_ptr(), usec) } - } pub fn ack_now(&self) { unsafe extern "C" { fn lsquic_conn_ack_now(c: *mut lsquic_conn); @@ -464,6 +463,14 @@ impl Conn { // SAFETY: `self.0` is a live conn (constructor contract). unsafe { lsquic_conn_use_preferred_address(self.0.as_ptr(), 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.as_ptr(), id) } + } pub fn datagram_early(&self) -> bool { unsafe extern "C" { fn lsquic_conn_datagram_early(c: *const lsquic_conn) -> c_int; @@ -481,6 +488,24 @@ impl Conn { // SAFETY: as above. unsafe { lsquic_conn_get_ssl(self.0.as_ptr()) } } + /// 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.as_ptr(), 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.as_ptr(), &raw mut code) } != 0; + (status, message, recorded.then_some(code)) + } } /// Borrowed `lsquic_stream_t`. Same lifetime contract as [`Conn`] — invalid @@ -546,9 +571,25 @@ impl Stream { // SAFETY: as above. unsafe { lsquic_stream_wantwrite(self.0.as_ptr(), 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.as_ptr(), 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.as_ptr()) } + } pub fn reset(&self, code: u64) { // SAFETY: as above. - unsafe { us_nq_stream_reset(self.0.as_ptr(), code) } + unsafe { lsquic_stream_force_reset_ext(self.0.as_ptr(), code) } } pub fn error_code(&self) -> u64 { unsafe extern "C" { @@ -622,6 +663,14 @@ impl Stream { // SAFETY: `self.0` is a live stream (constructor contract). unsafe { lsquic_stream_has_unacked_data(self.0.as_ptr()) != 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.as_ptr()) != 0 } + } pub fn reset_received(&self) -> bool { unsafe extern "C" { fn lsquic_stream_reset_received(s: *const lsquic_stream) -> c_int; @@ -762,8 +811,9 @@ impl Conn { pub fn peer_transport_params(&self) -> Option { let mut out = NqTransportParams::default(); // SAFETY: `self.0` is live; `out` is a stack out-param. - if unsafe { us_nq_conn_transport_params(self.0.as_ptr(), 1, core::ptr::from_mut(&mut out)) } - == 1 + if unsafe { + lsquic_conn_transport_params(self.0.as_ptr(), 1, core::ptr::from_mut(&mut out)) + } == 1 { Some(out) } else { diff --git a/src/picohttp/lib.rs b/src/picohttp/lib.rs index 14a5a878694b..e29d2b1b6fef 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()])) } } } @@ -269,9 +290,7 @@ impl<'a> HeaderList<'a> { pub struct Request<'a> { pub method: &'a [u8], pub path: &'a [u8], - pub minor_version: usize, pub headers: &'a [Header], - pub bytes_read: u32, } impl<'a> Request<'a> { @@ -284,28 +303,6 @@ impl<'a> Request<'a> { } } -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> { request: &'a Request<'a>, ignore_insecure: bool, @@ -333,19 +330,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 { @@ -371,11 +373,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(()) @@ -412,6 +418,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); @@ -484,6 +492,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) @@ -531,10 +541,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, "< {} {}", @@ -544,10 +551,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 d18a88bbb5e8..e15f9007a3e8 100644 --- a/src/resolver/data_url.rs +++ b/src/resolver/data_url.rs @@ -129,6 +129,25 @@ 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, +} + // `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 +165,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; } @@ -165,26 +185,53 @@ impl<'a> DataURL<'a> { } pub fn decode_mime_type(&self) -> bun_http_types::MimeType::MimeType { - bun_http_types::MimeType::MimeType::init(self.mime_type, false, None) + bun_http_types::MimeType::MimeType::init(self.mime_type, false) } /// Decodes the data from the data URL. Always returns an owned slice. pub fn decode_data(&self) -> Result, DecodeDataError> { + // `import` and the bundler also take the URL-safe alphabet. + self.decode_data_with(|base64| { + let decoded = bun_base64::decode_alloc(base64).ok()?; + (decoded.len() == bun_base64::decode_len(base64)).then_some(decoded) + }) + } + + fn decode_data_with( + &self, + decode_base64: impl FnOnce(&[u8]) -> Option>, + ) -> Result, DecodeDataError> { let percent_decoded_owned: Option> = PercentEncoding::decode_unstrict(self.data)?; 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 decode_base64(percent_decoded).ok_or(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_with(|base64| bun_base64::decode_forgiving_alloc(base64).ok()) + .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: bun_http_types::MimeType::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 @@ -416,6 +463,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.rs b/src/runtime/api.rs index 375b112ecd45..0a393a6c643b 100644 --- a/src/runtime/api.rs +++ b/src/runtime/api.rs @@ -100,11 +100,11 @@ pub(crate) mod js_bun_spawn_bindings; #[path = "api/bun/Terminal.rs"] pub(crate) mod bun_terminal_body; -// H2FrameParser — ~338 jsc refs (Strong, JsRef, host_fn getters, AbortSignal). -// From-scratch node:http2 engine rewrite (will replace h2_frame_parser.rs). +// node:http2's protocol state and receive half. #[path = "api/bun/h2/mod.rs"] pub(crate) mod h2; +// node:http2's JS binding, transport and send half. #[path = "api/bun/h2_frame_parser.rs"] pub(crate) mod h2_frame_parser_body; diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index a9e420b63dd9..b702683a0ab7 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -1038,7 +1038,6 @@ pub(crate) fn serve(cx: &mut JsScope<'_>, options: JsLocal<'_>) -> JsResult, options: JsLocal<'_>) -> JsResult {{ // SAFETY: tag was matched; ptr was inserted as `*mut $T` below. let server: &$T = unsafe { &*entry.ptr.cast::<$T>() }; - server.on_reload_from_zig(&mut config, global_object); + server.apply_reload(&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(); @@ -1211,10 +1210,10 @@ pub(crate) fn serve(cx: &mut JsScope<'_>, options: JsLocal<'_>) -> JsResult bun_removable_features::removable_body!(TlsSockets, Abort, { serve_with!(crate::api::DebugHTTPSServer, AnyServerTag::DebugHTTPSServer) }), @@ -1796,9 +1795,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/NativePromiseContext.rs b/src/runtime/api/NativePromiseContext.rs index 2d9b961afc95..38e3444580d9 100644 --- a/src/runtime/api/NativePromiseContext.rs +++ b/src/runtime/api/NativePromiseContext.rs @@ -30,22 +30,16 @@ use crate::api::html_rewriter; use crate::api::server; // Request contexts are a single generic -// `NewRequestContext`; alias the eight +// `NewRequestContext`; alias the eight // concrete monomorphizations here so the tag↔type mapping stays readable. -type HTTPServerRequestContext = server::NewRequestContext; -type HTTPSServerRequestContext = server::NewRequestContext; -type DebugHTTPServerRequestContext = - server::NewRequestContext; -type DebugHTTPSServerRequestContext = - server::NewRequestContext; -type HTTPServerMuxRequestContext = - server::NewRequestContext; -type HTTPSServerMuxRequestContext = - server::NewRequestContext; -type DebugHTTPServerMuxRequestContext = - server::NewRequestContext; -type DebugHTTPSServerMuxRequestContext = - server::NewRequestContext; +type HTTPServerRequestContext = server::NewRequestContext; +type HTTPSServerRequestContext = server::NewRequestContext; +type DebugHTTPServerRequestContext = server::NewRequestContext; +type DebugHTTPSServerRequestContext = server::NewRequestContext; +type HTTPServerMuxRequestContext = server::NewRequestContext; +type HTTPSServerMuxRequestContext = server::NewRequestContext; +type DebugHTTPServerMuxRequestContext = server::NewRequestContext; +type DebugHTTPSServerMuxRequestContext = server::NewRequestContext; /// Must match Bun::NativePromiseContext::Tag in NativePromiseContext.h. /// One entry per concrete native type — the tag is packed into the pointer's @@ -101,9 +95,6 @@ pub(crate) trait NativePromiseContextType { const TAG: Tag; } -// Layering note: blanket-impl over `ThisServer` so that ANY server -// type (mod.rs::NewServer or server_body::NewServer) yields the same Tag — -// the tag depends only on (SSL, DBG, MUX), never on the server type. const fn npc_tag_for(ssl: bool, dbg: bool, mux: bool) -> Tag { match (ssl, dbg, mux) { (false, false, false) => Tag::HTTPServerRequestContext, @@ -116,8 +107,8 @@ const fn npc_tag_for(ssl: bool, dbg: bool, mux: bool) -> Tag { (true, true, true) => Tag::DebugHTTPSServerMuxRequestContext, } } -impl NativePromiseContextType - for server::NewRequestContext +impl NativePromiseContextType + for server::NewRequestContext { const TAG: Tag = npc_tag_for(SSL, DBG, MUX); } diff --git a/src/runtime/api/bun/SSLContextCache.rs b/src/runtime/api/bun/SSLContextCache.rs index 3979d99b408c..e0e7fea8f1b8 100644 --- a/src/runtime/api/bun/SSLContextCache.rs +++ b/src/runtime/api/bun/SSLContextCache.rs @@ -1,4 +1,4 @@ -//! Process/VM-scoped weak cache of `SSL_CTX*` keyed by config digest. +//! Per-VM cache of `SSL_CTX*` keyed by config digest: weak, except for the `KEPT` contexts asked for last that read no file. //! //! The map holds **zero** refs on the cached `SSL_CTX*`. An `SSL_CTX` ex_data //! slot stores a back-pointer to the heap `Entry`; BoringSSL's `CRYPTO_EX_free` @@ -7,7 +7,7 @@ //! `get_or_create` for that digest sees the tombstone and rebuilds. //! //! Race-freedom relies on the per-VM instance only being touched from the JS -//! thread: every consumer's `SSL_CTX_free` (socket close, `owned_ssl_ctx` +//! thread: every consumer's `SSL_CTX_free` (socket close, `SSLWrapper` //! deinit, `SecureContext.finalize`) runs there — JSC sweeps destructible //! objects on the mutator, not heap-helper, thread. The mutex makes the //! tombstone-write / `get_or_create`-load+`up_ref` ordering explicit and @@ -32,9 +32,14 @@ use bun_uws::create_bun_socket_error_t; // `jsc.API.ServerConfig.SSLConfig` — re-exported from src/runtime/socket/SSLConfig.rs use crate::api::server::server_config::SSLConfig; +/// 16 contexts that each parsed all bundled roots take 2.8 MB. +const KEPT: usize = 16; + #[derive(Default)] pub(crate) struct SSLContextCache { map: ArrayHashMap, + /// The contexts asked for last, newest first, so that a program that opens one connection at a time doesn't build its context each time. + kept: [Option; KEPT], mutex: Mutex, ops_since_compact: u32, } @@ -71,30 +76,75 @@ pub(crate) struct Entry { } impl SSLContextCache { - /// The map itself holds no ref. + /// This thread's context for `config`. pub(crate) fn get_or_create( - &mut self, config: &SSLConfig, err: &mut create_bun_socket_error_t, ) -> Option { - let opts = config.as_usockets(); - self.get_or_create_digest(&opts, opts.digest(), err) + Self::get_or_create_opts(&config.as_usockets(), err) } /// Variant for callers that already projected to `BunSocketContextOptions` /// (e.g. via `as_usockets_for_client_verification()`). pub(crate) fn get_or_create_opts( - &mut self, opts: &uws::SocketContext::BunSocketContextOptions, err: &mut create_bun_socket_error_t, ) -> Option { - self.get_or_create_digest(opts, opts.digest(), err) + Self::get_or_create_digest(opts, opts.digest(), err) + } + + /// For a server. One whose options name files reads them again, whoever else holds a context with the same digest. + pub(crate) fn get_or_create_for_server( + opts: &uws::SocketContext::BunSocketContextOptions, + err: &mut create_bun_socket_error_t, + ) -> Option { + if opts.reads_files() { + return opts.create_ssl_context_with_digest(&opts.digest(), err); + } + Self::get_or_create_opts(opts, err) } /// Core entry — `d` already computed by caller. `SecureContext.intern()` /// threads its WeakGCMap key through here so the SHA-256 runs once total /// instead of three times on a miss. pub(crate) fn get_or_create_digest( + opts: &uws::SocketContext::BunSocketContextOptions, + d: Digest, + err: &mut create_bun_socket_error_t, + ) -> Option { + let state = crate::jsc_hooks::runtime_state(); + debug_assert!( + !state.is_null(), + "runtime_state() before init_runtime_state" + ); + // SAFETY: the per-thread `RuntimeState` boxed in `init_runtime_state` is address-stable until VM teardown, and only the JS thread gets here, so this `&mut` is unique until its last use below. + let cache = unsafe { &mut (*state).ssl_ctx_cache }; + let ctx = cache.find_or_build(opts, d, err)?; + // A file can get new content under the same digest, so its context goes with its last user and the next one reads the file again. + if opts.reads_files() { + return Some(ctx); + } + let evicted = cache.keep(&ctx); + // Freeing it can run `bun_ssl_ctx_cache_on_free`, which reaches the cache through the entry, so the borrow has to be over. + drop(evicted); + Some(ctx) + } + + /// Moves `ctx` to the front of `kept`. Returns the context that fell off the end. + fn keep(&mut self, ctx: &OwnedSslCtx) -> Option { + let is_ctx = |kept: &Option| { + kept.as_ref() + .is_some_and(|kept| kept.as_ptr() == ctx.as_ptr()) + }; + if let Some(at) = self.kept.iter().position(is_ctx) { + self.kept[..=at].rotate_right(1); + return None; + } + self.kept.rotate_right(1); + self.kept[0].replace(ctx.clone()) + } + + fn find_or_build( &mut self, opts: &uws::SocketContext::BunSocketContextOptions, d: Digest, @@ -244,8 +294,7 @@ pub(crate) extern "C" fn bun_ssl_ctx_cache_on_free( impl Drop for SSLContextCache { /// VM teardown. Clears each live entry's ex_data so the eventual /// `SSL_CTX_free` (from sockets/SecureContexts that outlive RareData) doesn't - /// dereference the freed `Entry`/map. Map itself holds no refs, so no - /// `SSL_CTX_free` here. + /// dereference the freed `Entry`/map. `kept` lets go of its contexts after this. fn drop(&mut self) { let _guard = self.mutex.lock_guard(); for &entry in self.map.values() { diff --git a/src/runtime/api/bun/SecureContext.bind.ts b/src/runtime/api/bun/SecureContext.bind.ts index 5515c98c5553..ef5ee0589eff 100644 --- a/src/runtime/api/bun/SecureContext.bind.ts +++ b/src/runtime/api/bun/SecureContext.bind.ts @@ -15,6 +15,9 @@ export const SecureContext = b.class({ /** Node's is an opaque V8 External of the `SSL_CTX`, for addons that link OpenSSL. Here it is `undefined`. */ _external: { getter: b.any.returning("undefined"), capability: "heap" }, addCACert: { args: { cert: b.any }, capability: "js", throws: true }, + getTicketKeys: { returns: b.bufferSource, capability: "heap", throws: true }, + /** `node:tls` has checked the type and the length, with Node's messages. */ + setTicketKeys: { args: { keys: b.bytes({ from: ["buffer"] }) }, capability: "heap", throws: true }, }, static: { /** Owns its `SSL_CTX`, so `addCACert` affects nothing else. */ diff --git a/src/runtime/api/bun/SecureContext.rs b/src/runtime/api/bun/SecureContext.rs index a3bff4c5d94f..bd4c7e2e1bce 100644 --- a/src/runtime/api/bun/SecureContext.rs +++ b/src/runtime/api/bun/SecureContext.rs @@ -13,14 +13,16 @@ //! allocates one of these and one `SSL_CTX` total — `tls.ts` no longer hashes //! in JS. +use crate::api::SSLContextCache::SSLContextCache; use crate::crypto::boringssl_jsc::err_to_js; +use crate::socket::SSLConfig; +use crate::socket::ssl_config::ParsedTls; use crate::socket::uws_jsc::create_bun_socket_error_to_js; -use crate::socket::{SSLConfig, SSLConfigFromJs}; use bun_boringssl_sys as boringssl; use bun_core::EncodedSlice; use bun_jsc::EncodedSliceJsc as _; use bun_jsc::JsClass as _; -use bun_jsc::bindgen::{JsHeap, JsLocal, JsPayload as _, JsScope, JsWrapper}; +use bun_jsc::bindgen::{self, BufferSource, JsHeap, JsLocal, JsPayload as _, JsScope, JsWrapper}; use bun_jsc::{JSGlobalObject, JSValue, JsResult}; use bun_uws as uws; use core::cell::Cell; @@ -54,11 +56,31 @@ impl SecureContext { SecureContext::create(global, &config) } + /// The `SSL_CTX` of `options.secureContext`. `None` if `options` isn't an object or names none. + pub(crate) fn ctx_from_options( + global: &JSGlobalObject, + options: JSValue, + ) -> JsResult> { + if !options.is_object() { + return Ok(None); + } + let Some(value) = options.get_truthy(global, "secureContext")? else { + return Ok(None); + }; + match value.as_class_ref::() { + Some(context) => Ok(Some(context.ctx.clone())), + None => Err(global.throw_invalid_argument_type_value( + b"secureContext", + b"SecureContext", + value, + )), + } + } + fn config_from_js(global: &JSGlobalObject, options: JSValue) -> JsResult { - // SAFETY: `bun_vm()` returns the live per-global VM pointer; valid for the call. - let vm = global.bun_vm().as_mut(); + let vm = global.bun_vm(); // Only a server reads `rejectUnauthorized` back from a context (`server_ctx_rejects_unauthorized`). - Ok(SSLConfig::from_js(vm, global, options, true)?.unwrap_or_else(SSLConfig::zero)) + Ok(ParsedTls::from_js(vm, global, options, true)?.or_defaults(vm, true)) } /// `SecureContext.parsePkcs12(pfx, passphrase)` - parses a PKCS#12 blob @@ -270,19 +292,7 @@ impl SecureContext { d: [u8; 32], ) -> JsResult { let mut err = uws::create_bun_socket_error_t::none; - // Note: spec is `global.bunVM().rareData().sslCtxCache()`. In the - // Rust crate split, `bun_jsc::RareData::ssl_ctx_cache()` returns an - // opaque cycle-break stub; the concrete per-VM `SSLContextCache` lives - // on this crate's `RuntimeState` (one per JS thread, same lifetime as - // `RareData`). Reach it via the thread-local — same instance - // `Bun__RareData__sslCtxCache` hands out over FFI. - let state = crate::jsc_hooks::runtime_state(); - debug_assert!(!state.is_null(), "RuntimeState not installed"); - // SAFETY: `state` is the boxed per-thread `RuntimeState` installed by - // `init_runtime_state`; the embedded `ssl_ctx_cache` has a stable - // address for the VM's lifetime and is only touched from the JS thread. - let cache = unsafe { &mut (*state).ssl_ctx_cache }; - let Some(ctx) = cache.get_or_create_digest(ctx_opts, d, &mut err) else { + let Some(ctx) = SSLContextCache::get_or_create_digest(ctx_opts, d, &mut err) else { // `err` is only set for the input-validation paths (bad PEM, missing // file, …). When BoringSSL itself fails (e.g. unsupported curve) the // enum is still `.none`; surface the library error stack instead of @@ -324,33 +334,29 @@ impl SecureContext { /// `secureContext.context.addCACert(pem)` — appends the certificates in /// the given PEM string or buffer to this context's trust store, the way /// Node's SecureContext exposes it. - pub(crate) fn add_ca_cert(&self, cx: &mut JsScope<'_>, cert: JsLocal<'_>) -> JsResult<()> { + pub(crate) fn add_ca_cert<'a>( + &self, + cx: &mut JsScope<'a>, + cert: JsLocal<'a>, + ) -> bindgen::Result<'a, ()> { + let ctx = self.own_ctx(&cx.heap)?; let global = cx.global(); - if self.shared { - return Err(global.throw(format_args!( - "cannot mutate a shared SecureContext; use tls.createSecureContext()" - ))); - } let pem = cert.to_js().to_utf8(global)?; let bytes = pem.slice(); if bytes.is_empty() { - return Err( - global.throw_invalid_arguments(format_args!("addCACert requires a certificate")) - ); + return Err(global + .throw_invalid_arguments(format_args!("addCACert requires a certificate")) + .into()); } // The C side wants a NUL-terminated PEM document. let mut owned = bytes.to_vec(); owned.push(0); - // SAFETY: `self.ctx` is the live SSL_CTX this object owns a reference + // SAFETY: `ctx` is the live SSL_CTX this object owns a reference // to, and `owned` is a NUL-terminated buffer valid for the call. - let ok = unsafe { - c::us_ssl_ctx_add_ca_cert( - self.ctx.as_ptr(), - owned.as_ptr().cast::(), - ) - }; + let ok = + unsafe { c::us_ssl_ctx_add_ca_cert(ctx, owned.as_ptr().cast::()) }; if ok == 0 { - return Err(global.throw(format_args!("Invalid CA certificate"))); + return Err(global.throw(format_args!("Invalid CA certificate")).into()); } // Its trust set changed, so it stops sharing sessions with same-option contexts. let mut folded = [0u8; 32]; @@ -359,23 +365,76 @@ impl SecureContext { hasher.update(bytes); hasher.r#final(&mut folded); self.digest.set(folded); - // SAFETY: `self.ctx` is live; the call copies the 32 bytes. + // SAFETY: `ctx` is live; the call copies the 32 bytes. unsafe { - boringssl::SSL_CTX_set_session_id_context( + boringssl::SSL_CTX_set_session_id_context(ctx, folded.as_ptr(), folded.len()); + } + Ok(()) + } + + /// The `SSL_CTX` for a member that changes it. A digest-interned one also serves other consumers, so it's refused. + fn own_ctx<'a>(&self, heap: &JsHeap<'a>) -> bindgen::Result<'a, *mut boringssl::SSL_CTX> { + if self.shared { + return Err(heap.error(format_args!( + "cannot mutate a shared SecureContext; use tls.createSecureContext()" + ))); + } + Ok(self.ctx.as_ptr()) + } + + /// `secureContext.context.getTicketKeys()`, for `tls.Server#getTicketKeys()`. + pub(crate) fn get_ticket_keys<'a>( + &self, + heap: &JsHeap<'a>, + ) -> bindgen::Result<'a, JsLocal<'a, BufferSource>> { + let mut keys = [0u8; TICKET_KEYS_LEN]; + // SAFETY: `self.ctx` is live and `keys` has the length BoringSSL requires. + let ok = unsafe { + boringssl::SSL_CTX_get_tlsext_ticket_keys( self.ctx.as_ptr(), - folded.as_ptr(), - folded.len(), - ); + 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 || (!self.shared && !self.apply_ticket_keys(&keys)) { + return Err(heap.error(format_args!("Failed to get the session ticket keys"))); + } + heap.create_buffer(&keys) + } + + /// `secureContext.context.setTicketKeys(keys)`, for `tls.Server#setTicketKeys()` and the `ticketKeys` option. + pub(crate) fn set_ticket_keys<'a>( + &self, + heap: &JsHeap<'a>, + keys: &[u8], + ) -> bindgen::Result<'a, ()> { + self.own_ctx(heap)?; + if !self.apply_ticket_keys(keys) { + return Err(heap.error(format_args!("Failed to set the session ticket keys"))); } Ok(()) } + 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 { 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/api/bun/h2/connection.rs b/src/runtime/api/bun/h2/connection.rs index a6ad19360952..6c79eeaed192 100644 --- a/src/runtime/api/bun/h2/connection.rs +++ b/src/runtime/api/bun/h2/connection.rs @@ -1,104 +1,29 @@ -//! HTTP/2 connection engine core (RFC 9113). Generic over a `Sink` (no JSC) so the protocol logic -//! is pure and the JSC binding implements the Sink. Part of the from-scratch rewrite — composes -//! wire/settings/flow_control/hpack/stream with a SINGLE inbound dispatch and centralized -//! §4.2/§6 validation. Connection-level framing lives here; stream-level (HEADERS/DATA/RST) and the -//! outbound request/respond paths build on top of this. - -#![allow(dead_code)] - -use super::flow_control::{RecvWindow, SendWindow}; -use super::hpack; -use super::settings::{self, Settings}; -use super::stream::{self, State}; -use super::wire::{self, ErrorCode, FrameHeader, FrameType, SettingId}; -use crate::api::h2_frame_parser_body::{has_control_byte, has_edge_whitespace}; -use bun_collections::HashMap; -use bun_http_types::parse_content_length_strict; +//! The protocol state of one `node:http2` session. Methods take `&self`, because JS run by a callback or a write can call back in. + +use super::field_checks::{BlockKind, FieldChecks}; +use super::stream::{PaddingStrategy, RequestMethod, ResetBy, Stream, StreamTable, update}; +use bun_http::lshpack; +use bun_http_types::h2::flow_control::{DEFAULT_WINDOW_SIZE, RecvWindow, SendWindow}; +use bun_http_types::h2::frame::{ + self, CONNECTION_PREFACE, ErrorCode, FRAME_HEADER_SIZE, Frame, FrameHeader, FrameType, + NextFrame, flags, +}; +use bun_http_types::h2::header_block::{HeaderBlockAssembler, HeaderBlockError}; +use bun_http_types::h2::settings::{Entries, SettingId, Settings}; +use bun_http_types::h2::stream::{self, State}; +use bun_ptr::JsCell; +use std::cell::Cell; +use std::collections::VecDeque; use std::num::NonZeroU32; - -/// Pseudo-header presence bits shared by the per-field decode loop and the RFC 9113 §8.3.1 -/// request checks in `finish_header_block` (nghttp2's NGHTTP2_HTTP_FLAG__* equivalents). -mod pseudo { - pub(super) const METHOD: u8 = 1; - pub(super) const SCHEME: u8 = 2; - pub(super) const AUTHORITY: u8 = 4; - pub(super) const PATH: u8 = 8; - pub(super) const STATUS: u8 = 16; - pub(super) const PROTOCOL: u8 = 32; - pub(super) const UNKNOWN: u8 = 64; - - /// Whether nghttp2 takes the value of a pseudo-header (nghttp2_check_method, - /// nghttp2_check_path, check_scheme, check_authority, parse_status_code). - pub(super) fn is_valid_value(bit: u8, value: &[u8]) -> bool { - let all = |extra: &[u8]| { - value - .iter() - .all(|c| c.is_ascii_alphanumeric() || extra.contains(c)) - }; - match bit { - METHOD => all(b"!#$%&'*+-.^_`|~"), - PATH => value.iter().all(|&c| c > b' ' && c != 0x7f), - SCHEME => value.first().is_some_and(u8::is_ascii_alphabetic) && all(b"+-."), - AUTHORITY => all(b"!$%&'()*+,-.:;=[]_~"), - // Three digits from 100 up. HTTP/2 has no 101 (RFC 9113 §8.6). - STATUS => matches!(value, [b'1'..=b'9', b'0'..=b'9', b'0'..=b'9']) && value != b"101", - _ => true, - } - } -} - -/// Snapshot of the local-settings values carried by one sent-but-unACKed SETTINGS frame, so an -/// inbound ACK is attributed to the submission it actually acknowledges (RFC 9113 §6.5.3) rather -/// than to the latest submission. -#[derive(Clone, Copy)] -pub(crate) struct PendingLocalSettings { - pub settings: Settings, -} - -/// Per-stream protocol state tracked by the engine. -pub(crate) struct Stream { - pub state: State, - pub send_window: SendWindow, - pub recv_window: RecvWindow, - /// A non-informational inbound header block was delivered: the next inbound HEADERS on this - /// stream is a trailer section (RFC 9113 §8.1). - pub recv_final_headers: bool, - /// A request's header section had a `host`: its trailer section cannot have another. - pub recv_host: bool, - /// Declared `content-length` of the inbound message, if any (RFC 9113 §8.1.1). - pub content_length: Option, - /// DATA payload bytes (padding excluded) received so far. - pub recv_body_bytes: u64, - /// The embedder had already sent GOAWAY when the peer opened this stream. - pub opened_after_goaway: bool, -} - -impl Stream { - fn new(initial_send: u32, initial_recv: u32) -> Self { - Stream { - state: State::Idle, - send_window: SendWindow::new(initial_send), - recv_window: RecvWindow::new(initial_recv), - recv_final_headers: false, - recv_host: false, - content_length: None, - recv_body_bytes: 0, - opened_after_goaway: false, - } - } - - /// RFC 9113 §8.1.1 (nghttp2_http_on_data_chunk): more DATA than the declared `content-length`. - fn body_exceeds_content_length(&self) -> bool { - self.content_length - .is_some_and(|declared| self.recv_body_bytes > declared) - } -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(crate) enum WriteResult { - Dropped = -1, - Queued = 0, - Sent = 1, +use std::rc::Rc; + +/// nghttp2's negative library error codes. JS builds node's `NghttpError` from them. +pub(crate) mod lib_error { + pub(crate) const PROTO: i32 = -505; + pub(crate) const STREAM_CLOSED: i32 = -510; + pub(crate) const BAD_CLIENT_MAGIC: i32 = -903; + pub(crate) const FLOODED: i32 = -904; + pub(crate) const TOO_MANY_CONTINUATIONS: i32 = -905; } /// Outcome of feeding bytes: how many were consumed, and whether the connection is now closing. @@ -108,6 +33,14 @@ enum StreamedDataStart { Consumed(usize), } +enum DataAdmission { + Fatal, + /// The frame is for a closed stream, or was just answered with RST_STREAM. + Discard, + Deliver, +} + +#[derive(Clone, Copy)] struct DataInFlight { stream_id: u32, /// Payload bytes (incl. trailing padding) not yet received. @@ -119,17 +52,9 @@ struct DataInFlight { discard: bool, } -/// Header block whose END_HEADERS has not arrived yet (RFC 9113 §4.3). Its fragments accumulate -/// in `Connection::header_block`. -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, - meta: HeaderBlockMeta, -} - /// Everything `finish_header_block` needs to know about a header block besides its bytes, fixed /// by the HEADERS/PUSH_PROMISE frame that opened it. +#[derive(Clone, Copy)] struct HeaderBlockMeta { /// The stream id the block belongs to (the promised id for a PUSH_PROMISE). target: u32, @@ -151,33 +76,48 @@ 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). - Refused, + /// The stream would go over SETTINGS_MAX_CONCURRENT_STREAMS. RST_STREAM(REFUSED_STREAM) has been sent. + OverStreamLimit, + /// The session is over node's maxSessionMemory. RST_STREAM(ENHANCE_YOUR_CALM) follows the block. + OverMemoryLimit, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum FeedEnd { + /// Every complete frame of the input was handled. + Drained, + /// `Sink::should_stop()` ended the call between frames, before any window was replenished. + Yielded, + Fatal, } pub(crate) struct Feed { pub consumed: usize, - pub fatal: bool, + pub end: FeedEnd, } /// 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; +/// What a stream counts toward maxSessionMemory, for its table entry and its `Http2Stream`. Node counts as much: its 10 MB hold 13,866 streams. +const STREAM_MEMORY_COST: usize = 756; + +/// 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; +const DEFAULT_STREAM_RESET_BURST: u32 = 1000; +const DEFAULT_STREAM_RESET_RATE: u32 = 33; -#[derive(Clone, Copy)] -enum ResetBy { - Peer, - Us, -} +/// nghttp2's NGHTTP2_DEFAULT_GLITCH_BURST / _RATE. +const GLITCH_BURST: u32 = 10_000; +const GLITCH_RATE: u32 = 330; /// nghttp2_ratelim: `rate` tokens per whole elapsed second, capped at `burst`. +#[derive(Clone, Copy)] struct RateLimit { burst: u32, rate: u32, @@ -213,353 +153,319 @@ impl RateLimit { } } -/// What the connection engine calls back into the embedder (the JSC binding) for. Methods take -/// `&self`: the JSC binding (H2FrameParser) is fully interior-mutable (Cell/JsCell) and its host -/// functions receive `&Self`, so it can own the `Connection` and pass itself as the sink without an -/// ownership cycle. +/// What the engine needs from its owner: a way to write frames, and a listener for what the peer's frames mean. pub(crate) trait Sink { - fn write(&self, bytes: &[u8]) -> WriteResult; - /// A locally-detected connection error: the GOAWAY (when one applies) is already on the wire. - /// `lib_error_code` is the negative nghttp2-style library error code (`wire::lib_error`) the - /// embedder maps to node's NghttpError. - fn on_error(&self, lib_error_code: i32, last_stream_id: u32, debug: &[u8]); + /// Writes one frame, whose payload is `payload` joined. Returns false under backpressure. + fn write_frame( + &self, + frame_type: FrameType, + flags: u8, + stream_id: u32, + payload: &[&[u8]], + ) -> bool; + /// The bytes that wait to be written, which count toward node's maxSessionMemory. + fn buffered_output(&self) -> usize; + /// The owner wants control back before the next frame. `receive` returns `FeedEnd::Yielded`. + fn should_stop(&self) -> bool; + + /// This engine detected a connection error and has written the GOAWAY. `lib_error_code` is one of `lib_error`. + fn on_error(&self, lib_error_code: i32, debug: &[u8]); + /// The peer went over node's maxSessionInvalidFrames, which ends the session with ERR_HTTP2_TOO_MANY_INVALID_FRAMES. + fn on_too_many_invalid_frames(&self); fn on_local_settings(&self, settings: &Settings); fn on_remote_settings(&self, settings: &Settings); + /// A SETTINGS entry with an id outside the standard registry (node's remoteCustomSettings). + fn on_remote_custom_setting(&self, id: u16, value: u32); 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). + /// `code` is the raw value from the wire, so that JS sees unknown codes like node does. 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); - - /// 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. - 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) {} - /// 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 - /// is allocated; the header block is still decoded for HPACK-table sync (§4.3). - fn can_open_stream(&self) -> bool { - true - } - /// Queried per use: a GOAWAY sent mid-dispatch counts for the rest of that read. - fn goaway_sent(&self) -> bool { - false - } - /// A SETTINGS entry with an id outside the standard registry (node's remoteCustomSettings). - fn on_remote_custom_setting(&self, _id: u16, _value: 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. - fn on_headers_complete(&self, _stream_id: u32, _end_stream: bool, _flags: u8) {} - /// A DATA payload (padding already stripped). - fn on_data(&self, _stream_id: u32, _data: &[u8]) {} - /// The stream half/fully closed; `state` is the `stream::State` integer. - fn on_stream_end(&self, _stream_id: u32, _state: u8) {} - /// The stream was reset (inbound RST_STREAM or a local stream error). `code` is the raw - /// u32 from the wire so unknown error codes survive to JS (node parity). - fn on_stream_reset(&self, _stream_id: u32, _code: u32) {} - /// A locally-initiated stream rejection (oversized/malformed header block) - distinct from - /// peer-sent resets so the embedder can budget rejections (maxSessionRejectedStreams). - fn on_stream_rejected(&self, _stream_id: u32) {} - /// A server PUSH_PROMISE reserved `promised_id` on behalf of `parent_id`. Fires before the - /// promised request's on_header/on_headers_complete (which use `promised_id`). - fn on_push_promise(&self, _parent_id: u32, _promised_id: u32) {} + /// A send window grew, so queued DATA may fit now. + fn on_send_window_increased(&self); /// RFC 7838 ALTSVC. - fn on_altsvc(&self, _stream_id: u32, _origin: &[u8], _value: &[u8]) {} - /// RFC 8336 ORIGIN — the full frame payload (a sequence of 2-byte-length-prefixed origins), - /// delivered once per frame so the embedder can surface them as a single event. - fn on_origin(&self, _payload: &[u8]) {} - /// The peer exceeded the session's invalid-frame allowance (node's maxSessionInvalidFrames): - /// the embedder should destroy the session with ERR_HTTP2_TOO_MANY_INVALID_FRAMES. - fn on_too_many_invalid_frames(&self) {} - /// Frame-counter update (perf_hooks http2 session stats). Called whenever either - /// counter moves, while the connection is mutably borrowed — the embedder must only - /// store the values. - fn on_frame_counters(&self, _received: u64, _sent: u64) {} - /// Transition shim while the outbound path still flows through the embedder's legacy encoder: - /// returns true if `stream_id` was initiated locally (HEADERS already sent by the embedder), so - /// inbound frames for it are not treated as frames on an idle stream. - fn is_local_stream(&self, _stream_id: u32) -> bool { - false - } - /// 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 - /// (nghttp2's session_detect_idle_stream uses the same allocation high-water marks). - fn highest_started_stream_id(&self) -> u32 { - 0 - } - /// Whether the embedder's reader for `stream_id` is currently consuming data. While it is - /// paused, the stream's receive window is not replenished (mirrors node, where - /// nghttp2_session_consume_stream is only called while the JS readable is flowing) so the - /// peer is backpressured instead of buffering unboundedly. Connection-level replenishment is - /// unaffected. - fn is_stream_reading(&self, _stream_id: u32) -> bool { - true - } + fn on_altsvc(&self, stream_id: u32, origin: &[u8], value: &[u8]); + /// RFC 8336 ORIGIN. `payload` is the whole frame's, so that its origins make one event. + fn on_origin(&self, payload: &[u8]); + + /// An inbound HEADERS opened a stream. + fn on_stream_open(&self, stream: &Stream); + /// A PUSH_PROMISE on `parent_id` reserved `promised_id`. The promised request's fields follow under that id. + fn on_push_promise(&self, parent_id: u32, promised_id: u32); + /// One decoded header field. `name` and `value` point into a shared buffer, so copy them before returning. + fn on_header(&self, stream_id: u32, name: &[u8], value: &[u8], never_index: bool); + /// The block is refused, so the fields `on_header` got for it are dropped. + fn on_headers_refused(&self); + /// The header block for `stream_id` is complete. `flags` are those of its HEADERS frame. + fn on_headers_complete(&self, stream_id: u32, flags: u8); + /// The payload of a DATA frame, or a part of it, without padding. + fn on_data(&self, stream_id: u32, data: &[u8]); + /// The peer's END_STREAM arrived. The owner applies it to the table once JS has seen what came before it. + fn on_stream_end(&self, stream: &Rc); + /// The peer's RST_STREAM or a stream error this engine detected closed the stream. + fn on_stream_reset(&self, stream: &Rc, code: u32); + /// This engine refused a request, which counts toward node's maxSessionRejectedStreams. + fn on_stream_rejected(&self); } pub(crate) struct Connection { - pub is_server: bool, - /// Wire frames fully accepted from the peer (perf_hooks http2 session stats). - pub frames_received: u64, - /// Wire frames this engine itself has written (the embedder counts its own). - pub frames_sent: u64, - - 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, - /// Invalid frames tolerated before the session is torn down (node's maxSessionInvalidFrames - /// session option; not a SETTINGS parameter). An empty DATA frame without END_STREAM counts - /// as an invalid frame. - pub max_invalid_frames: u32, - /// node's strictFieldWhitespaceValidation session option (not a SETTINGS parameter). - pub strict_field_whitespace: bool, - /// The initial window size the peer has ACKed (RFC 9113 6.5.3): until our SETTINGS is ACKed, - /// the peer may legitimately send according to the previous value - enforce the larger. - acked_local_initial_window: u32, - /// SETTINGS_MAX_HEADER_LIST_SIZE value enforced on received header blocks. nghttp2 only - /// applies a submitted local SETTINGS value once the peer ACKs it, so a header block that was - /// already in flight when we lowered the limit must not be rejected (node's - /// test-http2-session-settings submits maxHeaderListSize=1 mid-request and still receives the - /// response). Starts at the creation-time value the connection preface advertises and is - /// updated on each SETTINGS ACK. - pub enforced_max_header_list_size: u32, - /// Local-settings snapshots of sent-but-unACKed SETTINGS frames, in send order. §6.5.3: - /// ACKs apply to outstanding SETTINGS in order, so each inbound ACK pops the front - the - /// values the peer actually acknowledged - rather than assuming the latest submission. - pub pending_local_settings_acks: std::collections::VecDeque, - invalid_frame_count: u32, - /// Maximum number of entries accepted in a single inbound SETTINGS frame (node's maxSettings - /// session option, nghttp2's max_settings; not a SETTINGS parameter). Frames carrying more - /// entries are a connection error (ENHANCE_YOUR_CALM, like nghttp2's flood guard). - pub max_settings: u32, - - /// Connection-level flow control (§6.9.1). The connection window is fixed at 65535 initially - /// and is NOT affected by SETTINGS_INITIAL_WINDOW_SIZE (that governs streams only). - pub send_window: SendWindow, - pub recv_window: RecvWindow, - - pub hpack: hpack::Coder, - - pub streams: HashMap, - - /// Header-block reassembly across CONTINUATION (RFC 9113 §4.3): `Some` from a HEADERS or - /// PUSH_PROMISE without END_HEADERS until the CONTINUATION carrying END_HEADERS. - header_block_in_flight: Option, - /// The fragments of the current header block; kept (emptied) across blocks for its capacity. - header_block: Vec, - /// In-progress partial DATA frame streamed incrementally. nghttp2 delivers DATA in - /// chunks as bytes arrive (node emits 'data' for a partial frame); buffering until the - /// frame completes would stall consumers behind a peer that trickles one large frame. - data_in_flight: Option, - /// A locally-detected connection error already tore the session down: ignore further input. - terminated: bool, - /// PING/SETTINGS ACKs queued behind a non-reading peer (nghttp2's - /// obq_flood_counter_). Reset only via note_outbound_drained() when the - /// embedder confirms its outbound buffer emptied — never per receive(). - obq_ack_pending: u32, + pub is_server: Cell, + /// Frames accepted from the peer, for the perf_hooks session entry. + pub frames_received: Cell, + + pub local_settings: Cell, + pub remote_settings: Cell, + /// node's maxHeaderListPairs. A block with more fields is refused like an oversized header list. + pub max_header_list_pairs: Cell, + /// node's maxSessionMemory, in bytes. + pub max_session_memory: Cell, + /// node's maxSessionInvalidFrames. + pub max_invalid_frames: Cell, + /// node's strictFieldWhitespaceValidation. + pub strict_field_whitespace: Cell, + /// The SETTINGS_INITIAL_WINDOW_SIZE the peer has ACKed. Until it ACKs a smaller one it may go by this one (§6.5.3). + acked_local_initial_window: Cell, + /// The SETTINGS_MAX_FRAME_SIZE the peer has ACKed. Until it ACKs a smaller one it may go by this one (§6.5.3). + acked_max_frame_size: Cell, + /// The SETTINGS_MAX_HEADER_LIST_SIZE the peer has ACKed. Like nghttp2, a submitted value applies from its ACK on. + pub enforced_max_header_list_size: Cell, + /// The values of each SETTINGS frame the peer hasn't ACKed yet, in send order (§6.5.3). + pub pending_local_settings_acks: JsCell>, + invalid_frame_count: Cell, + /// node's maxSettings: the entries accepted in one SETTINGS frame. + pub max_settings: Cell, + + /// SETTINGS_INITIAL_WINDOW_SIZE doesn't move the connection windows (§6.9.2). + pub send_window: Cell, + pub recv_window: Cell, + + pub hpack: JsCell, + /// node's paddingStrategy, which every new stream starts with. + pub padding_strategy: Cell, + + pub streams: StreamTable, + + header_block: JsCell, + /// What to do with the open header block once its last CONTINUATION arrives. + open_block_meta: Cell>, + /// A DATA frame whose payload is still arriving. Like nghttp2, its data is delivered as it comes. + data_in_flight: Cell>, + /// A connection error this side detected ended the session, so further input is ignored. + terminated: Cell, + /// PING and SETTINGS ACKs queued behind a peer that doesn't read (nghttp2's obq_flood_counter_). + obq_ack_pending: Cell, /// Inbound RST_STREAM (nghttp2's stream_reset_ratelim; node's streamResetBurst/Rate). - stream_reset_limit: RateLimit, + stream_reset_limit: Cell, /// RST_STREAM this engine sends that cancels a delivered request (cf. nghttp2's glitch_ratelim). - sent_reset_limit: RateLimit, + sent_reset_limit: Cell, /// A bucket ran dry mid-frame; receive() tears the session down between frames. - reset_flood: bool, - - /// Scratch buffer for the outbound HPACK-encoded header block. - enc_buf: Vec, - /// Reusable scratch for end-of-batch window replenishment (stream id, increment). - replenish_buf: Vec<(u32, u32)>, - /// Reused buffer for evicting closed streams after each receive pass (no per-call allocation). - evict_buf: Vec, - - preface_received: usize, - pub last_stream_id: u32, - pub going_away: bool, + reset_flood: Cell, + /// Frames and header blocks dropped without a reply (nghttp2's glitch_ratelim). + glitch_limit: Cell, + + /// The streams whose receive window got half used since `replenish_windows` last ran, so it doesn't have to walk the table. + half_used_windows: JsCell>, + + preface_received: Cell, + /// The id the next stream we open follows: the highest we have opened, or that a server has accepted. + pub last_stream_id: Cell, + /// Highest stream id the peer has opened or promised (§5.1.1). Our GOAWAY names it. + pub last_peer_id: Cell, + /// We sent a GOAWAY. + pub goaway_sent: Cell, } -impl Connection { - pub(crate) fn new(is_server: bool, local: Settings) -> Self { +impl Default for Connection { + fn default() -> Self { + let reset_limit = RateLimit::new(DEFAULT_STREAM_RESET_BURST, DEFAULT_STREAM_RESET_RATE); 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, - max_invalid_frames: 1000, - strict_field_whitespace: true, - acked_local_initial_window: 65_535, - enforced_max_header_list_size: local.max_header_list_size, - pending_local_settings_acks: std::collections::VecDeque::new(), - invalid_frame_count: 0, - 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), - streams: HashMap::new(), - header_block_in_flight: None, - header_block: Vec::new(), - data_in_flight: None, - terminated: false, - obq_ack_pending: 0, - stream_reset_limit: RateLimit::new( - DEFAULT_STREAM_RESET_BURST, - DEFAULT_STREAM_RESET_RATE, - ), - sent_reset_limit: RateLimit::new(DEFAULT_STREAM_RESET_BURST, DEFAULT_STREAM_RESET_RATE), - reset_flood: false, - enc_buf: Vec::new(), - replenish_buf: Vec::new(), - evict_buf: Vec::new(), - preface_received: 0, - last_stream_id: 0, - going_away: false, + is_server: Cell::new(false), + local_settings: Cell::new(Settings::default()), + remote_settings: Cell::new(Settings::default()), + max_header_list_pairs: Cell::new(128), + frames_received: Cell::new(0), + max_session_memory: Cell::new(10 * 1024 * 1024), + max_invalid_frames: Cell::new(1000), + strict_field_whitespace: Cell::new(true), + acked_local_initial_window: Cell::new(DEFAULT_WINDOW_SIZE), + acked_max_frame_size: Cell::new(Settings::default().max_frame_size), + enforced_max_header_list_size: Cell::new(Settings::default().max_header_list_size), + pending_local_settings_acks: JsCell::new(VecDeque::new()), + invalid_frame_count: Cell::new(0), + max_settings: Cell::new(32), + send_window: Cell::new(SendWindow::new(DEFAULT_WINDOW_SIZE)), + recv_window: Cell::new(RecvWindow::new(DEFAULT_WINDOW_SIZE)), + hpack: JsCell::new(lshpack::Coder::default()), + padding_strategy: Cell::new(PaddingStrategy::None), + streams: StreamTable::default(), + header_block: JsCell::new(HeaderBlockAssembler::default()), + open_block_meta: Cell::new(None), + data_in_flight: Cell::new(None), + terminated: Cell::new(false), + obq_ack_pending: Cell::new(0), + stream_reset_limit: Cell::new(reset_limit), + sent_reset_limit: Cell::new(reset_limit), + reset_flood: Cell::new(false), + glitch_limit: Cell::new(RateLimit::new(GLITCH_BURST, GLITCH_RATE)), + half_used_windows: JsCell::new(Vec::new()), + preface_received: Cell::new(0), + last_stream_id: Cell::new(0), + last_peer_id: Cell::new(0), + goaway_sent: Cell::new(false), } } +} + +impl Connection { + /// A new entry with the windows both sides currently go by (§6.9.2). + pub(crate) fn insert_stream(&self, stream_id: u32, state: State) -> Rc { + let stream = Stream::new( + stream_id, + stream_id.is_multiple_of(2) == self.is_server.get(), + state, + self.remote_settings.get().initial_window_size, + self.acked_local_initial_window.get(), + ); + stream.padding_strategy.set(self.padding_strategy.get()); + self.streams.insert(stream) + } + + pub(crate) fn header_block_capacity(&self) -> usize { + self.header_block.get().capacity() + } + + /// node's IsAvailableSessionMemory. New streams are refused while this returns true. + pub(crate) fn is_over_memory_limit(&self, sink: &impl Sink) -> bool { + sink.buffered_output() + self.streams.len() * STREAM_MEMORY_COST + > self.max_session_memory.get() + } // ---- Outbound ------------------------------------------------------- - fn write_frame( - &mut self, - sink: &impl Sink, - ftype: FrameType, - flags: u8, - stream_id: u32, - payload: &[u8], - ) { - self.frames_sent += 1; - sink.on_frame_counters(self.frames_received, self.frames_sent); - let mut hdr_buf = [0u8; wire::FRAME_HEADER_SIZE]; - let hdr = FrameHeader { - length: payload.len() as u32, - frame_type: ftype as u8, - flags, - stream_id, - }; - hdr.write(&mut hdr_buf); - sink.write(&hdr_buf); - if !payload.is_empty() { - sink.write(payload); - } + fn send_settings_ack(&self, sink: &impl Sink) { + sink.write_frame(FrameType::Settings, flags::ACK, 0, &[]); } - fn send_settings_ack(&mut self, sink: &impl Sink) { - self.write_frame(sink, FrameType::Settings, wire::flags::ACK, 0, &[]); + fn send_ping_ack(&self, sink: &impl Sink, payload: &[u8]) { + sink.write_frame(FrameType::Ping, flags::ACK, 0, &[payload]); } - fn send_ping_ack(&mut self, sink: &impl Sink, payload: &[u8]) { - self.write_frame(sink, FrameType::Ping, wire::flags::ACK, 0, payload); + /// RFC 9113 §6.8: GOAWAY goes out on stream 0 and names the last stream of the peer's that we may have acted on. + pub(crate) fn write_go_away( + &self, + sink: &impl Sink, + last_stream_id: u32, + code: ErrorCode, + debug: &[u8], + ) { + self.goaway_sent.set(true); + sink.write_frame( + FrameType::GoAway, + 0, + 0, + &[ + &(last_stream_id & frame::MAX_STREAM_ID).to_be_bytes(), + &code.0.to_be_bytes(), + debug, + ], + ); } /// Locally-detected connection error: send GOAWAY, mark the session terminated (further input /// is ignored, like nghttp2 after terminate_session), and surface it to the embedder with the /// nghttp2-style `lib_code` so JS can build node's NghttpError. fn local_connection_error( - &mut self, + &self, sink: &impl Sink, code: ErrorCode, lib_code: i32, debug: &[u8], ) { - self.going_away = true; - self.terminated = true; - let mut payload = Vec::with_capacity(8 + debug.len()); - payload.extend_from_slice(&self.last_stream_id.to_be_bytes()); - payload.extend_from_slice(&code.as_u32().to_be_bytes()); - payload.extend_from_slice(debug); - self.write_frame(sink, FrameType::GoAway, 0, 0, &payload); - let last = self.last_stream_id; - sink.on_error(lib_code, last, debug); + self.terminated.set(true); + self.write_go_away(sink, self.last_peer_id.get(), code, debug); + sink.on_error(lib_code, debug); } /// Connection error with the generic NGHTTP2_ERR_PROTO library code — the same thing node /// reports ("Protocol error") whenever nghttp2 terminates a session internally without a more /// specific callback (node's `internal_goaway_sent_` path in OnFrameSent/SendPendingData). - pub(crate) fn send_go_away(&mut self, sink: &impl Sink, code: ErrorCode, debug: &[u8]) { - self.local_connection_error(sink, code, wire::lib_error::PROTO, debug); + pub(crate) fn send_go_away(&self, sink: &impl Sink, code: ErrorCode, debug: &[u8]) { + self.local_connection_error(sink, code, lib_error::PROTO, debug); } - fn send_window_update(&mut self, sink: &impl Sink, stream_id: u32, increment: u32) { - self.write_frame( - sink, + pub(crate) fn send_window_update(&self, sink: &impl Sink, stream_id: u32, increment: u32) { + sink.write_frame( FrameType::WindowUpdate, 0, stream_id, - &increment.to_be_bytes(), + &[&increment.to_be_bytes()], ); } - fn send_rst_stream(&mut self, sink: &impl Sink, stream_id: u32, code: ErrorCode) { - self.write_frame( - sink, - FrameType::RstStream, - 0, - stream_id, - &code.as_u32().to_be_bytes(), - ); - // MadeYouReset cancels a delivered request. A late frame on a closed stream cancels nothing. - let delivered = |s: &Stream| s.recv_final_headers && s.state != State::Closed; - if self.streams.get(&stream_id).is_some_and(delivered) { + fn send_rst_stream(&self, sink: &impl Sink, stream_id: u32, code: ErrorCode) { + sink.write_frame(FrameType::RstStream, 0, stream_id, &[&code.0.to_be_bytes()]); + // MadeYouReset makes us cancel a request the handler was given. A second reset cancels nothing. + if self + .streams + .inbound(stream_id) + .is_some_and(|s| s.recv_final_headers.get() && !s.was_reset()) + { self.note_reset(ResetBy::Us); } } + /// A stream error this engine detected: RST_STREAM, then the stream is closed and the embedder hears about it. + fn reset_stream(&self, sink: &impl Sink, stream_id: u32, code: ErrorCode) { + self.send_rst_stream(sink, stream_id, code); + if let Some(s) = self.streams.inbound(stream_id) { + s.reset.set(s.reset.get().or(Some(ResetBy::Us))); + if self.streams.close(&s) { + sink.on_stream_reset(&s, code.0); + } + } + } + // ---- Inbound -------------------------------------------------------- /// Feed received bytes. Processes every complete frame and returns `consumed` = the offset of /// the first incomplete frame (the caller keeps `bytes[consumed..]` and re-presents it prepended /// to the next chunk — the engine holds no reassembly buffer). - pub(crate) fn receive(&mut self, sink: &impl Sink, bytes: &[u8]) -> Feed { + pub(crate) fn receive(&self, sink: &impl Sink, bytes: &[u8]) -> Feed { let mut offset = 0usize; // A locally-detected connection error already tore the session down (GOAWAY sent, error // surfaced): everything after it is discarded so no further streams/frames reach JS. - if self.terminated { + if self.terminated.get() { return Feed { consumed: bytes.len(), - fatal: true, + end: FeedEnd::Fatal, }; } // §3.4: server validates the 24-octet client preface before any frame. - if self.is_server && self.preface_received < wire::CONNECTION_PREFACE.len() { - let need = wire::CONNECTION_PREFACE.len() - self.preface_received; + let preface_received = self.preface_received.get(); + if self.is_server.get() && preface_received < CONNECTION_PREFACE.len() { + let need = CONNECTION_PREFACE.len() - preface_received; let avail = need.min(bytes.len()); - let expect = - &wire::CONNECTION_PREFACE[self.preface_received..self.preface_received + avail]; + let expect = &CONNECTION_PREFACE[preface_received..preface_received + avail]; if &bytes[..avail] != expect { // node: nghttp2_session_mem_recv returns NGHTTP2_ERR_BAD_CLIENT_MAGIC and the // session errors with "Received bad client magic byte string". self.local_connection_error( sink, - ErrorCode::ProtocolError, - wire::lib_error::BAD_CLIENT_MAGIC, + ErrorCode::PROTOCOL_ERROR, + lib_error::BAD_CLIENT_MAGIC, b"invalid connection preface", ); return Feed { consumed: avail, - fatal: true, + end: FeedEnd::Fatal, }; } - self.preface_received += avail; + self.preface_received.set(preface_received + avail); offset += avail; - if self.preface_received < wire::CONNECTION_PREFACE.len() { + if preface_received + avail < CONNECTION_PREFACE.len() { return Feed { consumed: offset, - fatal: false, + end: FeedEnd::Drained, }; } } @@ -574,55 +480,52 @@ impl Connection { inflight.payload_remaining -= avail as u32; offset += avail; if inflight.payload_remaining == 0 { - self.finish_streamed_data(sink, &inflight); + if inflight.end_stream && !inflight.discard { + self.end_inbound_data(sink, inflight.stream_id); + } } else { - self.data_in_flight = Some(inflight); + self.data_in_flight.set(Some(inflight)); + if sink.should_stop() { + return Feed { + consumed: offset, + end: FeedEnd::Yielded, + }; + } self.replenish_windows(sink); return Feed { consumed: offset, - fatal: false, + end: FeedEnd::Drained, }; } } loop { - let remaining = &bytes[offset..]; - if remaining.len() < wire::FRAME_HEADER_SIZE || sink.should_stop() { - break; - } - let hdr = FrameHeader::parse(remaining); - // RFC 9113 4.2: refuse a frame whose declared length exceeds SETTINGS_MAX_FRAME_SIZE - // before buffering its payload - waiting for the full frame first would let a peer - // make us hold up to 16 MiB per connection on a 9-byte header. - if hdr.length > self.local_settings.max_frame_size { - self.send_go_away( - sink, - ErrorCode::FrameSizeError, - b"frame exceeds SETTINGS_MAX_FRAME_SIZE", - ); + // 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: true, + end: FeedEnd::Yielded, }; } - let total = wire::FRAME_HEADER_SIZE + hdr.length as usize; - if remaining.len() < total { - // Incomplete DATA frames stream incrementally (see DataInFlight); everything - // else waits for the full frame. - if matches!(hdr.typ(), Some(FrameType::Data)) - && self.header_block_in_flight.is_none() - && hdr.stream_id != 0 - && hdr.length > 0 - { - let padded = wire::flags::has(hdr.flags, wire::flags::PADDED); - let avail_payload = remaining.len() - wire::FRAME_HEADER_SIZE; - // With PADDED set the first payload octet is Pad Length; wait for it. - if !padded || avail_payload >= 1 { - match self.begin_streamed_data(sink, &hdr, remaining, padded) { + let max_frame_size = self + .acked_max_frame_size + .get() + .max(self.local_settings.get().max_frame_size); + let (hdr, payload) = match frame::next_frame(&bytes[offset..], max_frame_size) { + Ok(NextFrame::Complete { header, payload }) => (header, payload), + Ok(NextFrame::NeedHeader) => break, + Ok(NextFrame::Partial { header, payload }) => { + // A DATA frame is delivered as it arrives. A PADDED one waits for its Pad Length octet. + if header.frame_type == FrameType::Data as u8 + && self.header_block.get().admits(&header) + && header.stream_id != 0 + && (!header.has(flags::PADDED) || !payload.is_empty()) + { + match self.begin_streamed_data(sink, &header, payload) { StreamedDataStart::Fatal => { return Feed { consumed: offset, - fatal: true, + end: FeedEnd::Fatal, }; } StreamedDataStart::Consumed(n) => { @@ -630,28 +533,41 @@ impl Connection { } } } + break; } - break; - } - let payload = &remaining[wire::FRAME_HEADER_SIZE..total]; + Err(e) => { + self.send_go_away(sink, e.code, e.reason.as_bytes()); + return Feed { + consumed: offset, + end: FeedEnd::Fatal, + }; + } + }; + let total = FRAME_HEADER_SIZE + payload.len(); if self.dispatch(sink, &hdr, payload) { return Feed { consumed: offset + total, - fatal: true, + end: FeedEnd::Fatal, }; } offset += total; if self.check_reset_flood(sink) { return Feed { consumed: offset, - fatal: true, + end: FeedEnd::Fatal, }; } } if self.check_reset_flood(sink) { return Feed { consumed: offset, - fatal: true, + end: FeedEnd::Fatal, + }; + } + if sink.should_stop() { + return Feed { + consumed: offset, + end: FeedEnd::Yielded, }; } // Re-open consumed receive windows once per batch (RFC 9113 §6.9; mirrors how the @@ -660,210 +576,177 @@ impl Connection { self.replenish_windows(sink); Feed { consumed: offset, - fatal: false, + end: FeedEnd::Drained, } } /// Send WINDOW_UPDATE for every receive window that has consumed at least half its size. - fn replenish_windows(&mut self, sink: &impl Sink) { - if self.recv_window.needs_update() { - let inc = self.recv_window.take_update(); + fn replenish_windows(&self, sink: &impl Sink) { + if self.recv_window.get().needs_update() { + let inc = update(&self.recv_window, RecvWindow::take_update); if inc > 0 { self.send_window_update(sink, 0, inc); } } - let mut buf = std::mem::take(&mut self.replenish_buf); - buf.clear(); - for (id, s) in self.streams.iter_mut() { - if s.state != State::Closed - && s.recv_window.needs_update() - && sink.is_stream_reading(*id) - { - let inc = s.recv_window.take_update(); - if inc > 0 { - buf.push((*id, inc)); - } + let mut ids = self.half_used_windows.take(); + for id in ids.drain(..) { + let Some(s) = self.streams.get(id) else { + continue; + }; + s.replenish_queued.set(false); + // A paused stream's window reopens when its reader resumes. + if !s.reading_paused.get() { + self.replenish_stream(sink, &s); } } - for (id, inc) in buf.iter() { - self.send_window_update(sink, *id, *inc); - } - 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. - let mut evict = std::mem::take(&mut self.evict_buf); - evict.clear(); - for (id, s) in self.streams.iter() { - if s.state == State::Closed { - evict.push(*id); + // Keeps the capacity, unless a write above queued something. + self.half_used_windows.with_mut(|queued| { + if queued.is_empty() { + *queued = ids; } + }); + } + + /// Call it after anything that uses up or shrinks the receive window of `s`, and when its reader resumes inside a batch. + pub(crate) fn queue_replenish(&self, s: &Stream) { + if s.recv_window.get().needs_update() && !s.replenish_queued.replace(true) { + self.half_used_windows.with_mut(|queued| queued.push(s.id)); } - for id in evict.iter() { - self.streams.remove(id); - } - self.evict_buf = evict; } /// Dispatch one fully-buffered frame. Returns true if the connection is now fatally closing. - fn dispatch(&mut self, sink: &impl Sink, hdr: &FrameHeader, payload: &[u8]) -> bool { + fn dispatch(&self, sink: &impl Sink, hdr: &FrameHeader, payload: &[u8]) -> bool { // GOAWAY is excluded: it terminates the session, so node's statistics — which are // read off a session that stopped processing at that frame — never include it. - if !matches!(hdr.typ(), Some(FrameType::GoAway)) { - self.frames_received += 1; - sink.on_frame_counters(self.frames_received, self.frames_sent); - } - // RFC 9113 §4.3 / §6.10: once a HEADERS/PUSH_PROMISE without END_HEADERS is received, the - // ONLY permitted frame is a CONTINUATION for that same stream until the block completes. - // Checked before structural validation: a malformed non-CONTINUATION frame mid-block is a - // connection error (§6.2), not the stream error its own size rule would produce. - if let Some(inflight) = &self.header_block_in_flight { - let is_continuation = matches!(hdr.typ(), Some(FrameType::Continuation)); - if !is_continuation || hdr.stream_id != inflight.continuation_stream { - self.send_go_away( - sink, - ErrorCode::ProtocolError, - b"expected CONTINUATION frame", - ); - return true; - } - } - - // §4.2/§6 structural validation (length bounds, stream-id rule, fixed sizes). - match wire::validate_header(hdr, self.local_settings.max_frame_size) { - wire::HeaderValidation::Ok => {} - wire::HeaderValidation::ConnectionError(code) => { - self.send_go_away(sink, code, b"frame validation failed"); - return true; - } - wire::HeaderValidation::StreamError { id, code } => { - self.send_rst_stream(sink, id, code); - return false; - } - } - - match hdr.typ() { - Some(FrameType::Settings) => self.handle_settings(sink, hdr, payload), - Some(FrameType::Ping) => self.handle_ping(sink, hdr, payload), - Some(FrameType::GoAway) => self.handle_go_away(sink, payload), - Some(FrameType::WindowUpdate) => self.handle_window_update(sink, hdr, payload), - Some(FrameType::Headers) => self.handle_headers(sink, hdr, payload), - Some(FrameType::Continuation) => self.handle_continuation(sink, hdr, payload), - Some(FrameType::Data) => self.handle_data(sink, hdr, payload), - Some(FrameType::RstStream) => self.handle_rst_stream(sink, hdr, payload), - 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, - } - } - - fn handle_settings(&mut self, sink: &impl Sink, hdr: &FrameHeader, payload: &[u8]) -> bool { - if wire::flags::has(hdr.flags, wire::flags::ACK) { - if hdr.length != 0 { - self.send_go_away( - sink, - ErrorCode::FrameSizeError, - b"SETTINGS ACK with payload", - ); - 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, - }); - 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; - sink.on_local_settings(&acked.settings); - return false; + if hdr.frame_type != FrameType::GoAway as u8 { + self.frames_received.set(self.frames_received.get() + 1); } - // node's maxSettings (nghttp2 max_settings): refuse SETTINGS frames carrying more entries - // than the session allows before applying or surfacing any of them. - if (payload.len() / 6) as u32 > self.max_settings { + // Checked first: a malformed frame inside a header block is still a connection error (§6.2). + if !self.header_block.get().admits(hdr) { self.send_go_away( sink, - ErrorCode::EnhanceYourCalm, - b"SETTINGS: too many settings entries", + ErrorCode::PROTOCOL_ERROR, + b"CONTINUATION frame out of sequence", ); return true; } - // §6.5.2: validate value ranges before applying. - if let Some(code) = settings::validate_payload(payload) { - self.send_go_away(sink, code, b"SETTINGS value out of range"); + let frame = match Frame::parse(hdr, payload, self.max_settings.get() as usize) { + Ok(frame) => frame, + Err(e) if e.stream_id == 0 => { + self.send_go_away(sink, e.code, e.reason.as_bytes()); + return true; + } + Err(e) => { + self.reset_stream(sink, e.stream_id, e.code); + return false; + } + }; + match frame { + Frame::Settings(entries) => self.handle_settings(sink, entries), + Frame::SettingsAck => self.handle_settings_ack(sink), + Frame::Ping { ack, payload } => self.handle_ping(sink, ack, payload), + Frame::GoAway { + last_stream_id, + code, + debug, + } => self.handle_go_away(sink, last_stream_id, code, debug), + Frame::WindowUpdate { increment } => { + self.handle_window_update(sink, hdr.stream_id, increment) + } + Frame::Headers { + fragment, + end_stream, + end_headers, + } => self.handle_headers(sink, hdr, fragment, end_stream, end_headers), + Frame::Continuation { + fragment, + end_headers, + } => self.handle_continuation(sink, fragment, end_headers), + Frame::Data { data, end_stream } => self.handle_data(sink, hdr, data, end_stream), + Frame::RstStream { code } => self.handle_rst_stream(sink, hdr.stream_id, code), + Frame::PushPromise { + promised_id, + fragment, + end_headers, + } => self.handle_push_promise(sink, hdr, promised_id, fragment, end_headers), + Frame::AltSvc => self.handle_altsvc(sink, hdr, payload), + Frame::Origin => self.handle_origin(sink, hdr, payload), + // Both are ignored (§4.1 for unknown types). nghttp2 charges each one as a glitch. + Frame::Priority | Frame::Unknown => self.note_glitch(sink), + } + } + + fn handle_settings_ack(&self, sink: &impl Sink) -> bool { + // §6.5.3: an ACK is for the oldest outstanding SETTINGS, not the latest submission. + let Some(acked) = self + .pending_local_settings_acks + .with_mut(VecDeque::pop_front) + else { + self.send_go_away(sink, ErrorCode::PROTOCOL_ERROR, b"SETTINGS: unexpected ACK"); return true; + }; + // §6.9.2: the peer shifted its send window for every stream by this delta. + let delta = acked.initial_window_size as i64 - self.acked_local_initial_window.get() as i64; + if delta != 0 { + self.streams.for_each(|s| { + update(&s.recv_window, |w| w.grow(delta)); + self.queue_replenish(s); + }); } - 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() { - let id = u16::from_be_bytes([payload[i], payload[i + 1]]); - let value = u32::from_be_bytes([ - payload[i + 2], - payload[i + 3], - payload[i + 4], - payload[i + 5], - ]); + self.acked_local_initial_window + .set(acked.initial_window_size); + self.acked_max_frame_size.set(acked.max_frame_size); + self.enforced_max_header_list_size + .set(acked.max_header_list_size); + self.hpack + .with_mut(|h| h.set_acked_header_table_size(acked.header_table_size)); + sink.on_local_settings(&acked); + false + } + + fn handle_settings(&self, sink: &impl Sink, entries: Entries<'_>) -> bool { + let old_initial_window = self.remote_settings.get().initial_window_size; + for (id, value) in entries { if let Some(sid) = SettingId::from_u16(id) { // RFC 8441 §3 (and nghttp2): once SETTINGS_ENABLE_CONNECT_PROTOCOL has been // enabled, the peer must not disable it again — doing so is a connection error. if sid == SettingId::EnableConnectProtocol && value == 0 - && self.remote_settings.enable_connect_protocol == 1 + && self.remote_settings.get().enable_connect_protocol { self.send_go_away( sink, - ErrorCode::ProtocolError, + ErrorCode::PROTOCOL_ERROR, b"SETTINGS: server attempted to disable enableConnectProtocol", ); return true; } - self.remote_settings.apply(sid, value); + update(&self.remote_settings, |r| r.apply(sid, value)); + // Per entry, because the peer's decoder evicts at each one (RFC 7541 §4.2). + if sid == SettingId::HeaderTableSize { + self.hpack.with_mut(|h| h.set_peer_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 { - let delta = self.remote_settings.initial_window_size as i64 - old_initial_window as i64; - for (_, s) in self.streams.iter_mut() { - if s.state != State::Closed { - s.send_window.apply_initial_delta(delta); - } - } } - // §6.5.3: ACK first. on_remote_settings flushes DATA the enlarged window - // unblocked, and the peer enforces its old receive window until it sees the ACK. + // §6.9.2: the change shifts every stream's send window, but not the connection's. + let snapshot = self.remote_settings.get(); + let delta = snapshot.initial_window_size as i64 - old_initial_window as i64; + if delta != 0 { + // nghttp2 ends the session if a window passes 2^31-1 here. Such a window stays as it was instead. + self.streams.for_each(|s| { + let _ = update(&s.send_window, |w| w.shift(delta)); + }); + } + // §6.5.3: ACK first. The peer goes by its old receive window until it sees the ACK. self.send_settings_ack(sink); if self.note_outbound_ack(sink) { return true; } - let snapshot = self.remote_settings; + if delta > 0 { + sink.on_send_window_increased(); + } sink.on_remote_settings(&snapshot); false } @@ -872,91 +755,108 @@ impl Connection { /// transport: reset the outbound-ACK-queue counter (mirrors nghttp2's /// per-send decrement, coarsened to whole-buffer drains). Never called /// from receive() itself so a peer that never reads cannot reset it. - pub(crate) fn note_outbound_drained(&mut self) { - self.obq_ack_pending = 0; + pub(crate) fn note_outbound_drained(&self) { + self.obq_ack_pending.set(0); } - /// No-op when unchanged: the embedder re-syncs this on every read and must not refill the bucket. - pub(crate) fn set_stream_reset_limit(&mut self, burst: u32, rate: u32) { - let lim = &self.stream_reset_limit; - if lim.burst != burst || lim.rate != rate { - self.stream_reset_limit = RateLimit::new(burst, rate); - } + pub(crate) fn set_stream_reset_limit(&self, burst: u32, rate: u32) { + self.stream_reset_limit.set(RateLimit::new(burst, rate)); } /// Servers only. The callers charge a reset only when it cancels a live stream. - fn note_reset(&mut self, by: ResetBy) { - if !self.is_server || self.reset_flood { + fn note_reset(&self, by: ResetBy) { + if !self.is_server.get() || self.reset_flood.get() { return; } let limit = match by { - ResetBy::Peer => &mut self.stream_reset_limit, - ResetBy::Us => &mut self.sent_reset_limit, + ResetBy::Peer => &self.stream_reset_limit, + ResetBy::Us => &self.sent_reset_limit, }; - if !limit.drain() { - self.reset_flood = true; + if !update(limit, RateLimit::drain) { + self.reset_flood.set(true); } } /// nghttp2 sends INTERNAL_ERROR here; ENHANCE_YOUR_CALM matches note_outbound_ack. - fn check_reset_flood(&mut self, sink: &impl Sink) -> bool { - if !self.reset_flood || self.terminated { + fn check_reset_flood(&self, sink: &impl Sink) -> bool { + if !self.reset_flood.get() || self.terminated.get() { return false; } self.local_connection_error( sink, - ErrorCode::EnhanceYourCalm, - wire::lib_error::FLOODED, + ErrorCode::ENHANCE_YOUR_CALM, + lib_error::FLOODED, b"too many stream resets", ); true } + /// node's Http2Session::OnInvalidFrame, `count++ > max`. Returns true when the session was torn down. + fn count_invalid_frame(&self, sink: &impl Sink) -> bool { + let count = self.invalid_frame_count.get(); + self.invalid_frame_count.set(count.saturating_add(1)); + if count <= self.max_invalid_frames.get() { + return false; + } + self.terminated.set(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(&self, sink: &impl Sink) -> bool { + if update(&self.glitch_limit, RateLimit::drain) { + return false; + } + self.send_go_away( + sink, + ErrorCode::ENHANCE_YOUR_CALM, + 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 /// is independent of recv() chunk size. Returns true when the session was /// torn down. - fn note_outbound_ack(&mut self, sink: &impl Sink) -> bool { - self.obq_ack_pending = self.obq_ack_pending.saturating_add(1); - if self.obq_ack_pending < MAX_OUTBOUND_ACK_QUEUE { + fn note_outbound_ack(&self, sink: &impl Sink) -> bool { + let pending = self.obq_ack_pending.get().saturating_add(1); + self.obq_ack_pending.set(pending); + if pending < MAX_OUTBOUND_ACK_QUEUE { return false; } self.local_connection_error( sink, - ErrorCode::EnhanceYourCalm, - wire::lib_error::FLOODED, + ErrorCode::ENHANCE_YOUR_CALM, + lib_error::FLOODED, b"too many outbound control frames queued", ); true } - fn handle_ping(&mut self, sink: &impl Sink, hdr: &FrameHeader, payload: &[u8]) -> bool { - if wire::flags::has(hdr.flags, wire::flags::ACK) { - sink.on_ping(payload, true); + fn handle_ping(&self, sink: &impl Sink, ack: bool, payload: [u8; 8]) -> bool { + if ack { + sink.on_ping(&payload, true); return false; } - // copy the 8-byte payload before echoing (sink.write may reuse buffers). - let mut echo = [0u8; 8]; - echo.copy_from_slice(&payload[..8]); - self.send_ping_ack(sink, &echo); - sink.on_ping(&echo, false); + self.send_ping_ack(sink, &payload); + sink.on_ping(&payload, false); self.note_outbound_ack(sink) } - fn handle_go_away(&mut self, sink: &impl Sink, payload: &[u8]) -> bool { - // §6.8: GOAWAY carries at least an 8-octet last-stream-id + error-code prefix. - if payload.len() < 8 { - self.send_go_away(sink, ErrorCode::FrameSizeError, b"GOAWAY too short"); - return true; - } - let last_stream_id = - u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]) & 0x7fff_ffff; - let code_raw = u32::from_be_bytes([payload[4], payload[5], payload[6], payload[7]]); + fn handle_go_away( + &self, + sink: &impl Sink, + last_stream_id: u32, + code: ErrorCode, + debug: &[u8], + ) -> bool { // nghttp2 (nghttp2_session_on_goaway_received): the Last-Stream-ID must refer to a stream // the *receiver* initiated (or 0) — for a server that means an even id, for a client an // odd id. Anything else is a connection PROTOCOL_ERROR. - let initiated_locally = if self.is_server { + let initiated_locally = if self.is_server.get() { last_stream_id.is_multiple_of(2) } else { !last_stream_id.is_multiple_of(2) @@ -964,264 +864,198 @@ impl Connection { if last_stream_id > 0 && !initiated_locally { self.send_go_away( sink, - ErrorCode::ProtocolError, + ErrorCode::PROTOCOL_ERROR, b"GOAWAY: invalid last_stream_id", ); return true; } - self.going_away = true; - sink.on_go_away(code_raw, last_stream_id, &payload[8..]); + sink.on_go_away(code.0, last_stream_id, debug); false } - fn handle_window_update( - &mut self, - sink: &impl Sink, - hdr: &FrameHeader, - payload: &[u8], - ) -> bool { - let increment = - u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]) & 0x7fff_ffff; - // §6.9.1: a 0 increment is an error (connection error on stream 0). - if increment == 0 { - if hdr.stream_id == 0 { - self.send_go_away( - sink, - ErrorCode::ProtocolError, - b"WINDOW_UPDATE with 0 increment", - ); - return true; - } - // Locally-initiated stream RST: close the engine entry and tell the embedder, the - // same as handle_data's Rst path, so the JS stream learns it was reset and the - // entry is evicted. - self.send_rst_stream(sink, hdr.stream_id, ErrorCode::ProtocolError); - if let Some(s) = self.streams.get_mut(&hdr.stream_id) { - s.state = State::Closed; - } - sink.on_stream_reset(hdr.stream_id, ErrorCode::ProtocolError.as_u32()); - 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() { + fn handle_window_update(&self, sink: &impl Sink, stream_id: u32, increment: u32) -> bool { + // §6.9.1: past 2^31-1 is a connection error on stream 0 and a stream error elsewhere. + if stream_id == 0 { + if update(&self.send_window, |w| w.increase(increment)).is_err() { self.send_go_away( sink, - ErrorCode::FlowControlError, + ErrorCode::FLOW_CONTROL_ERROR, 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()); + } else { + // A frame in flight when the stream was reset or forgotten is ignored (§5.1). + let Some(s) = self.streams.inbound(stream_id).filter(|s| !s.was_reset()) else { + return false; + }; + if update(&s.send_window, |w| w.increase(increment)).is_err() { + self.reset_stream(sink, stream_id, ErrorCode::FLOW_CONTROL_ERROR); return false; } } - sink.on_window_update(hdr.stream_id, increment); + sink.on_send_window_increased(); false } // ---- Stream-level inbound ------------------------------------------ - /// RFC 9113 §6.2 HEADERS: strip padding/priority, then begin (or complete) the header block. - fn handle_headers(&mut self, sink: &impl Sink, hdr: &FrameHeader, payload: &[u8]) -> bool { - let mut off = 0usize; - let mut end = payload.len(); - - if wire::flags::has(hdr.flags, wire::flags::PADDED) { - if payload.is_empty() { - self.send_go_away(sink, ErrorCode::FrameSizeError, b"HEADERS padded but empty"); - return true; - } - let pad = payload[0] as usize; - off = 1; - // §6.1: padding that spans the whole frame is a PROTOCOL_ERROR. - if off + pad > end { - self.send_go_away(sink, ErrorCode::ProtocolError, b"HEADERS padding too large"); - return true; - } - end -= pad; - } - if wire::flags::has(hdr.flags, wire::flags::PRIORITY) { - // 4-byte stream dependency + 1-byte weight; ignored (no RFC 7540 prioritization). - if off + 5 > end { - self.send_go_away( - sink, - ErrorCode::FrameSizeError, - b"HEADERS priority truncated", - ); - return true; - } - off += 5; - } - - 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). - let send_init = self.remote_settings.initial_window_size; - let recv_init = self.local_settings.initial_window_size; - 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.) - if is_new && self.is_server && hdr.stream_id.is_multiple_of(2) { + /// RFC 9113 §6.2 HEADERS: begin (or complete) the header block. + fn handle_headers( + &self, + sink: &impl Sink, + hdr: &FrameHeader, + fragment: &[u8], + end_stream: bool, + end_headers: bool, + ) -> bool { + let is_server = self.is_server.get(); + let existing = self.streams.inbound(hdr.stream_id); + let is_new = existing.is_none(); + // §5.1.1: a client opens odd ids, and it never sends HEADERS on a stream we pushed. + if is_server && hdr.stream_id.is_multiple_of(2) { self.send_go_away( sink, - ErrorCode::ProtocolError, + ErrorCode::PROTOCOL_ERROR, b"invalid stream id for HEADERS", ); return true; } - let refused = is_new && self.is_server && !sink.can_open_stream(); - let mut disposition = if refused { - BlockDisposition::Refused + // An id with no entry is a new request if it's above the peer's last one. Otherwise it's closed or idle. + let opens_stream = is_new && is_server && hdr.stream_id > self.last_peer_id.get(); + let closed = is_new && !opens_stream; + if closed && !self.has_existed(hdr.stream_id) { + self.send_go_away(sink, ErrorCode::PROTOCOL_ERROR, b"HEADERS on idle stream"); + return true; + } + let mut disposition = if closed { + BlockDisposition::StreamClosed + } else if !opens_stream { + BlockDisposition::Deliver + } else if self.is_over_memory_limit(sink) { + // Like node's OnBeginHeadersCallback. + BlockDisposition::OverMemoryLimit + } else if self.streams.open_peer() >= self.local_settings.get().max_concurrent_streams { + // §5.1.2. The limit applies from the moment it's submitted, like nghttp2's pending local settings. + BlockDisposition::OverStreamLimit } else { BlockDisposition::Deliver }; - if !refused { - let s = self - .streams - .entry(hdr.stream_id) - .or_insert_with(|| Stream::new(send_init, recv_init)); - if is_new { - s.opened_after_goaway = sink.goaway_sent(); + if opens_stream { + // Also for a refused stream, so that what the client pipelined behind its HEADERS is a frame on a closed stream (§5.1). + self.last_peer_id.set(hdr.stream_id); + if disposition != BlockDisposition::OverMemoryLimit { + self.last_stream_id + .set(self.last_stream_id.get().max(hdr.stream_id)); } - let cur_state = s.state; - let ev = if end_stream { - stream::Event::RecvHeadersEndStream - } else { - stream::Event::RecvHeaders - }; - match stream::transition(cur_state, ev) { - Ok(next) => { - if let Some(s) = self.streams.get_mut(&hdr.stream_id) { - s.state = next; - } + if disposition == BlockDisposition::OverStreamLimit { + self.send_rst_stream(sink, hdr.stream_id, ErrorCode::REFUSED_STREAM); + // nghttp2 reports a stream over the limit to node as an invalid frame. + if self.count_invalid_frame(sink) { + return true; } + } + } + if disposition == BlockDisposition::Deliver { + let s = existing.unwrap_or_else(|| self.insert_stream(hdr.stream_id, State::Idle)); + // Like nghttp2, HEADERS after the peer's END_STREAM end the session. Node reports "Stream was already closed or invalid". + if s.inbound_state() == State::HalfClosedRemote { + self.local_connection_error( + sink, + ErrorCode::STREAM_CLOSED, + lib_error::STREAM_CLOSED, + b"HEADERS: stream closed", + ); + return true; + } + // END_STREAM is applied once the block is complete. + match self.streams.apply(&s, stream::Event::RecvHeaders) { + Ok(_) => {} Err(stream::TransitionError::Protocol) => { self.send_go_away( sink, - ErrorCode::ProtocolError, + ErrorCode::PROTOCOL_ERROR, b"HEADERS in invalid stream state", ); return true; } + // The stream was reset with the peer's trailers in flight. Err(stream::TransitionError::StreamClosed) => { - // nghttp2 (session_on_*_headers_received): HEADERS for a stream whose remote - // half already ended (half-closed (remote)) is escalated to a CONNECTION error - // 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. - if cur_state == State::HalfClosedRemote { - self.local_connection_error( - sink, - ErrorCode::StreamClosed, - wire::lib_error::STREAM_CLOSED, - b"HEADERS: stream closed", - ); - return true; - } disposition = BlockDisposition::StreamClosed; } } - } - if is_new { - // Must advance even for refused streams: §5.1 treats anything at or below the - // high-water mark as having existed, so frames a client pipelined behind the - // refused HEADERS (RST_STREAM especially) are tolerated instead of GOAWAY'd. - if hdr.stream_id > self.last_stream_id { - self.last_stream_id = hdr.stream_id; - } - if !refused { - sink.on_stream_open(hdr.stream_id); + if opens_stream { + s.opened_after_goaway.set(self.goaway_sent.get()); + sink.on_stream_open(&s); } } - self.header_block.clear(); - self.header_block.extend_from_slice(&payload[off..end]); let meta = HeaderBlockMeta { target: hdr.stream_id, push_parent: None, flags: hdr.flags, end_stream, - // Only a server receives request blocks via HEADERS; on a client every response block - // looks "new" (the engine only tracks inbound-created streams), so without this gate it - // would be misclassified as a request. PUSH_PROMISE sets the flag itself. - is_request: self.is_server && is_new, + is_request: opens_stream, disposition, }; - self.finish_or_park_header_block(sink, hdr, meta) + self.begin_header_block(sink, hdr.stream_id, fragment, end_headers, meta) } - /// Tail of HEADERS/PUSH_PROMISE once the first fragment is in `header_block`: decode the block - /// if the frame carried END_HEADERS, otherwise hold it for the CONTINUATION frames, which must - /// arrive on the frame's own stream (RFC 9113 §6.10). - fn finish_or_park_header_block( - &mut self, + /// Tail of HEADERS and PUSH_PROMISE. CONTINUATION frames follow on the frame's own stream (§6.10). + fn begin_header_block( + &self, sink: &impl Sink, - hdr: &FrameHeader, + stream_id: u32, + fragment: &[u8], + end_headers: bool, meta: HeaderBlockMeta, ) -> bool { - if !wire::flags::has(hdr.flags, wire::flags::END_HEADERS) { - self.header_block_in_flight = Some(HeaderBlockInFlight { - continuation_stream: hdr.stream_id, - meta, - }); - return false; + if self + .header_block + .with_mut(|b| b.begin(stream_id, fragment, end_headers)) + { + return self.finish_header_block(sink, &meta); } - self.finish_header_block(sink, &meta) + self.open_block_meta.set(Some(meta)); + false } - /// 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 { - // §6.10: a CONTINUATION with no header block in progress is a connection PROTOCOL_ERROR. - self.send_go_away( - sink, - ErrorCode::ProtocolError, - b"unexpected CONTINUATION frame", - ); - return true; - }; - // dispatch() already enforced that we are assembling this exact stream. - // 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 — - // tearing down here is safe for every legitimate block and bounds memory against - // CONTINUATION floods. node itself never errors on this (nghttp2 tolerates far more, - // verified on node v26.3.0) — this is deliberate hardening, covered by the - // maxHeaderListSize test in node-http2.test.js. - let cap = (self.enforced_max_header_list_size as usize).max(65536); - if self.header_block.len().saturating_add(payload.len()) > cap { - // nghttp2's NGHTTP2_MAX_HEADERSLEN (65536) overflow returns NGHTTP2_ERR_HEADER_COMP, - // which node surfaces as a session COMPRESSION_ERROR. - self.send_go_away(sink, ErrorCode::CompressionError, b"header block too large"); - return true; - } - self.header_block.extend_from_slice(payload); - if !wire::flags::has(hdr.flags, wire::flags::END_HEADERS) { - self.header_block_in_flight = Some(inflight); - return false; + fn handle_continuation(&self, sink: &impl Sink, fragment: &[u8], end_headers: bool) -> bool { + // HPACK output is never smaller than its input. The floor keeps a tiny maxHeaderListSize from refusing normal blocks. + let max_len = (self.enforced_max_header_list_size.get() as usize).max(65536); + match self + .header_block + .with_mut(|b| b.push(fragment, end_headers, MAX_CONTINUATIONS, max_len)) + { + Ok(false) => false, + Ok(true) => match self.open_block_meta.take() { + Some(meta) => self.finish_header_block(sink, &meta), + None => false, + }, + Err(HeaderBlockError::TooManyContinuations) => { + // nghttp2 sends GOAWAY(INTERNAL_ERROR) here. + self.local_connection_error( + sink, + ErrorCode::INTERNAL_ERROR, + lib_error::TOO_MANY_CONTINUATIONS, + b"too many CONTINUATION frames", + ); + true + } + Err(HeaderBlockError::TooLarge) => { + // nghttp2 fails a block over NGHTTP2_MAX_HEADERSLEN with NGHTTP2_ERR_HEADER_COMP. + self.send_go_away( + sink, + ErrorCode::COMPRESSION_ERROR, + b"header block too large", + ); + true + } } - self.finish_header_block(sink, &inflight.meta) } - /// Decode the assembled header block (HPACK) and dispatch each field, then finalize. The whole - /// block is always decoded so the connection-scoped HPACK table stays in sync (§4.3). Works for - /// both a normal HEADERS block and a PUSH_PROMISE block (`meta.push_parent` set). - fn finish_header_block(&mut self, sink: &impl Sink, meta: &HeaderBlockMeta) -> bool { + /// Decodes a complete header block and delivers it. Every block is decoded whole, so the HPACK table stays in sync (§4.3). + fn finish_header_block(&self, sink: &impl Sink, meta: &HeaderBlockMeta) -> bool { let HeaderBlockMeta { target, push_parent, @@ -1230,644 +1064,294 @@ impl Connection { is_request, disposition, } = *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 { + let deliver = disposition == BlockDisposition::Deliver; + // The embedder creates the pushed stream before it gets the promised request's fields. + if let Some(parent) = push_parent + && deliver + { sink.on_push_promise(parent.get(), target); } - let block = std::mem::take(&mut self.header_block); - let mut off = 0usize; - let mut fatal = false; - // RFC 9113 §10.5.1: enforce SETTINGS_MAX_HEADER_LIST_SIZE (uncompressed size: name + value - // + 32 per field). The whole block is still decoded so the HPACK table stays in sync; the - // stream is then refused without surfacing the oversized list. The peer-acknowledged - // limit is used, never a still-in-flight submission (see enforced_max_header_list_size). - let max_list_size = self.enforced_max_header_list_size as usize; - let max_pairs = self.max_header_list_pairs as usize; - let mut list_size: usize = 0; - let mut field_count: usize = 0; - // RFC 9113 §8.1: a trailer section is the final header block on the stream — it must - // carry END_STREAM and must not contain pseudo-header fields. + let is_server = self.is_server.get(); + let stream = self.streams.inbound(target); + // §8.1: a trailer section ends the stream and has no pseudo-header fields. let is_trailer = push_parent.is_none() - && disposition == BlockDisposition::Deliver - && self - .streams - .get(&target) - .is_some_and(|s| s.recv_final_headers); - let mut rejected = false; - let mut malformed = is_trailer && !end_stream; - let mut seen_regular = false; - let mut seen_pseudo: u8 = 0; - // Request-block state for the RFC 9113 §8.3.1 checks below (nghttp2's - // nghttp2_http_on_request_headers): only an initial HEADERS block (or a PUSH_PROMISE - // block) is a request; a later HEADERS on the same stream is a trailer section. - let mut saw_connect = false; - let mut saw_options = false; - let mut saw_host = self.streams.get(&target).is_some_and(|s| s.recv_host); - let mut http_scheme = false; - let mut origin_form = false; - let mut asterisk_form = false; - let mut informational = false; - let mut content_length: Option = None; - while off < block.len() { - match self.hpack.decode(&block[off..]) { - Ok(h) => { - off += h.next; - list_size += h.name.len() + h.value.len() + 32; - field_count += 1; - if rejected || list_size > max_list_size || field_count > max_pairs { - rejected = true; - continue; - } - // HEADERS on a closed stream: decode for HPACK-table sync only (§4.3); the - // fields are never surfaced. - if disposition != BlockDisposition::Deliver { - continue; - } - // RFC 9113 §8.2.1/§8.2.2: connection-specific fields, a pseudo-header following a - // regular field, a repeated or unknown pseudo-header, or `te` with a value other - // than "trailers" make the header block malformed. - if !malformed { - let name_b: &[u8] = h.name; - let value_b: &[u8] = h.value; - // RFC 9113 §8.2.1: field names must be lowercase token characters and - // values must not contain NUL/CR/LF — otherwise the block is malformed - // (CR/LF in a surfaced value is a request-smuggling vector when the - // application proxies to HTTP/1). - if crate::api::h2_frame_parser_body::is_malformed_field_name(name_b) - || crate::api::h2_frame_parser_body::is_malformed_field_value(value_b) - { - malformed = true; - } else if let Some(rest) = name_b.strip_prefix(b":") { - let bit: u8 = match rest { - b"method" => pseudo::METHOD, - b"scheme" => pseudo::SCHEME, - b"authority" => pseudo::AUTHORITY, - b"path" => pseudo::PATH, - b"status" => pseudo::STATUS, - b"protocol" => pseudo::PROTOCOL, - _ => pseudo::UNKNOWN, - }; - // 8.3: :status is the only pseudo-header of a response, and a request - // does not have it. A PUSH_PROMISE block is a request. - let wrong_direction = - (rest == b"status") == (self.is_server || push_parent.is_some()); - // RFC 8441 §4: :protocol is only valid when SETTINGS_ENABLE_CONNECT_PROTOCOL - // has been enabled by this endpoint. nghttp2 (and so node) checks the - // submitted local value here, not the ACKed one — so a request that arrives - // after a server has set enableConnectProtocol back to false is rejected at - // the protocol level and never reaches the JS 'stream' handler - // (test-http2-connect-method-extended-cant-turn-off). - let protocol_disabled = self.is_server - && rest == b"protocol" - && self.local_settings.enable_connect_protocol == 0; - // nghttp2 (check_pseudo_header) treats an empty pseudo-header value as - // malformed, so `:path: ""` never counts as a present :path (§8.3.1: - // `:path` "MUST NOT be empty" for http/https). JS drops a field with - // whitespace at an edge of its value, so it is not a present one either. - // Without strictFieldWhitespaceValidation nghttp2 takes such a :protocol, - // unless it is all whitespace. - let edge_whitespace = has_edge_whitespace(value_b) - && (self.strict_field_whitespace - || bit != pseudo::PROTOCOL - || value_b.iter().all(|c| matches!(c, b' ' | b'\t'))); - if seen_regular - || bit == pseudo::UNKNOWN - || (seen_pseudo & bit) != 0 - || wrong_direction - || protocol_disabled - || is_trailer - || value_b.is_empty() - || edge_whitespace - || has_control_byte(value_b) - || !pseudo::is_valid_value(bit, value_b) - { - malformed = true; - } - if rest == b"status" && value_b.len() == 3 && value_b[0] == b'1' { - informational = true; - } - seen_pseudo |= bit; - if rest == b"method" && value_b == b"CONNECT" { - saw_connect = true; - } - match bit { - pseudo::METHOD => saw_options = value_b == b"OPTIONS", - pseudo::SCHEME => { - http_scheme = value_b.eq_ignore_ascii_case(b"http") - || value_b.eq_ignore_ascii_case(b"https"); - } - pseudo::PATH => { - origin_form = value_b.starts_with(b"/"); - asterisk_form = value_b == b"*"; - } - _ => {} - } - } else { - seen_regular = true; - match name_b { - b"connection" | b"keep-alive" | b"proxy-connection" - | b"transfer-encoding" | b"upgrade" => malformed = true, - // nghttp2 ignores a request's Host that is not an authority. - b"host" if self.is_server || push_parent.is_some() => { - if !pseudo::is_valid_value(pseudo::AUTHORITY, value_b) { - continue; - } - // check_pseudo_header: not empty, and one per stream. - if value_b.is_empty() || saw_host { - malformed = true; - } - saw_host = true; - } - b"te" => { - // RFC 9110 10.1.4: field values are case-insensitive. - if !value_b.eq_ignore_ascii_case(b"trailers") { - malformed = true; - } - } - b"content-length" => match parse_content_length_strict(value_b) { - Some(n) if content_length.is_none() => { - content_length = Some(n); - } - _ => malformed = true, - }, - _ => {} - } - // nghttp2 ignores such a field (NGHTTP2_ERR_IGN_HTTP_HEADER). - if has_control_byte(value_b) { - continue; - } - } - } - if malformed { - continue; - } - sink.on_header(target, h.name, h.value, h.never_index); - } - Err(_) => { - // §4.3: a header-block decoding error is a connection COMPRESSION_ERROR. - self.send_go_away(sink, ErrorCode::CompressionError, b"HPACK decode error"); - fatal = true; - break; + && deliver + && stream.as_ref().is_some_and(|s| s.recv_final_headers.get()); + let mut checks = FieldChecks::new( + BlockKind { + is_server, + is_push: push_parent.is_some(), + is_request, + is_trailer, + connect_protocol_enabled: self.local_settings.get().enable_connect_protocol, + strict_whitespace: self.strict_field_whitespace.get(), + max_list_size: self.enforced_max_header_list_size.get() as usize, + max_fields: self.max_header_list_pairs.get() as usize, + }, + stream.as_ref().is_some_and(|s| s.recv_host.get()), + is_trailer && !end_stream, + ); + let block = self.header_block.with_mut(HeaderBlockAssembler::take); + let decoded = self.hpack.with_mut(|hpack| { + let mut rest = block.as_slice(); + while !rest.is_empty() { + let Ok(field) = hpack.decode(rest) else { + return false; + }; + rest = &rest[field.next..]; + if deliver && checks.accept(field.name, field.value) { + sink.on_header(target, field.name, field.value, field.never_index); } } - } - // restore the buffer's capacity for reuse - self.header_block = block; - self.header_block.clear(); - if fatal { + true + }); + self.header_block.with_mut(|b| b.recycle(block)); + if !decoded { + self.send_go_away(sink, ErrorCode::COMPRESSION_ERROR, b"HPACK decode error"); return true; } match disposition { - BlockDisposition::Refused => { - // node (node_http2.cc, Http2Session::OnBeginHeadersCallback): a stream refused for - // the session memory budget is answered with RST_STREAM(ENHANCE_YOUR_CALM), which - // is what node's own test-http2-max-session-memory asserts. - self.send_rst_stream(sink, target, ErrorCode::EnhanceYourCalm); - 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()); + BlockDisposition::OverStreamLimit => return false, + BlockDisposition::OverMemoryLimit => { + self.send_rst_stream(sink, target, ErrorCode::ENHANCE_YOUR_CALM); + sink.on_stream_rejected(); 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 - // non-empty :method, :scheme and :path plus an :authority or Host; plain CONNECT omits - // :scheme/:path and carries :authority; extended CONNECT (:protocol, RFC 8441) requires - // :method CONNECT and :authority. Without this a block with an empty or missing :path - // reaches JS as a request with an empty url (no compliant peer can produce that shape). - // For http and https, :path starts with "/", or is "*" in an OPTIONS request (check_path). - if is_request && !rejected && !malformed { - use pseudo::{AUTHORITY, METHOD, PATH, PROTOCOL, SCHEME}; - let extended_connect = (seen_pseudo & PROTOCOL) != 0; - malformed = if saw_connect && !extended_connect { - (seen_pseudo & (SCHEME | PATH)) != 0 || (seen_pseudo & AUTHORITY) == 0 - } else { - (seen_pseudo & (METHOD | SCHEME | PATH)) != (METHOD | SCHEME | PATH) - || ((seen_pseudo & AUTHORITY) == 0 && !saw_host) - || (extended_connect && (!saw_connect || (seen_pseudo & AUTHORITY) == 0)) - || (http_scheme && !origin_form && !(asterisk_form && saw_options)) - }; - } - if push_parent.is_none() && self.is_server && !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 end_stream - && s.content_length - .is_some_and(|declared| declared != s.recv_body_bytes) - { - 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(); - return true; - } - // RFC 9113 §8.2: a malformed header block gets a stream error of type PROTOCOL_ERROR and - // is not delivered to the application. - self.send_rst_stream(sink, target, ErrorCode::ProtocolError); - if let Some(s) = self.streams.get_mut(&target) { - s.state = State::Closed; - } - sink.on_stream_reset(target, ErrorCode::ProtocolError.as_u32()); - sink.on_stream_rejected(target); - return false; - } - if rejected { - // Refuse the oversized header list with a stream error (matches the legacy engine and - // node's ENHANCE_YOUR_CALM behavior). - self.send_rst_stream(sink, target, ErrorCode::EnhanceYourCalm); - if let Some(s) = self.streams.get_mut(&target) { - s.state = State::Closed; - } - sink.on_stream_reset(target, ErrorCode::EnhanceYourCalm.as_u32()); - sink.on_stream_rejected(target); - return false; - } + checks.finish(); if push_parent.is_none() - && !informational - && let Some(s) = self.streams.get_mut(&target) + && !checks.malformed + && !checks.rejected + && let Some(s) = stream.as_ref() { - s.recv_final_headers = true; - } - 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); - } - } - false - } - - /// RFC 9113 §6.1 DATA: strip padding, enforce flow control, deliver, replenish windows. - /// Start streaming a DATA frame whose payload is only partially available. Performs the - /// same up-front validation and flow-control accounting as `handle_data` (the FULL declared - /// frame length counts against the windows on receipt), delivers the available data bytes, - /// and parks the remainder in `data_in_flight`. - fn begin_streamed_data( - &mut self, - sink: &impl Sink, - hdr: &FrameHeader, - remaining: &[u8], - padded: bool, - ) -> StreamedDataStart { - let payload_avail = &remaining[wire::FRAME_HEADER_SIZE..]; - let mut pad = 0usize; - let mut off = 0usize; - if padded { - pad = payload_avail[0] as usize; - off = 1; - if 1 + pad > hdr.length as usize { - self.send_go_away(sink, ErrorCode::ProtocolError, b"DATA padding too large"); - return StreamedDataStart::Fatal; - } - } - let data_total = hdr.length as usize - off - pad; - - // §6.9: the whole declared frame counts against the connection recv window on receipt. - self.recv_window.on_data(hdr.length as i64); - if self.recv_window.is_overflowed() { - self.send_go_away( - sink, - ErrorCode::FlowControlError, - b"connection flow-control window exceeded", - ); - return StreamedDataStart::Fatal; - } - - 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 mut s = Stream::new(send_init, recv_init); - s.state = State::Open; - self.streams.insert(hdr.stream_id, s); - } - let recv_limit = self - .acked_local_initial_window - .max(self.local_settings.initial_window_size) as i64; - let closed = self.is_closed_stream(sink, hdr.stream_id); - let mut discard = closed; - match self.streams.get_mut(&hdr.stream_id) { - _ if closed => {} - None => { - self.send_rst_stream(sink, hdr.stream_id, ErrorCode::StreamClosed); - sink.on_stream_reset(hdr.stream_id, ErrorCode::StreamClosed.as_u32()); - discard = true; - } - Some(st) => { - if !stream::can_receive_data(st.state) { - self.send_rst_stream(sink, hdr.stream_id, ErrorCode::StreamClosed); - 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()); - discard = true; - } else { - st.recv_window.on_data(hdr.length as i64); - if st.recv_window.is_overflowed_with(recv_limit) { - // nghttp2 (nghttp2_session_update_recv_stream_window_size): a peer that - // violates a stream's flow-control window terminates the whole session - // with FLOW_CONTROL_ERROR; node surfaces it as NghttpError "Protocol - // error" via its internal-GOAWAY path. - self.send_go_away( - sink, - ErrorCode::FlowControlError, - b"stream flow-control window exceeded", - ); - return StreamedDataStart::Fatal; - } else { - st.recv_body_bytes = st.recv_body_bytes.saturating_add(data_total as u64); - if st.body_exceeds_content_length() { - self.send_rst_stream(sink, hdr.stream_id, ErrorCode::ProtocolError); - if let Some(st2) = self.streams.get_mut(&hdr.stream_id) { - st2.state = State::Closed; - } - sink.on_stream_reset(hdr.stream_id, ErrorCode::ProtocolError.as_u32()); - discard = true; - } - } + if is_server { + s.recv_host.set(checks.saw_host); + if !checks.saw_connect && s.content_length.get().is_none() { + s.content_length.set(checks.content_length); } + } else if !checks.informational && s.content_length.get().is_none() { + // nghttp2_http_on_response_headers: HEAD, 204 and 304 have no body, and CONNECT's DATA is a tunnel. + s.content_length.set(match s.request_method.get() { + None => None, + Some(RequestMethod::Head) => Some(0), + Some(_) if checks.no_body_status => Some(0), + Some(RequestMethod::Connect) => None, + Some(RequestMethod::Other) => checks.content_length, + }); + } + if end_stream && s.body_contradicts_content_length() { + checks.malformed = true; } } - - let body_avail = &payload_avail[off..]; - let data_now = body_avail.len().min(data_total); - if data_now > 0 && !discard { - sink.on_data(hdr.stream_id, &body_avail[..data_now]); + // §8.1: a 1xx block isn't the final response, so it can't end the stream. + if checks.informational && end_stream { + checks.malformed = true; } - let consumed_payload = off + body_avail.len(); - let inflight = DataInFlight { - stream_id: hdr.stream_id, - payload_remaining: (hdr.length as usize - consumed_payload) as u32, - data_remaining: (data_total - data_now) as u32, - end_stream: wire::flags::has(hdr.flags, wire::flags::END_STREAM), - discard, - }; - debug_assert!(inflight.payload_remaining > 0); - self.data_in_flight = Some(inflight); - // An incrementally-streamed DATA frame never reaches dispatch(); count it here, - // once, when its header is accepted. - self.frames_received += 1; - sink.on_frame_counters(self.frames_received, self.frames_sent); - StreamedDataStart::Consumed(wire::FRAME_HEADER_SIZE + consumed_payload) - } - - /// Complete a streamed DATA frame: END_STREAM transition + notification, as the tail of - /// `handle_data` does for whole frames. - fn finish_streamed_data(&mut self, sink: &impl Sink, inflight: &DataInFlight) { - if inflight.end_stream && !inflight.discard { - if self.enforce_content_length(sink, inflight.stream_id) { - return; + if checks.rejected || checks.malformed { + sink.on_headers_refused(); + if !checks.rejected && self.count_invalid_frame(sink) { + return true; } - let state = match self.streams.get_mut(&inflight.stream_id) { - Some(s) => { - if let Ok(next) = stream::transition(s.state, stream::Event::RecvEndStream) { - s.state = next; - } - Some(s.state as u8) - } - None => None, + let code = if checks.rejected { + ErrorCode::ENHANCE_YOUR_CALM + } else { + ErrorCode::PROTOCOL_ERROR }; - if let Some(state) = state { - sink.on_stream_end(inflight.stream_id, state); + self.reset_stream(sink, target, code); + sink.on_stream_rejected(); + return false; + } + if let Some(s) = stream.as_ref() { + if push_parent.is_some() { + s.request_method.set(Some(checks.method)); + } else if !checks.informational { + s.recv_final_headers.set(true); } } - } - - fn has_existed(&self, sink: &impl Sink, stream_id: u32) -> bool { - stream_id <= self.last_stream_id || stream_id <= sink.highest_started_stream_id() - } - - /// RFC 9113 §5.1 closed state. DATA on such a stream is dropped, as nghttp2 does. - fn is_closed_stream(&self, sink: &impl Sink, stream_id: u32) -> bool { - match self.streams.get(&stream_id) { - Some(s) => s.state == State::Closed, - // A stream the embedder opened has no entry here until its first inbound frame. - None => !sink.is_local_stream(stream_id) && self.has_existed(sink, stream_id), + sink.on_headers_complete(target, flags); + if end_stream && let Some(s) = stream.as_ref() { + sink.on_stream_end(s); } + false } - fn handle_data(&mut self, sink: &impl Sink, hdr: &FrameHeader, payload: &[u8]) -> bool { - let mut off = 0usize; - let mut end = payload.len(); - if wire::flags::has(hdr.flags, wire::flags::PADDED) { - if payload.is_empty() { - self.send_go_away(sink, ErrorCode::FrameSizeError, b"DATA padded but empty"); - return true; - } - let pad = payload[0] as usize; - off = 1; - if off + pad > end { - self.send_go_away(sink, ErrorCode::ProtocolError, b"DATA padding too large"); - return true; - } - end -= pad; - } - let consumed = payload.len() as i64; // full frame counts against flow control, incl. padding - - // §6.9: the whole frame counts against the connection recv window. - self.recv_window.on_data(consumed); - if self.recv_window.is_overflowed() { + /// Everything a DATA frame's header decides: flow control, stream state and the declared content-length. + fn admit_data(&self, sink: &impl Sink, hdr: &FrameHeader, data_len: usize) -> DataAdmission { + let frame_len = hdr.length as usize; + // §6.9: the whole frame, padding included, counts against both windows. + update(&self.recv_window, |w| w.on_data(frame_len)); + if self.recv_window.get().is_overflowed(0) { self.send_go_away( sink, - ErrorCode::FlowControlError, + ErrorCode::FLOW_CONTROL_ERROR, b"connection flow-control window exceeded", ); - return true; - } - - // 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; - } - } - - // Per-stream flow control + state check, decided under a scoped borrow so the self.* calls - // below don't alias the streams map. - enum DataDecision { - Rst(ErrorCode), - FlowControlViolation, - Deliver(u32), - } - // Transition shim: DATA for a stream the embedder opened locally (legacy outbound) — open 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 mut s = Stream::new(send_init, recv_init); - s.state = State::Open; - self.streams.insert(hdr.stream_id, s); - } - if self.is_closed_stream(sink, hdr.stream_id) { - return false; - } - let recv_limit = self + return DataAdmission::Fatal; + } + // Like node's HandleDataFrame, an empty DATA frame that doesn't end the stream counts as invalid. + if frame_len == 0 && !hdr.has(flags::END_STREAM) && self.count_invalid_frame(sink) { + return DataAdmission::Fatal; + } + let stream = self.streams.inbound(hdr.stream_id); + // Like nghttp2, drop DATA for a stream that was reset or forgotten (§5.1). + if match stream.as_ref() { + Some(s) => s.was_reset(), + None => self.has_existed(hdr.stream_id), + } { + return DataAdmission::Discard; + } + enum Verdict { + Reset(ErrorCode), + WindowExceeded, + Deliver, + } + let recv_floor = self .acked_local_initial_window - .max(self.local_settings.initial_window_size) as i64; - let decision = match self.streams.get_mut(&hdr.stream_id) { + .get() + .max(self.local_settings.get().initial_window_size); + let verdict = match stream { // §5.1: DATA for a stream nobody opened is a STREAM_CLOSED error. - None => DataDecision::Rst(ErrorCode::StreamClosed), + None => Verdict::Reset(ErrorCode::STREAM_CLOSED), Some(s) => { - if !stream::can_receive_data(s.state) { - DataDecision::Rst(ErrorCode::StreamClosed) + if let Some(code) = s.data_refusal(self.is_server.get()) { + Verdict::Reset(code) } else { - s.recv_window.on_data(consumed); - if s.recv_window.is_overflowed_with(recv_limit) { - DataDecision::FlowControlViolation + update(&s.recv_window, |w| w.on_data(frame_len)); + self.queue_replenish(&s); + // §6.9.1: an empty frame takes no window, even one a SETTINGS change left negative. + if frame_len > 0 && s.recv_window.get().is_overflowed(recv_floor) { + Verdict::WindowExceeded } else { - s.recv_body_bytes = s.recv_body_bytes.saturating_add((end - off) as u64); + s.recv_body_bytes + .set(s.recv_body_bytes.get().saturating_add(data_len as u64)); if s.body_exceeds_content_length() { - DataDecision::Rst(ErrorCode::ProtocolError) + Verdict::Reset(ErrorCode::PROTOCOL_ERROR) } else { - DataDecision::Deliver(0) + Verdict::Deliver } } } } }; - let stream_inc = match decision { - DataDecision::Rst(code) => { - self.send_rst_stream(sink, hdr.stream_id, code); - if let Some(s) = self.streams.get_mut(&hdr.stream_id) { - s.state = State::Closed; - } - // Surface the stream error (e.g. a peer protocol violation) to the embedder. - sink.on_stream_reset(hdr.stream_id, code.as_u32()); - return false; + match verdict { + Verdict::Reset(code) => { + self.reset_stream(sink, hdr.stream_id, code); + DataAdmission::Discard } - DataDecision::FlowControlViolation => { - // nghttp2 (nghttp2_session_update_recv_stream_window_size): a stream flow-control - // violation terminates the whole session with FLOW_CONTROL_ERROR; node surfaces - // it as NghttpError "Protocol error" via its internal-GOAWAY path. + Verdict::WindowExceeded => { + // Like nghttp2, a stream window violation ends the whole session. self.send_go_away( sink, - ErrorCode::FlowControlError, + ErrorCode::FLOW_CONTROL_ERROR, b"stream flow-control window exceeded", ); - return true; + DataAdmission::Fatal } - DataDecision::Deliver(inc) => inc, - }; - let _ = stream_inc; - - let end_stream = wire::flags::has(hdr.flags, wire::flags::END_STREAM); - if end != off { - sink.on_data(hdr.stream_id, &payload[off..end]); + Verdict::Deliver => DataAdmission::Deliver, } + } - // Window replenishment is deferred to the end of the receive() batch (replenish_windows): - // re-opening the window per frame would both spam WINDOW_UPDATE and make a peer that - // ignores flow control (sending a whole burst past the window in one batch) undetectable. - - if end_stream { - if self.enforce_content_length(sink, hdr.stream_id) { - return false; - } - let state = match self.streams.get_mut(&hdr.stream_id) { - Some(s) => { - if let Ok(next) = stream::transition(s.state, stream::Event::RecvEndStream) { - s.state = next; - } - Some(s.state as u8) - } - None => None, - }; - if let Some(state) = state { - sink.on_stream_end(hdr.stream_id, state); + /// Starts a DATA frame whose payload has only partly arrived and parks the rest in `data_in_flight`. + fn begin_streamed_data( + &self, + sink: &impl Sink, + hdr: &FrameHeader, + payload: &[u8], + ) -> StreamedDataStart { + let data = match frame::unpadded_range(hdr, payload.first().copied()) { + Ok(range) => range, + Err(e) => { + self.send_go_away(sink, e.code, e.reason.as_bytes()); + return StreamedDataStart::Fatal; } + }; + let discard = match self.admit_data(sink, hdr, data.len()) { + DataAdmission::Fatal => return StreamedDataStart::Fatal, + DataAdmission::Discard => true, + DataAdmission::Deliver => false, + }; + let arrived = &payload[data.start.min(payload.len())..]; + let data_now = arrived.len().min(data.len()); + if data_now > 0 && !discard { + sink.on_data(hdr.stream_id, &arrived[..data_now]); } - false + self.data_in_flight.set(Some(DataInFlight { + stream_id: hdr.stream_id, + payload_remaining: hdr.length - payload.len() as u32, + data_remaining: (data.len() - data_now) as u32, + end_stream: hdr.has(flags::END_STREAM), + discard, + })); + // A streamed frame never reaches dispatch(), so count it here. + self.frames_received.set(self.frames_received.get() + 1); + StreamedDataStart::Consumed(FRAME_HEADER_SIZE + payload.len()) } - /// RFC 9113 §8.1.1: once END_STREAM arrives, a request 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; + /// END_STREAM on a DATA frame. A body that contradicts the declared `content-length` is malformed (§8.1.1). + fn end_inbound_data(&self, sink: &impl Sink, stream_id: u32) { + let Some(s) = self.streams.inbound(stream_id) else { + return; + }; + if s.body_contradicts_content_length() { + self.reset_stream(sink, stream_id, ErrorCode::PROTOCOL_ERROR); + } else { + sink.on_stream_end(&s); } - let mismatch = self.streams.get(&stream_id).is_some_and(|s| { - s.content_length - .is_some_and(|declared| declared != s.recv_body_bytes) - }); - if !mismatch { - return false; + } + + /// An id without an entry is closed if it has been used, and idle otherwise (§5.1.1). + fn has_existed(&self, stream_id: u32) -> bool { + stream_id <= self.last_peer_id.get() || stream_id <= self.last_stream_id.get() + } + + fn handle_data( + &self, + sink: &impl Sink, + hdr: &FrameHeader, + data: &[u8], + end_stream: bool, + ) -> bool { + match self.admit_data(sink, hdr, data.len()) { + DataAdmission::Fatal => return true, + DataAdmission::Discard => return false, + DataAdmission::Deliver => {} } - self.send_rst_stream(sink, stream_id, ErrorCode::ProtocolError); - if let Some(s) = self.streams.get_mut(&stream_id) { - s.state = State::Closed; + if !data.is_empty() { + sink.on_data(hdr.stream_id, data); } - sink.on_stream_reset(stream_id, ErrorCode::ProtocolError.as_u32()); - true + if end_stream { + self.end_inbound_data(sink, hdr.stream_id); + } + false } /// RFC 9113 §6.4 RST_STREAM. - fn handle_rst_stream(&mut self, sink: &impl Sink, hdr: &FrameHeader, payload: &[u8]) -> bool { - let code_raw = u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]); - let mut charged = false; - // §5.1: RST_STREAM on an idle (or never-seen) stream is a connection PROTOCOL_ERROR. - let mut on_idle = match self.streams.get_mut(&hdr.stream_id) { - Some(s) if s.state != State::Idle => { - // nghttp2 exempts every reset after its GOAWAY, but it opens no stream after it. - let exempt = sink.goaway_sent() && !s.opened_after_goaway; - charged = s.state != State::Closed && !exempt; - s.state = State::Closed; - false + fn handle_rst_stream(&self, sink: &impl Sink, stream_id: u32, code: ErrorCode) -> bool { + let Some(s) = self.streams.inbound(stream_id) else { + // §5.1: tolerated on a closed stream, a connection error on an idle one. + if self.has_existed(stream_id) { + return false; } - _ => true, + self.send_go_away( + sink, + ErrorCode::PROTOCOL_ERROR, + b"RST_STREAM on idle stream", + ); + return true; }; - // Transition shim: a stream the embedder opened locally (legacy outbound) is not idle even - // 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 s = self - .streams - .entry(hdr.stream_id) - .or_insert_with(|| Stream::new(send_init, recv_init)); - s.state = State::Closed; - on_idle = false; - } - // §5.1: a stream evicted after full close (per-request memory release) is - // closed, not idle — a late RST_STREAM on it MUST be tolerated. Anything at or - // below the highest stream id either layer has started has existed; the embedder's - // mark covers locally-initiated streams this engine never saw HEADERS for. - if on_idle && self.has_existed(sink, hdr.stream_id) { + // Only the peer's first reset of a stream counts. Bun's own client answers each late DATA frame with another one. + if s.reset.replace(Some(ResetBy::Peer)) == Some(ResetBy::Peer) { return false; } - if on_idle { - self.send_go_away(sink, ErrorCode::ProtocolError, b"RST_STREAM on idle stream"); - return true; + // JS has already seen a stream close that both sides ended. + if self.streams.close(&s) { + sink.on_stream_reset(&s, code.0); } - sink.on_stream_reset(hdr.stream_id, code_raw); - if charged { + // nghttp2 exempts every reset after its GOAWAY, but it opens no stream after it. A refused push isn't an attack. + let exempt = self.goaway_sent.get() && !s.opened_after_goaway.get(); + if !exempt && !stream_id.is_multiple_of(2) { self.note_reset(ResetBy::Peer); } false @@ -1875,103 +1359,71 @@ impl Connection { /// RFC 9113 §6.6 PUSH_PROMISE (clients only, §8.4): reserve the promised stream and assemble its /// request header block (decoded in finish_header_block, which fires on_push_promise first). - fn handle_push_promise(&mut self, sink: &impl Sink, hdr: &FrameHeader, payload: &[u8]) -> bool { + fn handle_push_promise( + &self, + sink: &impl Sink, + hdr: &FrameHeader, + promised: u32, + fragment: &[u8], + end_headers: bool, + ) -> bool { // 8.4: a server must never receive PUSH_PROMISE. - if self.is_server { + if self.is_server.get() { self.send_go_away( sink, - ErrorCode::ProtocolError, + ErrorCode::PROTOCOL_ERROR, b"server received PUSH_PROMISE", ); return true; } // 6.6: a client that disabled push (SETTINGS_ENABLE_PUSH=0) must treat the receipt of a // PUSH_PROMISE as a connection error of type PROTOCOL_ERROR. - if self.local_settings.enable_push == 0 { + if !self.local_settings.get().enable_push { self.send_go_away( sink, - ErrorCode::ProtocolError, + ErrorCode::PROTOCOL_ERROR, b"PUSH_PROMISE with push disabled", ); return true; } - let mut off = 0usize; - let mut end = payload.len(); - if wire::flags::has(hdr.flags, wire::flags::PADDED) { - if payload.is_empty() { - self.send_go_away( - sink, - ErrorCode::FrameSizeError, - b"PUSH_PROMISE padded but empty", - ); - return true; - } - let pad = payload[0] as usize; - off = 1; - if off + pad > end { - self.send_go_away( - sink, - ErrorCode::ProtocolError, - b"PUSH_PROMISE padding too large", - ); - return true; - } - end -= pad; - } - if off + 4 > end { + // §5.1.1 / §8.4: server-initiated streams use even ids, each above the last. + if promised & 1 == 1 || promised <= self.last_peer_id.get() { self.send_go_away( sink, - ErrorCode::FrameSizeError, - b"PUSH_PROMISE missing promised id", - ); - return true; - } - let promised = u32::from_be_bytes([ - payload[off], - payload[off + 1], - payload[off + 2], - 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) { - self.send_go_away( - sink, - ErrorCode::ProtocolError, + ErrorCode::PROTOCOL_ERROR, b"PUSH_PROMISE invalid promised stream id", ); 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; - if promised > self.last_stream_id { - self.last_stream_id = promised; + // 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 = self.goaway_sent.get(); + if !discard { + self.insert_stream(promised, State::ReservedRemote); } + self.last_peer_id.set(promised); - self.header_block.clear(); - self.header_block.extend_from_slice(&payload[off..end]); let meta = HeaderBlockMeta { target: promised, - // Always Some: wire::validate_header already rejected PUSH_PROMISE on stream 0. + // Always Some: Frame::parse refuses PUSH_PROMISE on stream 0. push_parent: NonZeroU32::new(hdr.stream_id), 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) + self.begin_header_block(sink, hdr.stream_id, fragment, end_headers, 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 { + fn handle_altsvc(&self, sink: &impl Sink, hdr: &FrameHeader, payload: &[u8]) -> bool { + if self.is_server.get() { + return self.note_glitch(sink); + } if payload.len() < 2 { return false; // malformed ALTSVC is ignored (§4) } @@ -1981,12 +1433,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); @@ -1994,567 +1442,25 @@ impl Connection { } /// RFC 8336 §2 ORIGIN: a sequence of (2-byte length + origin) entries on stream 0. - fn handle_origin(&mut self, sink: &impl Sink, hdr: &FrameHeader, payload: &[u8]) -> bool { + fn handle_origin(&self, sink: &impl Sink, hdr: &FrameHeader, payload: &[u8]) -> bool { // §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.get() || hdr.flags & 0xf0 != 0 { + return self.note_glitch(sink); } sink.on_origin(payload); false } - // ---- Outbound stream API (called by the embedder) ------------------ - - /// 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]); - } - - /// HPACK-encode one header field into the current block. Returns false on encode failure. - pub(crate) fn encode_header(&mut self, name: &[u8], value: &[u8], never_index: bool) -> bool { - let old = self.enc_buf.len(); - self.enc_buf.resize(old + name.len() + value.len() + 16, 0); - match self - .hpack - .encode(name, value, never_index, &mut self.enc_buf, old) - { - Ok(n) => { - self.enc_buf.truncate(old + n); - true - } - Err(_) => { - self.enc_buf.truncate(old); - false - } - } - } - - /// Emit the accumulated header block as a HEADERS frame, splitting into CONTINUATION frames when - /// it exceeds the peer's max frame size (§4.3/§6.10), and advance the send-side stream state. - pub(crate) fn send_header_block(&mut self, sink: &impl Sink, stream_id: u32, end_stream: bool) { - let block = std::mem::take(&mut self.enc_buf); - let max = (self.remote_settings.max_frame_size as usize).max(1); - let total = block.len(); - - let first_len = total.min(max); - let mut flags = 0u8; - if first_len == total { - flags |= wire::flags::END_HEADERS; - } - if end_stream { - flags |= wire::flags::END_STREAM; - } - self.write_frame( - sink, - FrameType::Headers, - flags, - stream_id, - &block[..first_len], - ); - let mut off = first_len; - while off < total { - let len = (total - off).min(max); - let f = if off + len == total { - wire::flags::END_HEADERS - } else { - 0 - }; - self.write_frame( - sink, - FrameType::Continuation, - f, - stream_id, - &block[off..off + len], - ); - off += len; - } - self.enc_buf = block; - self.enc_buf.clear(); - - let send_init = self.remote_settings.initial_window_size; - let recv_init = self.local_settings.initial_window_size; - let s = self - .streams - .entry(stream_id) - .or_insert_with(|| Stream::new(send_init, recv_init)); - let ev = if end_stream { - stream::Event::SendHeadersEndStream - } else { - stream::Event::SendHeaders - }; - if let Ok(next) = stream::transition(s.state, ev) { - s.state = next; - } - if stream_id > self.last_stream_id { - self.last_stream_id = stream_id; - } - } - - /// Send DATA honoring connection + stream send windows and the max frame size. Returns the - /// number of bytes actually written; the caller queues and retries the remainder on - /// WINDOW_UPDATE. END_STREAM is only set when the whole buffer is flushed in this call. - pub(crate) fn send_data( - &mut self, - sink: &impl Sink, - stream_id: u32, - data: &[u8], - end_stream: bool, - ) -> usize { - let conn_avail = self.send_window.available(); - let stream_avail = self - .streams - .get(&stream_id) - .map(|s| s.send_window.available()) - .unwrap_or(0); - let max_frame = self.remote_settings.max_frame_size as i64; - let allowed = conn_avail.min(stream_avail).min(max_frame).max(0) as usize; - let to_send = allowed.min(data.len()); - let send_all = to_send == data.len(); - - // Nothing can move right now (and this isn't a bare END_STREAM): caller queues it. - if to_send == 0 && !(data.is_empty() && end_stream) { - return 0; - } - - let flags = if end_stream && send_all { - wire::flags::END_STREAM - } else { - 0 - }; - self.write_frame(sink, FrameType::Data, flags, stream_id, &data[..to_send]); - self.send_window.consume(to_send as i64); - if let Some(s) = self.streams.get_mut(&stream_id) { - s.send_window.consume(to_send as i64); - if end_stream && send_all { - if let Ok(next) = stream::transition(s.state, stream::Event::SendEndStream) { - s.state = next; - } - } + /// The JS reader resumed. The peer may be stalled on this window, so don't wait for the next read to reopen it. + pub(crate) fn replenish_stream(&self, sink: &impl Sink, s: &Stream) { + if s.is_closed() || !s.recv_window.get().needs_update() { + return; } - to_send - } - - /// Drop a stream entry whose lifecycle completed on the legacy outbound encoder. - /// The outbound half does not run through this engine yet, so without this hook a - /// completed request's entry would linger as HalfClosedRemote forever — the map (and - /// the per-batch replenish/evict scans) would grow by one entry per request. Removal - /// has the same observable behavior as scan-eviction of a Closed stream. - pub(crate) fn close_stream(&mut self, stream_id: u32) { - self.streams.remove(&stream_id); - } - - /// Replenish a single stream's receive window now (the embedder's reader resumed after a - /// pause). Without this, a peer stalled on a zero stream window would only be released by the - /// next inbound batch — which may never come, since the peer is the one waiting. - pub(crate) fn replenish_stream(&mut self, sink: &impl Sink, stream_id: u32) { - let inc = match self.streams.get_mut(&stream_id) { - Some(s) if s.state != State::Closed && s.recv_window.needs_update() => { - s.recv_window.take_update() - } - _ => return, - }; + let inc = update(&s.recv_window, RecvWindow::take_update); if inc > 0 { - self.send_window_update(sink, stream_id, inc); + self.send_window_update(sink, s.id, inc); } } - - /// Server-side: emit a PUSH_PROMISE on `parent_id` reserving `promised_id`, carrying the - /// promised request headers staged via begin_header_block/encode_header (RFC 9113 §6.6). - pub(crate) fn send_push_promise(&mut self, sink: &impl Sink, parent_id: u32, promised_id: u32) { - let block = std::mem::take(&mut self.enc_buf); - let max = (self.remote_settings.max_frame_size as usize).max(5); - - // First frame: PUSH_PROMISE = 4-byte promised id + (head of) the header block. - let first_cap = max - 4; - let first_len = block.len().min(first_cap); - let mut first = Vec::with_capacity(4 + first_len); - first.extend_from_slice(&(promised_id & 0x7fff_ffff).to_be_bytes()); - first.extend_from_slice(&block[..first_len]); - let flags = if first_len == block.len() { - wire::flags::END_HEADERS - } else { - 0 - }; - self.write_frame(sink, FrameType::PushPromise, flags, parent_id, &first); - - let mut o = first_len; - while o < block.len() { - let len = (block.len() - o).min(max); - let f = if o + len == block.len() { - wire::flags::END_HEADERS - } else { - 0 - }; - self.write_frame( - sink, - FrameType::Continuation, - f, - parent_id, - &block[o..o + len], - ); - o += len; - } - self.enc_buf = block; - self.enc_buf.clear(); - - let send_init = self.remote_settings.initial_window_size; - let recv_init = self.local_settings.initial_window_size; - let s = self - .streams - .entry(promised_id) - .or_insert_with(|| Stream::new(send_init, recv_init)); - s.state = State::ReservedLocal; - if promised_id > self.last_stream_id { - self.last_stream_id = promised_id; - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::vec::Vec; - - use std::cell::{Cell, RefCell}; - - #[derive(Default)] - struct CaptureSink { - out: RefCell>, - pings: RefCell, bool)>>, - remote_settings: Cell, - goaway: Cell>, - /// nghttp2-style lib error code from on_error (locally-detected connection errors). - local_error: Cell>, - opens: RefCell>, - headers: RefCell, Vec)>>, - headers_done: RefCell>, - data: RefCell)>>, - ended: RefCell>, - resets: RefCell>, - pushes: RefCell>, - altsvc: RefCell, Vec)>>, - origins: RefCell>>, - } - impl Sink for CaptureSink { - fn write(&self, bytes: &[u8]) -> WriteResult { - self.out.borrow_mut().extend_from_slice(bytes); - WriteResult::Sent - } - fn on_error(&self, lib_code: i32, _l: u32, _d: &[u8]) { - // send_go_away() reports through on_error; record it so the _is_goaway tests can assert. - self.local_error.set(Some(lib_code)); - } - fn on_local_settings(&self, _s: &Settings) {} - fn on_remote_settings(&self, _s: &Settings) { - self.remote_settings.set(self.remote_settings.get() + 1); - } - fn on_ping(&self, payload: &[u8], is_ack: bool) { - self.pings.borrow_mut().push((payload.to_vec(), is_ack)); - } - 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) { - self.opens.borrow_mut().push(id); - } - fn on_header(&self, id: u32, name: &[u8], value: &[u8], _never: bool) { - self.headers - .borrow_mut() - .push((id, name.to_vec(), value.to_vec())); - } - fn on_headers_complete(&self, id: u32, end_stream: bool, _flags: u8) { - self.headers_done.borrow_mut().push((id, end_stream)); - } - fn on_data(&self, id: u32, data: &[u8]) { - self.data.borrow_mut().push((id, data.to_vec())); - } - fn on_stream_end(&self, id: u32, _state: u8) { - self.ended.borrow_mut().push(id); - } - fn on_stream_reset(&self, id: u32, code: u32) { - self.resets.borrow_mut().push((id, code)); - } - fn on_push_promise(&self, parent: u32, promised: u32) { - self.pushes.borrow_mut().push((parent, promised)); - } - fn on_altsvc(&self, id: u32, origin: &[u8], value: &[u8]) { - self.altsvc - .borrow_mut() - .push((id, origin.to_vec(), value.to_vec())); - } - fn on_origin(&self, origin: &[u8]) { - self.origins.borrow_mut().push(origin.to_vec()); - } - } - - /// 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 buf = vec![0u8; 4096]; - let mut off = 0usize; - for (name, value) in pairs { - off += coder.encode(name, value, false, &mut buf, off).unwrap(); - } - buf.truncate(off); - buf - } - - fn frame(ftype: FrameType, flags: u8, stream_id: u32, payload: &[u8]) -> Vec { - let mut v = vec![0u8; wire::FRAME_HEADER_SIZE]; - let hdr = FrameHeader { - length: payload.len() as u32, - frame_type: ftype as u8, - flags, - stream_id, - }; - let mut hb = [0u8; wire::FRAME_HEADER_SIZE]; - hdr.write(&mut hb); - v.copy_from_slice(&hb); - v.extend_from_slice(payload); - v - } - - #[test] - fn ping_is_acked_with_echo() { - let sink = CaptureSink::default(); - let mut c = Connection::new(true, Settings::default()); - c.preface_received = wire::CONNECTION_PREFACE.len(); // skip preface - let payload = [1u8, 2, 3, 4, 5, 6, 7, 8]; - let f = frame(FrameType::Ping, 0, 0, &payload); - let fed = c.receive(&sink, &f); - assert_eq!(fed.consumed, f.len()); - // emitted a PING ACK echoing the payload - let out = sink.out.borrow(); - assert_eq!(out[3], FrameType::Ping as u8); - assert_eq!(out[4] & wire::flags::ACK, wire::flags::ACK); - assert_eq!( - &out[wire::FRAME_HEADER_SIZE..wire::FRAME_HEADER_SIZE + 8], - &payload - ); - assert_eq!(sink.pings.borrow().len(), 1); - } - - #[test] - fn window_update_zero_increment_on_conn_is_goaway() { - let sink = CaptureSink::default(); - let mut c = Connection::new(true, Settings::default()); - c.preface_received = wire::CONNECTION_PREFACE.len(); - let f = frame(FrameType::WindowUpdate, 0, 0, &[0, 0, 0, 0]); - let fed = c.receive(&sink, &f); - assert!(fed.fatal); - assert_eq!(sink.local_error.get(), Some(wire::lib_error::PROTO)); - } - - #[test] - fn settings_value_out_of_range_is_goaway() { - let sink = CaptureSink::default(); - let mut c = Connection::new(true, Settings::default()); - c.preface_received = wire::CONNECTION_PREFACE.len(); - // ENABLE_PUSH = 2 (invalid) - let payload = [0u8, 0x02, 0, 0, 0, 2]; - let f = frame(FrameType::Settings, 0, 0, &payload); - let fed = c.receive(&sink, &f); - assert!(fed.fatal); - assert_eq!(sink.local_error.get(), Some(wire::lib_error::PROTO)); - } - - #[test] - fn headers_decode_and_open_stream() { - let sink = CaptureSink::default(); - let mut c = Connection::new(true, Settings::default()); - c.preface_received = wire::CONNECTION_PREFACE.len(); - let block = encode_block(&[ - (b":method", b"GET"), - (b":scheme", b"http"), - (b":path", b"/"), - (b":authority", b"localhost"), - ]); - let flags = wire::flags::END_HEADERS | wire::flags::END_STREAM; - let f = frame(FrameType::Headers, flags, 1, &block); - let fed = c.receive(&sink, &f); - assert!(!fed.fatal); - assert_eq!(fed.consumed, f.len()); - assert_eq!(*sink.opens.borrow(), vec![1]); - assert!( - sink.headers - .borrow() - .iter() - .any(|(id, n, v)| *id == 1 && n == b":method" && v == b"GET") - ); - assert!( - sink.headers - .borrow() - .iter() - .any(|(id, n, v)| *id == 1 && n == b":path" && v == b"/") - ); - assert_eq!(*sink.headers_done.borrow(), vec![(1, true)]); - assert_eq!(*sink.ended.borrow(), vec![1]); - assert_eq!( - c.streams.get(&1).map(|s| s.state), - Some(State::HalfClosedRemote) - ); - } - - #[test] - fn data_after_headers_is_delivered() { - let sink = CaptureSink::default(); - let mut c = Connection::new(true, Settings::default()); - c.preface_received = wire::CONNECTION_PREFACE.len(); - let block = encode_block(&[ - (b":method", b"POST"), - (b":scheme", b"http"), - (b":path", b"/"), - (b":authority", b"localhost"), - ]); - // HEADERS without END_STREAM -> stream stays open for DATA. - let h = frame(FrameType::Headers, wire::flags::END_HEADERS, 1, &block); - c.receive(&sink, &h); - assert_eq!(c.streams.get(&1).map(|s| s.state), Some(State::Open)); - - let d = frame(FrameType::Data, wire::flags::END_STREAM, 1, b"hello"); - let fed = c.receive(&sink, &d); - assert!(!fed.fatal); - assert_eq!(*sink.data.borrow(), vec![(1, b"hello".to_vec())]); - assert_eq!(*sink.ended.borrow(), vec![1]); - assert_eq!( - c.streams.get(&1).map(|s| s.state), - Some(State::HalfClosedRemote) - ); - } - - #[test] - fn rst_stream_on_idle_is_goaway() { - let sink = CaptureSink::default(); - let mut c = Connection::new(true, Settings::default()); - c.preface_received = wire::CONNECTION_PREFACE.len(); - let f = frame( - FrameType::RstStream, - 0, - 1, - &ErrorCode::Cancel.as_u32().to_be_bytes(), - ); - let fed = c.receive(&sink, &f); - assert!(fed.fatal); - assert_eq!(sink.local_error.get(), Some(wire::lib_error::PROTO)); - } - - #[test] - fn headers_roundtrip_client_to_server() { - // Client engine encodes + emits a HEADERS frame... - let csink = CaptureSink::default(); - let mut client = Connection::new(false, Settings::default()); - client.begin_header_block(); - assert!(client.encode_header(b":method", b"GET", false)); - assert!(client.encode_header(b":scheme", b"http", false)); - assert!(client.encode_header(b":path", b"/x", false)); - assert!(client.encode_header(b":authority", b"localhost", false)); - client.send_header_block(&csink, 1, true); - let wire_bytes = csink.out.borrow().clone(); - assert_eq!( - client.streams.get(&1).map(|s| s.state), - Some(State::HalfClosedLocal) - ); - - // ...and a server engine decodes the exact same bytes back to the original fields. - let ssink = CaptureSink::default(); - let mut server = Connection::new(true, Settings::default()); - server.preface_received = wire::CONNECTION_PREFACE.len(); - let fed = server.receive(&ssink, &wire_bytes); - assert!(!fed.fatal); - assert_eq!(fed.consumed, wire_bytes.len()); - assert!( - ssink - .headers - .borrow() - .iter() - .any(|(id, n, v)| *id == 1 && n == b":method" && v == b"GET") - ); - assert!( - ssink - .headers - .borrow() - .iter() - .any(|(id, n, v)| *id == 1 && n == b":path" && v == b"/x") - ); - assert_eq!(*ssink.ended.borrow(), vec![1]); - } - - #[test] - fn push_promise_roundtrip_server_to_client() { - // Server stages the promised request headers and emits PUSH_PROMISE on parent stream 1. - let ssink = CaptureSink::default(); - let mut server = Connection::new(true, Settings::default()); - server.begin_header_block(); - assert!(server.encode_header(b":method", b"GET", false)); - assert!(server.encode_header(b":scheme", b"http", false)); - assert!(server.encode_header(b":path", b"/pushed", false)); - assert!(server.encode_header(b":authority", b"localhost", false)); - server.send_push_promise(&ssink, 1, 2); - let bytes = ssink.out.borrow().clone(); - assert_eq!( - server.streams.get(&2).map(|s| s.state), - Some(State::ReservedLocal) - ); - - // Client receives it: on_push_promise(parent=1, promised=2) then the request headers. - let csink = CaptureSink::default(); - let mut client = Connection::new(false, Settings::default()); - client.preface_received = wire::CONNECTION_PREFACE.len(); - let fed = client.receive(&csink, &bytes); - assert!(!fed.fatal); - assert_eq!(fed.consumed, bytes.len()); - assert_eq!(*csink.pushes.borrow(), vec![(1, 2)]); - assert!( - csink - .headers - .borrow() - .iter() - .any(|(id, n, v)| *id == 2 && n == b":path" && v == b"/pushed") - ); - assert_eq!( - client.streams.get(&2).map(|s| s.state), - Some(State::ReservedRemote) - ); - } - - #[test] - fn server_rejects_inbound_push_promise() { - let sink = CaptureSink::default(); - let mut c = Connection::new(true, Settings::default()); - c.preface_received = wire::CONNECTION_PREFACE.len(); - // promised id + empty block - let f = frame( - FrameType::PushPromise, - wire::flags::END_HEADERS, - 1, - &[0, 0, 0, 2], - ); - let fed = c.receive(&sink, &f); - assert!(fed.fatal); - assert_eq!(sink.local_error.get(), Some(wire::lib_error::PROTO)); - } - - #[test] - fn send_data_respects_flow_control_window() { - let sink = CaptureSink::default(); - let mut c = Connection::new(false, Settings::default()); - // Open a stream (send side) with a tiny peer window. - c.begin_header_block(); - assert!(c.encode_header(b":method", b"POST", false)); - c.send_header_block(&sink, 1, false); - sink.out.borrow_mut().clear(); - if let Some(s) = c.streams.get_mut(&1) { - s.send_window = SendWindow::new(4); - } - c.send_window = SendWindow::new(4); - // Only 4 of 10 bytes fit in the window. - let sent = c.send_data(&sink, 1, b"0123456789", true); - assert_eq!(sent, 4); - } } diff --git a/src/runtime/api/bun/h2/field_checks.rs b/src/runtime/api/bun/h2/field_checks.rs new file mode 100644 index 000000000000..51f822d1cbbd --- /dev/null +++ b/src/runtime/api/bun/h2/field_checks.rs @@ -0,0 +1,242 @@ +//! The checks nghttp2 makes on the fields of one inbound header block (RFC 9113 §8.2 and §8.3). + +use super::stream::RequestMethod; +use bun_http_types::h2::fields::{self, has_control_byte, has_edge_whitespace}; +use bun_http_types::{is_token_char, parse_content_length_strict, parse_status_pseudo_header}; + +const METHOD: u8 = 1; +const SCHEME: u8 = 2; +const AUTHORITY: u8 = 4; +const PATH: u8 = 8; +const STATUS: u8 = 16; +const PROTOCOL: u8 = 32; +const UNKNOWN: u8 = 64; + +/// nghttp2_check_method, nghttp2_check_path, check_scheme, check_authority and parse_status_code. +fn is_valid_pseudo_value(bit: u8, value: &[u8]) -> bool { + let all = |extra: &[u8]| { + value + .iter() + .all(|c| c.is_ascii_alphanumeric() || extra.contains(c)) + }; + match bit { + METHOD => value.iter().all(|&c| is_token_char(c)), + PATH => value.iter().all(|&c| c > b' ' && c != 0x7f), + SCHEME => value.first().is_some_and(u8::is_ascii_alphabetic) && all(b"+-."), + AUTHORITY => all(b"!$%&'()*+,-.:;=[]_~"), + STATUS => parse_status_pseudo_header(value).is_some(), + _ => true, + } +} + +/// What the frame and the session say about a block before its first field. +#[derive(Clone, Copy)] +pub(crate) struct BlockKind { + pub is_server: bool, + /// The block arrived in a PUSH_PROMISE, so it's a request although a client receives it. + pub is_push: bool, + /// The block opens a stream: a request on a server, or a PUSH_PROMISE. + pub is_request: bool, + pub is_trailer: bool, + /// Our SETTINGS_ENABLE_CONNECT_PROTOCOL. Like nghttp2, the submitted value counts, not the ACKed one. + pub connect_protocol_enabled: bool, + pub strict_whitespace: bool, + /// SETTINGS_MAX_HEADER_LIST_SIZE counts name + value + 32 per field (§10.5.1). + pub max_list_size: usize, + pub max_fields: usize, +} + +pub(crate) struct FieldChecks { + kind: BlockKind, + list_size: usize, + field_count: usize, + seen_regular: bool, + seen_pseudo: u8, + saw_options: bool, + http_scheme: bool, + origin_form: bool, + asterisk_form: bool, + /// The list is over a size limit. The rest of the block is only decoded to keep the HPACK table in sync. + pub rejected: bool, + pub malformed: bool, + pub saw_connect: bool, + pub saw_host: bool, + pub informational: bool, + /// `:status` is 204 or 304. + pub no_body_status: bool, + pub method: RequestMethod, + pub content_length: Option, +} + +impl FieldChecks { + /// `saw_host` is true if an earlier block of the stream had a `host`. + pub(crate) fn new(kind: BlockKind, saw_host: bool, malformed: bool) -> Self { + FieldChecks { + kind, + list_size: 0, + field_count: 0, + seen_regular: false, + seen_pseudo: 0, + saw_options: false, + http_scheme: false, + origin_form: false, + asterisk_form: false, + rejected: false, + malformed, + saw_connect: false, + saw_host, + informational: false, + no_body_status: false, + method: RequestMethod::Other, + content_length: None, + } + } + + fn is_request_like(&self) -> bool { + self.kind.is_server || self.kind.is_push + } + + /// Returns true if the field is to be delivered. + pub(crate) fn accept(&mut self, name: &[u8], value: &[u8]) -> bool { + self.list_size += name.len() + value.len() + 32; + self.field_count += 1; + if self.rejected + || self.list_size > self.kind.max_list_size + || self.field_count > self.kind.max_fields + { + self.rejected = true; + return false; + } + if self.malformed { + return false; + } + // A CR or LF that reached the application could split a header when it proxies to HTTP/1. + if fields::is_malformed_name(name) || fields::is_malformed_value(value) { + self.malformed = true; + return false; + } + let keep = match name.strip_prefix(b":") { + Some(pseudo) => { + self.pseudo_header(pseudo, value); + true + } + None => self.regular_field(name, value), + }; + keep && !self.malformed + } + + fn pseudo_header(&mut self, name: &[u8], value: &[u8]) { + let bit: u8 = match name { + b"method" => METHOD, + b"scheme" => SCHEME, + b"authority" => AUTHORITY, + b"path" => PATH, + b"status" => STATUS, + b"protocol" => PROTOCOL, + _ => UNKNOWN, + }; + // §8.3: `:status` is the only pseudo-header of a response, and a request doesn't have it. + let wrong_direction = (bit == STATUS) == self.is_request_like(); + // RFC 8441 §4 + let protocol_disabled = + self.kind.is_server && bit == PROTOCOL && !self.kind.connect_protocol_enabled; + // JS drops such a field, so it doesn't count as present. Without strict validation nghttp2 takes a `:protocol` that isn't all whitespace. + let edge_whitespace = has_edge_whitespace(value) + && (self.kind.strict_whitespace + || bit != PROTOCOL + || value.iter().all(|c| matches!(c, b' ' | b'\t'))); + if self.seen_regular + || bit == UNKNOWN + || (self.seen_pseudo & bit) != 0 + || wrong_direction + || protocol_disabled + || self.kind.is_trailer + || value.is_empty() + || edge_whitespace + || has_control_byte(value) + || !is_valid_pseudo_value(bit, value) + { + self.malformed = true; + } + self.seen_pseudo |= bit; + match bit { + METHOD => { + self.saw_options = value == b"OPTIONS"; + self.method = RequestMethod::from_method(value); + if self.method == RequestMethod::Connect { + self.saw_connect = true; + // nghttp2's http_request_on_header refuses CONNECT in a PUSH_PROMISE. + self.malformed |= self.kind.is_push; + } + } + STATUS => { + self.informational = value.len() == 3 && value[0] == b'1'; + self.no_body_status = matches!(value, b"204" | b"304"); + } + SCHEME => { + self.http_scheme = + value.eq_ignore_ascii_case(b"http") || value.eq_ignore_ascii_case(b"https"); + } + PATH => { + self.origin_form = value.starts_with(b"/"); + self.asterisk_form = value == b"*"; + } + _ => {} + } + } + + /// Returns false for a field nghttp2 ignores (NGHTTP2_ERR_IGN_HTTP_HEADER). + fn regular_field(&mut self, name: &[u8], value: &[u8]) -> bool { + self.seen_regular = true; + match name { + _ if fields::is_connection_specific(name) => self.malformed = true, + b"host" if self.is_request_like() => { + if !is_valid_pseudo_value(AUTHORITY, value) { + return false; + } + // check_pseudo_header: not empty, and one per stream. + if value.is_empty() || self.saw_host { + self.malformed = true; + } + self.saw_host = true; + } + b"te" => { + if !value.eq_ignore_ascii_case(b"trailers") { + self.malformed = true; + } + } + b"content-length" => match parse_content_length_strict(value) { + Some(n) if self.content_length.is_none() => self.content_length = Some(n), + _ => self.malformed = true, + }, + _ => {} + } + !has_control_byte(value) + } + + /// The checks that need the whole block: nghttp2_http_on_request_headers and nghttp2_http_on_response_headers. + pub(crate) fn finish(&mut self) { + if self.rejected { + return; + } + let seen = self.seen_pseudo; + let has = |bits: u8| (seen & bits) == bits; + if self.kind.is_request && !self.malformed { + let extended_connect = has(PROTOCOL); + // Plain CONNECT has `:authority` and neither `:scheme` nor `:path` (§8.5). + self.malformed = if self.saw_connect && !extended_connect { + (seen & (SCHEME | PATH)) != 0 || !has(AUTHORITY) + } else { + !has(METHOD | SCHEME | PATH) + || (!has(AUTHORITY) && !self.saw_host) + || (extended_connect && (!self.saw_connect || !has(AUTHORITY))) + || (self.http_scheme + && !self.origin_form + && !(self.asterisk_form && self.saw_options)) + }; + } + if !self.kind.is_server && !self.kind.is_request && !self.kind.is_trailer { + self.malformed |= !has(STATUS); + } + } +} diff --git a/src/runtime/api/bun/h2/flow_control.rs b/src/runtime/api/bun/h2/flow_control.rs deleted file mode 100644 index fbfc2629e904..000000000000 --- a/src/runtime/api/bun/h2/flow_control.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! HTTP/2 flow control (RFC 9113 §6.9). Pure. Part of the from-scratch rewrite. -//! -//! Two directions per connection and per stream: -//! * Send window — how much DATA we may still send (governed by the peer's advertised window + -//! WINDOW_UPDATE). May go negative when the peer lowers INITIAL_WINDOW_SIZE (§6.9.2). -//! * Recv window — how much DATA the peer may still send us; replenished by emitting -//! WINDOW_UPDATE once enough has been consumed. - -#![allow(dead_code)] - -use super::wire::{DEFAULT_WINDOW_SIZE, ErrorCode, MAX_WINDOW_SIZE}; - -/// Outbound (send) window. Signed because a SETTINGS-driven INITIAL_WINDOW_SIZE decrease can push -/// it negative (§6.9.2) — legal; we just stop sending until it recovers. -#[derive(Clone, Copy, Debug)] -pub(crate) struct SendWindow { - pub remaining: i64, -} - -impl Default for SendWindow { - fn default() -> Self { - SendWindow { - remaining: DEFAULT_WINDOW_SIZE as i64, - } - } -} - -impl SendWindow { - pub(crate) fn new(initial: u32) -> Self { - SendWindow { - remaining: initial as i64, - } - } - - /// Bytes we may send right now (never negative for sizing). - #[inline] - pub(crate) fn available(self) -> i64 { - if self.remaining > 0 { - self.remaining - } else { - 0 - } - } - - #[inline] - pub(crate) fn consume(&mut self, n: i64) { - self.remaining -= n; - } - - /// Apply a WINDOW_UPDATE increment. §6.9.1: the window MUST NOT exceed 2^31-1; exceeding it is a - /// FLOW_CONTROL_ERROR. A zero increment is rejected by the caller before reaching here. - pub(crate) fn increase(&mut self, increment: u32) -> Result<(), ErrorCode> { - let next = self.remaining + increment as i64; - if next > MAX_WINDOW_SIZE as i64 { - return Err(ErrorCode::FlowControlError); - } - self.remaining = next; - Ok(()) - } - - /// §6.9.2: shift by (new_initial - old_initial) on a peer INITIAL_WINDOW_SIZE change. - #[inline] - pub(crate) fn apply_initial_delta(&mut self, delta: i64) { - // 6.9.2: the result may legitimately go negative; cap the upper bound so repeated - // positive deltas cannot push past the protocol maximum while outbound is legacy-driven. - self.remaining = (self.remaining + delta).min(MAX_WINDOW_SIZE as i64); - } -} - -/// Inbound (recv) window. We advertise `size` and track `consumed`; once enough is consumed we -/// emit a WINDOW_UPDATE of the consumed amount and reset. -#[derive(Clone, Copy, Debug)] -pub(crate) struct RecvWindow { - pub size: i64, - pub consumed: i64, -} - -impl Default for RecvWindow { - fn default() -> Self { - RecvWindow { - size: DEFAULT_WINDOW_SIZE as i64, - consumed: 0, - } - } -} - -impl RecvWindow { - pub(crate) fn new(initial: u32) -> Self { - RecvWindow { - size: initial as i64, - consumed: 0, - } - } - - #[inline] - pub(crate) fn on_data(&mut self, n: i64) { - self.consumed += n; - } - - /// Whether the peer exceeded our advertised window (a FLOW_CONTROL_ERROR, §6.9.1). - #[inline] - pub(crate) fn is_overflowed(&self) -> bool { - self.consumed > self.size - } - - /// Overflow check with an enforcement limit that may exceed the advertised size: - /// until our SETTINGS shrinking the window is ACKed, the peer may legitimately send - /// according to the previous (larger) value (RFC 9113 6.5.3). - pub(crate) fn is_overflowed_with(&self, limit: i64) -> bool { - self.consumed > limit.max(self.size) - } - - /// Replenish heuristic: update once at least half the window has been consumed. - #[inline] - pub(crate) fn needs_update(&self) -> bool { - self.consumed > 0 && self.consumed >= self.size / 2 - } - - /// Take the pending WINDOW_UPDATE increment and reset the consumed counter (0 if none). - pub(crate) fn take_update(&mut self) -> u32 { - if self.consumed <= 0 { - return 0; - } - let inc = self.consumed.min(MAX_WINDOW_SIZE as i64); - self.consumed -= inc; - inc as u32 - } - - #[inline] - pub(crate) fn grow(&mut self, delta: i64) { - self.size += delta; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn send_window_overflow_is_flow_control_error() { - let mut w = SendWindow::new(MAX_WINDOW_SIZE); - assert_eq!(w.increase(1), Err(ErrorCode::FlowControlError)); - } - - #[test] - fn recv_window_replenish() { - let mut w = RecvWindow::new(100); - w.on_data(60); - assert!(w.needs_update()); - assert_eq!(w.take_update(), 60); - assert_eq!(w.consumed, 0); - } -} diff --git a/src/runtime/api/bun/h2/hpack.rs b/src/runtime/api/bun/h2/hpack.rs deleted file mode 100644 index ac84c8048116..000000000000 --- a/src/runtime/api/bun/h2/hpack.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! HPACK coder (RFC 7541) over the lshpack binding — the only reused piece in the rewrite. -//! -//! Centralizes the dynamic-table-size-update handling: when the peer changes -//! SETTINGS_HEADER_TABLE_SIZE, the encoder capacity is lowered AND a §6.3 Dynamic Table Size -//! Update opcode is emitted at the start of the next header block so the peer's decoder evicts in -//! lockstep. Decode results alias a shared buffer and MUST be copied before the next call -//! (see lshpack.rs). - -#![allow(dead_code)] - -use bun_http::lshpack::{DecodeResult, 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; - -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, -} - -impl Coder { - pub(crate) fn new(max_capacity: u32) -> Self { - Coder { - hpack: HpackHandle::new(max_capacity), - enc_capacity: max_capacity, - pending_enc_capacity: 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() { - return; - } - self.pending_enc_capacity = Some(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; - }; - self.hpack.set_encoder_max_capacity(cap); - self.enc_capacity = cap; - write_table_size_update(dst, offset, cap) - } - - #[inline] - pub(crate) fn encode( - &mut self, - name: &[u8], - value: &[u8], - never_index: bool, - dst: &mut [u8], - offset: usize, - ) -> Result { - self.hpack.encode(name, value, never_index, dst, offset) - } - - /// Decode one header. Result aliases a shared buffer; copy before the next call. - #[inline] - pub(crate) fn decode<'a>(&'a mut self, src: &[u8]) -> Result, HpackError> { - self.hpack.decode(src) - } -} - -/// 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; - if value < 31 { - dst[i] = 0x20 | value as u8; - return 1; - } - dst[i] = 0x20 | 31; - i += 1; - let mut rest = value - 31; - while rest >= 128 { - dst[i] = (rest as u8) | 0x80; - i += 1; - rest >>= 7; - } - dst[i] = rest as u8; - i += 1; - i - offset -} - -#[cfg(test)] -mod tests { - use super::*; - - #[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); - } - - #[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); - } -} diff --git a/src/runtime/api/bun/h2/mod.rs b/src/runtime/api/bun/h2/mod.rs index 32c889387393..5450c97b0d93 100644 --- a/src/runtime/api/bun/h2/mod.rs +++ b/src/runtime/api/bun/h2/mod.rs @@ -1,12 +1,6 @@ -//! From-scratch rewrite of the `node:http2` native engine (replacing `h2_frame_parser.rs`). -//! -//! Clean-room, spec-organized modules. HPACK is the only piece reused (lshpack via `bun_http`). -//! Build order: wire -> settings -> flow_control -> hpack -> stream -> connection, then the -//! JSC binding is pointed at this engine and `h2_frame_parser.rs` is removed. +//! The protocol engine of `node:http2`. `h2_frame_parser.rs` binds it to JS and to the socket. pub(crate) mod connection; -pub(crate) mod flow_control; -pub(crate) mod hpack; -pub(crate) mod settings; +mod field_checks; +pub(crate) mod outbound_fields; pub(crate) mod stream; -pub(crate) mod wire; diff --git a/src/runtime/api/bun/h2/outbound_fields.rs b/src/runtime/api/bun/h2/outbound_fields.rs new file mode 100644 index 000000000000..d85576f477ad --- /dev/null +++ b/src/runtime/api/bun/h2/outbound_fields.rs @@ -0,0 +1,283 @@ +//! The one walk over the headers JS hands to `node:http2`: validates them and stages them for the HPACK encoder. + +use std::borrow::Cow; + +use bstr::BStr; +use bun_core::strings; +use bun_http::lshpack::HeaderBlock; +use bun_http_types::is_token_char; +use bun_jsc::{ErrorCode, JSGlobalObject, JSValue, JsResult}; + +/// Which pseudo-headers a block may have. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum BlockRole { + /// A request, or the promised request of a PUSH_PROMISE. + Request, + Response, + Trailers, +} + +impl BlockRole { + fn allows_pseudo_header(self, name: &[u8]) -> bool { + match self { + BlockRole::Request => matches!( + name, + b":path" | b":method" | b":scheme" | b":protocol" | b":authority" + ), + BlockRole::Response => name == b":status", + BlockRole::Trailers => false, + } + } +} + +const SINGLE_VALUE_HEADERS_LEN: usize = 40; + +bun_core::comptime_string_map! { + /// Each header that takes one value, with its slot in `Walk::seen_single_value`. + static SINGLE_VALUE_HEADERS: usize = { + b"tk" => 36, + b"age" => 9, + b"dnt" => 19, + b"date" => 18, + b"etag" => 20, + b"from" => 22, + b"host" => 23, + b":path" => 4, + b"range" => 33, + b":status" => 0, + b":method" => 1, + b":scheme" => 3, + b"expires" => 21, + b"referer" => 34, + b"if-match" => 24, + b"if-range" => 27, + b"location" => 30, + b":protocol" => 5, + b":authority" => 2, + b"user-agent" => 38, + b"content-md5" => 15, + b"retry-after" => 35, + b"content-type" => 17, + b"max-forwards" => 31, + b"authorization" => 10, + b"content-range" => 16, + b"if-none-match" => 26, + b"last-modified" => 29, + b"content-length" => 13, + b"content-encoding" => 11, + b"content-language" => 12, + b"content-location" => 14, + b"if-modified-since" => 25, + b"if-unmodified-since" => 28, + b"proxy-authorization" => 32, + b"access-control-max-age" => 7, + b"x-content-type-options" => 39, + b"upgrade-insecure-requests" => 37, + b"access-control-request-method" => 8, + b"access-control-allow-credentials" => 6, + }; +} + +/// lshpack's limit on a header name. +const MAX_NAME_LEN: usize = 4096; + +/// The wire bytes of a header value, an ALTSVC field or an origin: one byte per code unit like Node, or UTF-8 if a unit is above 0xFF. +pub(crate) fn header_value_bytes<'a>(value: &'a bun_jsc::JSStringView<'_>) -> Cow<'a, [u8]> { + if !value.is_utf16() { + return Cow::Borrowed(value.latin1()); + } + let units = value.utf16(); + if units.iter().any(|&unit| unit > 0xFF) { + return Cow::Owned(value.to_owned_slice()); + } + let mut bytes = vec![0u8; units.len()]; + strings::copy_u16_into_u8(&mut bytes, units); + Cow::Owned(bytes) +} + +/// The lowercase form of `name`, or `None` unless it's a token, after the ":" of a pseudo-header. +fn lowercase_name<'a>(name: &'a [u8], buffer: &'a mut [u8; MAX_NAME_LEN]) -> Option<&'a [u8]> { + let token = name.strip_prefix(b":").unwrap_or(name); + if name.len() > MAX_NAME_LEN || !token.iter().all(|&c| is_token_char(c)) { + return None; + } + if !name.iter().any(u8::is_ascii_uppercase) { + return Some(name); + } + let lowered = &mut buffer[..name.len()]; + lowered.copy_from_slice(name); + lowered.make_ascii_lowercase(); + Some(lowered) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Pass { + Pseudo, + Regular, + /// Trailers have no pseudo-headers to put first. + All, +} + +struct Walk<'a> { + global: &'a JSGlobalObject, + /// An object whose keys are the lowercase names that are never indexed. + sensitive: JSValue, + role: BlockRole, + strict_single_value: bool, + seen_single_value: [bool; SINGLE_VALUE_HEADERS_LEN], + name_buffer: [u8; MAX_NAME_LEN], + block: Option<&'a mut HeaderBlock>, +} + +impl Walk<'_> { + fn field(&mut self, pass: Pass, name: &[u8], js_value: JSValue) -> JsResult<()> { + let global = self.global; + let lowered = lowercase_name(name, &mut self.name_buffer); + let is_pseudo = name.first() == Some(&b':'); + if is_pseudo && !lowered.is_some_and(|n| self.role.allows_pseudo_header(n)) { + return Err(global + .err( + ErrorCode::HTTP2_INVALID_PSEUDOHEADER, + format_args!( + "\"{}\" is an invalid pseudoheader or is used incorrectly", + BStr::new(name) + ), + ) + .throw()); + } + let Some(lowered) = lowered else { + return Err(global + .err( + ErrorCode::INVALID_HTTP_TOKEN, + format_args!( + "Header name must be a valid HTTP token [\"{}\"]", + BStr::new(name) + ), + ) + .throw()); + }; + if (pass == Pass::Pseudo && !is_pseudo) || (pass == Pass::Regular && is_pseudo) { + return Ok(()); + } + + // An array is one field per element. Like Node, an empty one is no field at all. + let mut elements = if js_value.js_type().is_array() { + Some(js_value.array_iterator(global)?) + } else { + None + }; + let field_count = elements.as_ref().map_or(1, |iter| iter.len); + if field_count == 0 { + return Ok(()); + } + if self.strict_single_value + && let Some(&slot) = SINGLE_VALUE_HEADERS.get(lowered) + { + if field_count > 1 || self.seen_single_value[slot] { + return Err(global + .err( + ErrorCode::HTTP2_HEADER_SINGLE_VALUE, + format_args!( + "Header field \"{}\" must only have a single value", + BStr::new(lowered) + ), + ) + .throw()); + } + self.seen_single_value[slot] = true; + } + // Validation stops here, before a value's toString() runs. + let Some(block) = self.block.as_deref_mut() else { + return Ok(()); + }; + + // A property lookup by an index-like name asserts, and such a name is never sensitive. + let never_index = !lowered.iter().all(u8::is_ascii_digit) + && self.sensitive.get_truthy(global, lowered)?.is_some(); + let mut push = |item: JSValue| -> JsResult<()> { + let view = item.to_js_string_view(global)?; + let value = header_value_bytes(&view); + if strings::contains_any(&value, b"\0\n\r") { + return Err(global + .err( + ErrorCode::HTTP2_INVALID_HEADER_VALUE, + format_args!("Invalid value for header \"{}\"", BStr::new(lowered)), + ) + .throw()); + } + block + .push(lowered, &value, never_index) + .map_err(|_| global.throw(format_args!("Failed to allocate header buffer"))) + }; + match elements.as_mut() { + Some(iter) => { + while let Some(item) = iter.next()? { + push(item)?; + } + Ok(()) + } + None => push(js_value), + } + } +} + +/// Stages the fields of `headers`, an object or a flat `[name, value, ...]` array, pseudo-headers first. Without a `block` it only checks the names. +pub(crate) fn collect_fields( + global: &JSGlobalObject, + headers: JSValue, + sensitive: JSValue, + role: BlockRole, + strict_single_value: bool, + block: Option<&mut HeaderBlock>, +) -> JsResult<()> { + let mut walk = Walk { + global, + sensitive, + role, + strict_single_value, + seen_single_value: [false; SINGLE_VALUE_HEADERS_LEN], + name_buffer: [0; MAX_NAME_LEN], + block, + }; + let passes: &[Pass] = if role == BlockRole::Trailers { + &[Pass::All] + } else { + &[Pass::Pseudo, Pass::Regular] + }; + let Some(object) = headers.get_object() else { + return Ok(()); + }; + for &pass in passes { + if headers.js_type().is_array() { + let mut pairs = headers.array_iterator(global)?; + while let Some(name_js) = pairs.next()? { + let Some(value_js) = pairs.next()? else { break }; + if name_js.is_empty_or_undefined_or_null() || value_js.is_undefined() { + continue; + } + let name_view = name_js.to_js_string_view(global)?; + let name = name_view.to_utf8(); + if !name.slice().is_empty() { + walk.field(pass, name.slice(), value_js)?; + } + } + continue; + } + let iter = bun_jsc::JSPropertyIterator::init( + global, + object, + bun_jsc::JSPropertyIteratorOptions { + skip_empty_name: false, + include_value: true, + ..Default::default() + }, + )?; + while let Some((name, value_js)) = iter.next()? { + if name.length() == 0 || value_js.is_undefined() { + continue; + } + walk.field(pass, name.to_utf8().slice(), value_js)?; + } + } + Ok(()) +} diff --git a/src/runtime/api/bun/h2/settings.rs b/src/runtime/api/bun/h2/settings.rs deleted file mode 100644 index 80ba8bd63b90..000000000000 --- a/src/runtime/api/bun/h2/settings.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! HTTP/2 SETTINGS (RFC 9113 §6.5). Pure: value semantics, range validation, on-wire packing, -//! and the INITIAL_WINDOW_SIZE retroactive-window delta. Part of the from-scratch rewrite. - -use super::wire::{self, ErrorCode, SettingId}; - -/// Logical SETTINGS values. Defaults match Node v27 `getDefaultSettings()` exactly -/// (note `max_concurrent_streams` = 2^32-1). -#[derive(Clone, Copy, Debug)] -pub(crate) struct Settings { - pub header_table_size: u32, - pub enable_push: u32, // 0/1 - pub max_concurrent_streams: u32, - pub initial_window_size: u32, - pub max_frame_size: u32, - pub max_header_list_size: u32, - pub enable_connect_protocol: u32, // 0/1 -} - -impl Default for Settings { - fn default() -> Self { - Settings { - header_table_size: 4_096, - enable_push: 1, - max_concurrent_streams: 4_294_967_295, // 2^32-1 (Node default) - initial_window_size: 65_535, - max_frame_size: 16_384, - max_header_list_size: 65_535, - enable_connect_protocol: 0, - } - } -} - -impl Settings { - pub(crate) fn apply(&mut self, id: SettingId, value: u32) { - match id { - SettingId::HeaderTableSize => self.header_table_size = value, - SettingId::EnablePush => self.enable_push = value, - SettingId::MaxConcurrentStreams => self.max_concurrent_streams = value, - SettingId::InitialWindowSize => self.initial_window_size = value, - SettingId::MaxFrameSize => self.max_frame_size = value, - SettingId::MaxHeaderListSize => self.max_header_list_size = value, - SettingId::EnableConnectProtocol => self.enable_connect_protocol = value, - SettingId::NoRfc7540Priorities => {} - } - } -} - -/// §6.5.2 single-value range validation. `Some(code)` = connection error to send, `None` = ok. -fn validate_unit(id: u16, value: u32) -> Option { - match SettingId::from_u16(id) { - // EnablePush (§6.5.2), EnableConnectProtocol (RFC 8441 §3), and NoRfc7540Priorities - // (RFC 9218 §2.1) are boolean-valued: anything other than 0/1 is a PROTOCOL_ERROR. - Some(SettingId::EnablePush) - | Some(SettingId::EnableConnectProtocol) - | Some(SettingId::NoRfc7540Priorities) - if value > 1 => - { - Some(ErrorCode::ProtocolError) - } - Some(SettingId::InitialWindowSize) if value > wire::MAX_WINDOW_SIZE => { - Some(ErrorCode::FlowControlError) - } - Some(SettingId::MaxFrameSize) - if value < wire::MAX_FRAME_SIZE_LOWER || value > wire::MAX_FRAME_SIZE_UPPER => - { - Some(ErrorCode::ProtocolError) - } - _ => None, // unknown settings are ignored (§6.5.2) - } -} - -/// Validate every 6-byte unit in a received SETTINGS payload; returns the first violation. -pub(crate) fn validate_payload(payload: &[u8]) -> Option { - let mut i = 0; - while i + 6 <= payload.len() { - let id = u16::from_be_bytes([payload[i], payload[i + 1]]); - let value = u32::from_be_bytes([ - payload[i + 2], - payload[i + 3], - payload[i + 4], - payload[i + 5], - ]); - if let Some(code) = validate_unit(id, value) { - return Some(code); - } - i += 6; - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn enable_push_out_of_range() { - assert_eq!( - validate_unit(SettingId::EnablePush as u16, 2), - Some(ErrorCode::ProtocolError) - ); - assert_eq!(validate_unit(SettingId::EnablePush as u16, 1), None); - } - - #[test] - fn initial_window_overflow() { - assert_eq!( - validate_unit(SettingId::InitialWindowSize as u16, 0x8000_0000), - Some(ErrorCode::FlowControlError) - ); - } - - #[test] - fn max_frame_size_bounds() { - assert_eq!( - validate_unit(SettingId::MaxFrameSize as u16, 1000), - Some(ErrorCode::ProtocolError) - ); - assert_eq!(validate_unit(SettingId::MaxFrameSize as u16, 16_384), None); - } -} diff --git a/src/runtime/api/bun/h2/stream.rs b/src/runtime/api/bun/h2/stream.rs index e44dd9dab1f2..4d10d49ba7e2 100644 --- a/src/runtime/api/bun/h2/stream.rs +++ b/src/runtime/api/bun/h2/stream.rs @@ -1,145 +1,371 @@ -//! HTTP/2 stream state machine (RFC 9113 §5.1). Pure. Part of the from-scratch rewrite. -//! -//! The integer values of `State` match the contract the JS layer expects (they are passed to JS -//! as the stream `state`): IDLE=1, OPEN=2, RESERVED_LOCAL=3, RESERVED_REMOTE=4, -//! HALF_CLOSED_LOCAL=5, HALF_CLOSED_REMOTE=6, CLOSED=7. +//! One `node:http2` stream, and the table of a session's streams. -#![allow(dead_code)] +use std::cell::Cell; +use std::collections::VecDeque; +use std::rc::Rc; +use bun_collections::HashMap; +use bun_http_types::h2::flow_control::{RecvWindow, SendWindow}; +use bun_http_types::h2::frame::ErrorCode; +use bun_http_types::h2::stream::{Event, State, TransitionError, transition}; +use bun_jsc::{JSGlobalObject, JSValue, StrongOptional}; +use bun_ptr::JsCell; + +/// Request methods that change how the response body is framed (nghttp2's METH_HEAD and METH_CONNECT flags). #[derive(Clone, Copy, PartialEq, Eq, Debug)] -#[repr(u8)] -pub(crate) enum State { - Idle = 1, - Open = 2, - ReservedLocal = 3, - ReservedRemote = 4, - HalfClosedLocal = 5, - HalfClosedRemote = 6, - Closed = 7, +pub(crate) enum RequestMethod { + Other, + Head, + Connect, } -/// A transition-driving event. `send_*` = we initiated; `recv_*` = peer initiated. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(crate) enum Event { - SendHeaders, - RecvHeaders, - SendHeadersEndStream, - RecvHeadersEndStream, - SendEndStream, // END_STREAM on DATA/trailers - RecvEndStream, - SendPushPromise, // reserve a stream (server push) - RecvPushPromise, // peer reserves a stream - SendRst, - RecvRst, +impl RequestMethod { + pub(crate) fn from_method(value: &[u8]) -> Self { + match value { + b"HEAD" => RequestMethod::Head, + b"CONNECT" => RequestMethod::Connect, + _ => RequestMethod::Other, + } + } } -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(crate) enum TransitionError { - /// §5.1: a frame for a stream where it is not allowed. - Protocol, - /// §5.1: a frame (other than PRIORITY) on a fully closed stream. - StreamClosed, +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum ResetBy { + Peer, + Us, +} + +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum PaddingStrategy { + #[default] + None, + Aligned, + Max, +} + +/// Runs `f` on a copy of the cell's value and stores the result back. +#[inline] +pub(crate) fn update(cell: &Cell, f: impl FnOnce(&mut T) -> R) -> R { + let mut value = cell.get(); + let result = f(&mut value); + cell.set(value); + result +} + +/// DATA that waits for a send window or for the socket. +#[derive(Default)] +pub(crate) struct PendingFrame { + pub end_stream: bool, + /// How much of `buffer` has been sent. + pub offset: usize, + pub buffer: Vec, + /// The write callback, called once all of `buffer` has been sent. + pub callback: StrongOptional, } -/// Apply `ev` to `state`, returning the new state per §5.1, or an error the caller raises. -pub(crate) fn transition(state: State, ev: Event) -> Result { - use Event::*; - use State::*; - Ok(match state { - Idle => match ev { - SendHeaders | RecvHeaders => Open, - SendHeadersEndStream => HalfClosedLocal, - RecvHeadersEndStream => HalfClosedRemote, - SendPushPromise => ReservedLocal, - RecvPushPromise => ReservedRemote, - // §5.1: RST_STREAM / DATA / etc. on an idle stream is a connection PROTOCOL_ERROR. - _ => return Err(TransitionError::Protocol), - }, - ReservedLocal => match ev { - SendHeaders => HalfClosedRemote, - // RFC 9113 5.1: reserved -> HEADERS moves to half-closed; END_STREAM on that same - // HEADERS closes the stream. - SendHeadersEndStream => Closed, - SendRst | RecvRst => Closed, - _ => return Err(TransitionError::Protocol), - }, - ReservedRemote => match ev { - RecvHeaders => HalfClosedLocal, - // RFC 9113 5.1: reserved -> HEADERS moves to half-closed; END_STREAM on that same - // HEADERS closes the stream. - RecvHeadersEndStream => Closed, - SendRst | RecvRst => Closed, - _ => return Err(TransitionError::Protocol), - }, - Open => match ev { - SendEndStream | SendHeadersEndStream => HalfClosedLocal, - RecvEndStream | RecvHeadersEndStream => HalfClosedRemote, - SendRst | RecvRst => Closed, - SendHeaders | RecvHeaders => Open, // trailing / info HEADERS - _ => return Err(TransitionError::Protocol), - }, - HalfClosedLocal => match ev { - RecvEndStream | RecvHeadersEndStream => Closed, - RecvHeaders => HalfClosedLocal, - SendRst | RecvRst => Closed, - _ => return Err(TransitionError::Protocol), - }, - HalfClosedRemote => match ev { - SendEndStream | SendHeadersEndStream => Closed, - SendHeaders => HalfClosedRemote, - SendRst | RecvRst => Closed, - // §5.1: receiving anything but WINDOW_UPDATE/PRIORITY/RST here is STREAM_CLOSED. - RecvEndStream | RecvHeaders | RecvHeadersEndStream => { - return Err(TransitionError::StreamClosed); +impl PendingFrame { + pub(crate) fn unsent(&self) -> &[u8] { + &self.buffer[self.offset..] + } +} + +/// Both halves of a stream. Fields are cells, because JS run by a callback or a write can reach the same stream again. +pub(crate) struct Stream { + pub id: u32, + /// We opened it: a client's request or a server's push. + pub is_local: bool, + /// Only the table changes it, because it counts the open streams. + state: Cell, + /// An RST_STREAM closed the stream. `Peer` once the peer has sent one, even after ours. + pub reset: Cell>, + /// The JS `Http2Stream`. + pub js_context: JsCell, + /// JS destroyed its `Http2Stream`. + released: Cell, + /// The handler that tells JS about the close has run, or is running for a close that JS asked for. + pub close_delivered: Cell, + /// `StreamTable::read_count` when the stream closed, or 0 if no read was being parsed. + closed_in_read: Cell, + + pub recv_window: Cell, + /// The id is in `Connection::half_used_windows`. + pub replenish_queued: Cell, + /// The JS readable is paused, so the receive window isn't replenished. + pub reading_paused: Cell, + /// A final header block arrived, so the next inbound HEADERS is a trailer section (RFC 9113 §8.1). + pub recv_final_headers: Cell, + /// The request had a `host`, so its trailer section can't have another. + pub recv_host: Cell, + /// Declared `content-length` of the inbound message (RFC 9113 §8.1.1). + pub content_length: Cell>, + /// DATA octets received so far, without padding. + pub recv_body_bytes: Cell, + /// We had already sent GOAWAY when the peer opened this stream. + pub opened_after_goaway: Cell, + /// Client only: `:method` of the request this stream answers. + pub request_method: Cell>, + + pub send_window: Cell, + /// END_STREAM goes out on the trailers, not on the last DATA frame. + pub wait_for_trailers: Cell, + pub padding_strategy: Cell, + pub queue: JsCell>, +} + +impl Stream { + pub(crate) fn new( + id: u32, + is_local: bool, + state: State, + initial_send: u32, + initial_recv: u32, + ) -> Self { + Stream { + id, + is_local, + state: Cell::new(state), + reset: Cell::new(None), + js_context: JsCell::new(StrongOptional::empty()), + released: Cell::new(false), + close_delivered: Cell::new(false), + closed_in_read: Cell::new(0), + replenish_queued: Cell::new(false), + recv_window: Cell::new(RecvWindow::new(initial_recv)), + reading_paused: Cell::new(false), + recv_final_headers: Cell::new(false), + recv_host: Cell::new(false), + content_length: Cell::new(None), + recv_body_bytes: Cell::new(0), + opened_after_goaway: Cell::new(false), + request_method: Cell::new(None), + send_window: Cell::new(SendWindow::new(initial_send)), + wait_for_trailers: Cell::new(false), + padding_strategy: Cell::new(PaddingStrategy::None), + queue: JsCell::new(VecDeque::new()), + } + } + + #[inline] + pub(crate) fn state(&self) -> State { + self.state.get() + } + + #[inline] + pub(crate) fn is_closed(&self) -> bool { + self.state.get() == State::Closed + } + + /// Closed, and JS has no `Http2Stream` for it any more. + fn is_forgettable(&self) -> bool { + self.is_closed() && (self.released.get() || !self.js_context.get().has()) + } + + #[inline] + pub(crate) fn was_reset(&self) -> bool { + self.reset.get().is_some() + } + + /// The state inbound frames are judged by. After both sides' END_STREAM the peer still owes what half-closed (remote) demands. + pub(crate) fn inbound_state(&self) -> State { + if self.is_closed() && !self.was_reset() { + State::HalfClosedRemote + } else { + self.state.get() + } + } + + /// What JS handlers get as the stream: the `Http2Stream`, or the id while there's none. + pub(crate) fn context(&self) -> JSValue { + self.js_context + .get() + .get() + .unwrap_or_else(|| JSValue::js_number(self.id as f64)) + } + + pub(crate) fn set_context(&self, value: JSValue, global: &JSGlobalObject) { + self.js_context.set(StrongOptional::create(value, global)); + } + + /// The stream error that answers an inbound DATA frame, if the stream has to refuse it. + pub(crate) fn data_refusal(&self, is_server: bool) -> Option { + if !self.inbound_state().can_receive() { + return Some(ErrorCode::STREAM_CLOSED); + } + // 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.get() { + return Some(ErrorCode::PROTOCOL_ERROR); + } + None + } + + /// Returns true if the DATA received so far differs from the declared `content-length` (RFC 9113 §8.1.1). + pub(crate) fn body_contradicts_content_length(&self) -> bool { + self.content_length + .get() + .is_some_and(|declared| declared != self.recv_body_bytes.get()) + } + + /// Returns true if more DATA arrived than the declared `content-length`. + pub(crate) fn body_exceeds_content_length(&self) -> bool { + self.content_length + .get() + .is_some_and(|declared| self.recv_body_bytes.get() > declared) + } + + /// The Pad Length of a frame with `frame_len` octets of content, which may grow to `max_len`. + pub(crate) fn padding_for(&self, frame_len: usize, max_len: usize) -> u8 { + match self.padding_strategy.get() { + PaddingStrategy::None => 0, + PaddingStrategy::Aligned => { + let past_boundary = (frame_len + 9) % 8; + if past_boundary == 0 { + return 0; + } + let padded_len = (frame_len + (8 - past_boundary)).min(max_len); + padded_len.saturating_sub(frame_len).min(255) as u8 } - _ => return Err(TransitionError::Protocol), - }, - Closed => match ev { - SendRst | RecvRst => Closed, - // §5.1: a frame on a closed stream is STREAM_CLOSED (PRIORITY handled before here). - _ => return Err(TransitionError::StreamClosed), - }, - }) + PaddingStrategy::Max => max_len.saturating_sub(frame_len).min(255) as u8, + } + } } -/// §6.1/§5.1: DATA may only be received in `open` or `half-closed (local)`. In particular a -/// promised stream still in `reserved (remote)` must not be handed DATA before its response -/// HEADERS arrive. (Locally-opened streams the engine never saw are shimmed to `Open` by the -/// caller before this check.) -pub(crate) fn can_receive_data(state: State) -> bool { - matches!(state, State::Open | State::HalfClosedLocal) +/// A session's streams: the open ones, and the closed ones that JS still has an `Http2Stream` for. Entries are shared, so a caller can hold one across JS that removes it. +#[derive(Default)] +pub(crate) struct StreamTable { + map: JsCell>>, + /// The entries that aren't closed, by who opened them (RFC 9113 §5.1.2). + open_local: Cell, + open_peer: Cell, + /// The reads that are being parsed, one inside the other. Nothing is forgotten meanwhile. + read_depth: Cell, + read_count: Cell, + /// `end_read` has entries to forget. + has_forgettable: Cell, } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn open_lifecycle() { - assert_eq!(transition(State::Idle, Event::RecvHeaders), Ok(State::Open)); - assert_eq!( - transition(State::Open, Event::RecvEndStream), - Ok(State::HalfClosedRemote) - ); - assert_eq!( - transition(State::HalfClosedRemote, Event::SendEndStream), - Ok(State::Closed) - ); - } - - #[test] - fn rst_on_idle_is_protocol_error() { - assert_eq!( - transition(State::Idle, Event::RecvRst), - Err(TransitionError::Protocol) - ); - } - - #[test] - fn data_after_peer_end_is_stream_closed() { - assert_eq!( - transition(State::HalfClosedRemote, Event::RecvEndStream), - Err(TransitionError::StreamClosed) - ); +impl StreamTable { + #[inline] + pub(crate) fn get(&self, id: u32) -> Option> { + self.map.get().get(&id).cloned() + } + + /// The entry that judges an inbound frame. Once the read that closed a stream is over, its id does, like that of a forgotten one. + pub(crate) fn inbound(&self, id: u32) -> Option> { + self.get(id).filter(|stream| { + !stream.is_closed() + || (self.is_reading() && stream.closed_in_read.get() == self.read_count.get()) + }) + } + + pub(crate) fn len(&self) -> usize { + self.map.get().len() + } + + pub(crate) fn open_local(&self) -> u32 { + self.open_local.get() + } + + pub(crate) fn open_peer(&self) -> u32 { + self.open_peer.get() + } + + fn open_count(&self, stream: &Stream) -> &Cell { + if stream.is_local { + &self.open_local + } else { + &self.open_peer + } + } + + pub(crate) fn insert(&self, stream: Stream) -> Rc { + let open = self.open_count(&stream); + open.set(open.get() + 1); + let stream = Rc::new(stream); + self.map + .with_mut(|map| map.insert(stream.id, Rc::clone(&stream))); + stream + } + + /// Moves `stream` through the state machine. An event that its state doesn't allow changes nothing. + pub(crate) fn apply(&self, stream: &Stream, event: Event) -> Result { + let next = transition(stream.state.get(), event)?; + if next == State::Closed { + self.close(stream); + } else { + stream.state.set(next); + } + Ok(next) + } + + /// Returns false if `stream` was closed already. + pub(crate) fn close(&self, stream: &Stream) -> bool { + if stream.state.replace(State::Closed) == State::Closed { + return false; + } + let open = self.open_count(stream); + open.set(open.get() - 1); + if self.is_reading() { + stream.closed_in_read.set(self.read_count.get()); + } + self.forget_if_done(stream); + true + } + + /// JS destroyed its `Http2Stream`. Returns true if `stream` is closed. + pub(crate) fn release(&self, stream: &Stream) -> bool { + stream.released.set(true); + self.forget_if_done(stream); + stream.is_closed() + } + + fn forget_if_done(&self, stream: &Stream) { + if !stream.is_forgettable() { + return; + } + if self.is_reading() { + self.has_forgettable.set(true); + } else { + self.map.with_mut(|map| map.remove(&stream.id)); + } + } + + pub(crate) fn is_reading(&self) -> bool { + self.read_depth.get() > 0 + } + + pub(crate) fn begin_read(&self) { + if !self.is_reading() { + self.read_count + .set(self.read_count.get().wrapping_add(1).max(1)); + } + self.read_depth.set(self.read_depth.get() + 1); + } + + pub(crate) fn end_read(&self) { + self.read_depth.set(self.read_depth.get() - 1); + if !self.is_reading() && self.has_forgettable.replace(false) { + self.map + .with_mut(|map| map.retain(|_, stream| !stream.is_forgettable())); + } + } + + /// Forgets every stream, open or not. + pub(crate) fn clear(&self) { + drop(self.map.take()); + self.open_local.set(0); + self.open_peer.set(0); + self.has_forgettable.set(false); + } + + /// Every entry, for a loop that runs JS. A stream added meanwhile isn't visited. + pub(crate) fn snapshot(&self) -> Vec> { + self.map.get().values().cloned().collect() + } + + /// Calls `f` for every entry. `f` must not run JS or change the table. + pub(crate) fn for_each(&self, mut f: impl FnMut(&Stream)) { + for stream in self.map.get().values() { + f(stream); + } } } diff --git a/src/runtime/api/bun/h2/wire.rs b/src/runtime/api/bun/h2/wire.rs deleted file mode 100644 index 3606f16b9caa..000000000000 --- a/src/runtime/api/bun/h2/wire.rs +++ /dev/null @@ -1,350 +0,0 @@ -//! HTTP/2 wire-format definitions (RFC 9113 + extensions). Pure: no JSC, no allocation. -//! -//! This is part of the from-scratch rewrite of `node:http2` (replacing the ported -//! `h2_frame_parser.rs`). Spec section numbers reference RFC 9113 unless noted. - -#![allow(dead_code)] - -/// RFC 9113 §3.4: the 24-octet client connection preface. -pub(crate) const CONNECTION_PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"; - -/// §4.1: fixed 9-octet frame header. -pub(crate) const FRAME_HEADER_SIZE: usize = 9; - -/// §4.2 SETTINGS_MAX_FRAME_SIZE bounds and default. -pub(crate) const MAX_FRAME_SIZE_DEFAULT: u32 = 16_384; // 2^14 -pub(crate) const MAX_FRAME_SIZE_LOWER: u32 = 16_384; // 2^14 -pub(crate) const MAX_FRAME_SIZE_UPPER: u32 = 16_777_215; // 2^24 - 1 - -/// §6.9.1 flow-control window bounds. -pub(crate) const DEFAULT_WINDOW_SIZE: u32 = 65_535; // 2^16 - 1 -pub(crate) const MAX_WINDOW_SIZE: u32 = 2_147_483_647; // 2^31 - 1 - -/// RFC 9113 §6 frame type registry (+ RFC 7838 ALTSVC, RFC 8336 ORIGIN). -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -#[repr(u8)] -pub(crate) enum FrameType { - Data = 0x00, - Headers = 0x01, - Priority = 0x02, - RstStream = 0x03, - Settings = 0x04, - PushPromise = 0x05, - Ping = 0x06, - GoAway = 0x07, - WindowUpdate = 0x08, - Continuation = 0x09, - AltSvc = 0x0a, // RFC 7838 §4 - Origin = 0x0c, // RFC 8336 §2 -} - -impl FrameType { - pub(crate) fn from_u8(v: u8) -> Option { - Some(match v { - 0x00 => FrameType::Data, - 0x01 => FrameType::Headers, - 0x02 => FrameType::Priority, - 0x03 => FrameType::RstStream, - 0x04 => FrameType::Settings, - 0x05 => FrameType::PushPromise, - 0x06 => FrameType::Ping, - 0x07 => FrameType::GoAway, - 0x08 => FrameType::WindowUpdate, - 0x09 => FrameType::Continuation, - 0x0a => FrameType::AltSvc, - 0x0c => FrameType::Origin, - _ => return None, // §4.1: unknown types are ignored. - }) - } -} - -/// §6 frame flag bits. Bits are reused across frame types, so they are named generically. -pub(crate) mod flags { - pub(crate) const ACK: u8 = 0x01; // SETTINGS, PING - pub(crate) const END_STREAM: u8 = 0x01; // DATA, HEADERS - pub(crate) const END_HEADERS: u8 = 0x04; // HEADERS, PUSH_PROMISE, CONTINUATION - pub(crate) const PADDED: u8 = 0x08; // DATA, HEADERS, PUSH_PROMISE - pub(crate) const PRIORITY: u8 = 0x20; // HEADERS - - #[inline] - pub(crate) fn has(flags: u8, mask: u8) -> bool { - flags & mask != 0 - } -} - -/// RFC 9113 §7 error codes. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -#[repr(u32)] -pub(crate) enum ErrorCode { - NoError = 0x0, - ProtocolError = 0x1, - InternalError = 0x2, - FlowControlError = 0x3, - SettingsTimeout = 0x4, - StreamClosed = 0x5, - FrameSizeError = 0x6, - RefusedStream = 0x7, - Cancel = 0x8, - CompressionError = 0x9, - ConnectError = 0xa, - EnhanceYourCalm = 0xb, - InadequateSecurity = 0xc, - Http11Required = 0xd, -} - -impl ErrorCode { - #[inline] - pub(crate) fn as_u32(self) -> u32 { - self as u32 - } -} - -/// nghttp2 library error codes (negative). The embedder surfaces locally-detected connection -/// errors to JS with one of these so node's `NghttpError` shape (code `ERR_HTTP2_ERROR`, message -/// `nghttp2_strerror(code)`) can be reproduced exactly. -/// https://github.com/nghttp2/nghttp2/blob/master/lib/includes/nghttp2/nghttp2.h (nghttp2_error) -pub(crate) mod lib_error { - /// NGHTTP2_ERR_PROTO — "Protocol error" - pub(crate) const PROTO: i32 = -505; - /// NGHTTP2_ERR_STREAM_CLOSED — "Stream was already closed or invalid" - pub(crate) const STREAM_CLOSED: i32 = -510; - /// NGHTTP2_ERR_BAD_CLIENT_MAGIC — "Received bad client magic byte string" - 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; -} - -/// RFC 9113 §6.5.2 SETTINGS parameter registry (+ RFC 8441, RFC 9218). -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -#[repr(u16)] -pub(crate) enum SettingId { - HeaderTableSize = 0x1, - EnablePush = 0x2, - MaxConcurrentStreams = 0x3, - InitialWindowSize = 0x4, - MaxFrameSize = 0x5, - MaxHeaderListSize = 0x6, - EnableConnectProtocol = 0x8, // RFC 8441 - NoRfc7540Priorities = 0x9, // RFC 9218 -} - -impl SettingId { - pub(crate) fn from_u16(v: u16) -> Option { - Some(match v { - 0x1 => SettingId::HeaderTableSize, - 0x2 => SettingId::EnablePush, - 0x3 => SettingId::MaxConcurrentStreams, - 0x4 => SettingId::InitialWindowSize, - 0x5 => SettingId::MaxFrameSize, - 0x6 => SettingId::MaxHeaderListSize, - 0x8 => SettingId::EnableConnectProtocol, - 0x9 => SettingId::NoRfc7540Priorities, - _ => return None, // §6.5.2: unknown settings are ignored. - }) - } -} - -/// RFC 9113 §4.1 frame header (9 octets, big-endian on the wire). -#[derive(Clone, Copy, Debug)] -pub(crate) struct FrameHeader { - /// 24-bit payload length. - pub length: u32, - pub frame_type: u8, - pub flags: u8, - /// Stream identifier with the reserved high bit already cleared. - pub stream_id: u32, -} - -impl FrameHeader { - /// Parse a 9-byte header from `buf` (must be >= 9 bytes), big-endian → host, reserved bit cleared. - pub(crate) fn parse(buf: &[u8]) -> FrameHeader { - debug_assert!(buf.len() >= FRAME_HEADER_SIZE); - let length = (buf[0] as u32) << 16 | (buf[1] as u32) << 8 | (buf[2] as u32); - let frame_type = buf[3]; - let flags = buf[4]; - let stream_id = u32::from_be_bytes([buf[5], buf[6], buf[7], buf[8]]) & 0x7fff_ffff; - FrameHeader { - length, - frame_type, - flags, - stream_id, - } - } - - /// Serialize this header into a 9-byte big-endian buffer. - pub(crate) fn write(&self, out: &mut [u8; FRAME_HEADER_SIZE]) { - out[0] = (self.length >> 16) as u8; - out[1] = (self.length >> 8) as u8; - out[2] = self.length as u8; - out[3] = self.frame_type; - out[4] = self.flags; - out[5..9].copy_from_slice(&(self.stream_id & 0x7fff_ffff).to_be_bytes()); - } - - #[inline] - pub(crate) fn typ(&self) -> Option { - FrameType::from_u8(self.frame_type) - } -} - -/// Outcome of validating an inbound frame header against §4.2/§6 structural rules -/// (independent of stream state, which the state machine checks separately). -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(crate) enum HeaderValidation { - Ok, - /// Send GOAWAY with this code and close the connection. - ConnectionError(ErrorCode), - /// Send RST_STREAM with this code on the given stream. - StreamError { - id: u32, - code: ErrorCode, - }, -} - -/// Whether a frame type's stream identifier must be zero, non-zero, or may be either (§4.1/§6). -fn stream_scope(t: FrameType) -> StreamScope { - match t { - FrameType::Settings | FrameType::Ping | FrameType::GoAway => StreamScope::Connection, - FrameType::Data - | FrameType::Headers - | FrameType::Priority - | FrameType::RstStream - | FrameType::PushPromise - | FrameType::Continuation => StreamScope::Stream, - // WINDOW_UPDATE is valid on stream 0 and on a stream (§6.9); ALTSVC on either (RFC 7838 §4). - // ORIGIN on a non-zero stream is ignored, not erred (RFC 8336 §2) - handle_origin drops it. - FrameType::WindowUpdate | FrameType::AltSvc | FrameType::Origin => StreamScope::Either, - } -} - -enum StreamScope { - Connection, - Stream, - Either, -} - -/// §4.2 + §6 structural validation of a frame header given the negotiated max frame size: -/// length bounds, the stream-id 0-vs-nonzero rule, and the fixed-length frame rules. -/// Does NOT check stream state. -pub(crate) fn validate_header(hdr: &FrameHeader, local_max_frame_size: u32) -> HeaderValidation { - let Some(t) = hdr.typ() else { - // Unknown frame type: caller discards it (§4.1); not an error here. - return HeaderValidation::Ok; - }; - - // §4.2: a frame larger than SETTINGS_MAX_FRAME_SIZE is an error. - if hdr.length > local_max_frame_size { - return frame_size_error(hdr, t); - } - - match stream_scope(t) { - StreamScope::Connection if hdr.stream_id != 0 => { - return HeaderValidation::ConnectionError(ErrorCode::ProtocolError); - } - StreamScope::Stream if hdr.stream_id == 0 => { - return HeaderValidation::ConnectionError(ErrorCode::ProtocolError); - } - _ => {} - } - - // §6: fixed-length frames. - let fixed: Option = match t { - FrameType::Priority => Some(5), - FrameType::RstStream => Some(4), - FrameType::Ping => Some(8), - FrameType::WindowUpdate => Some(4), - _ => None, - }; - if let Some(len) = fixed { - if hdr.length != len { - return frame_size_error(hdr, t); - } - } - // §6.5: SETTINGS length must be a multiple of 6. - if t == FrameType::Settings && !hdr.length.is_multiple_of(6) { - return frame_size_error(hdr, t); - } - - HeaderValidation::Ok -} - -fn frame_size_error(hdr: &FrameHeader, t: FrameType) -> HeaderValidation { - // §4.2 + per-frame rules: a frame-size error is a CONNECTION error except for DATA, which is - // a stream error on a non-zero stream. §6.3 would allow a stream error for PRIORITY too, but - // nghttp2 (and therefore node) treats a bad-length PRIORITY as a connection error — verified - // against node v26.3.0 — so we match that. RST_STREAM (§6.4) and WINDOW_UPDATE (§6.9) bad - // lengths are connection errors. - let stream_level = hdr.stream_id != 0 && matches!(t, FrameType::Data); - if stream_level { - HeaderValidation::StreamError { - id: hdr.stream_id, - code: ErrorCode::FrameSizeError, - } - } else { - HeaderValidation::ConnectionError(ErrorCode::FrameSizeError) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn frame_header_roundtrip() { - let hdr = FrameHeader { - length: 0x010203, - frame_type: 0x04, - flags: 0x01, - stream_id: 0x7abcdef0 & 0x7fffffff, - }; - let mut buf = [0u8; FRAME_HEADER_SIZE]; - hdr.write(&mut buf); - let back = FrameHeader::parse(&buf); - assert_eq!(back.length, hdr.length); - assert_eq!(back.frame_type, hdr.frame_type); - assert_eq!(back.flags, hdr.flags); - assert_eq!(back.stream_id, hdr.stream_id); - } - - #[test] - fn settings_on_nonzero_stream_is_connection_error() { - let hdr = FrameHeader { - length: 0, - frame_type: FrameType::Settings as u8, - flags: 0, - stream_id: 1, - }; - assert_eq!( - validate_header(&hdr, MAX_FRAME_SIZE_DEFAULT), - HeaderValidation::ConnectionError(ErrorCode::ProtocolError) - ); - } - - #[test] - fn ping_wrong_length_is_frame_size_error() { - let hdr = FrameHeader { - length: 6, - frame_type: FrameType::Ping as u8, - flags: 0, - stream_id: 0, - }; - assert_eq!( - validate_header(&hdr, MAX_FRAME_SIZE_DEFAULT), - HeaderValidation::ConnectionError(ErrorCode::FrameSizeError) - ); - } - - #[test] - fn data_on_stream_zero_is_protocol_error() { - let hdr = FrameHeader { - length: 3, - frame_type: FrameType::Data as u8, - flags: 0, - stream_id: 0, - }; - assert_eq!( - validate_header(&hdr, MAX_FRAME_SIZE_DEFAULT), - HeaderValidation::ConnectionError(ErrorCode::ProtocolError) - ); - } -} diff --git a/src/runtime/api/bun/h2_frame_parser.bind.ts b/src/runtime/api/bun/h2_frame_parser.bind.ts index 0ea792d8c6c5..26888f43fc91 100644 --- a/src/runtime/api/bun/h2_frame_parser.bind.ts +++ b/src/runtime/api/bun/h2_frame_parser.bind.ts @@ -2,16 +2,53 @@ import * as b from "../../../codegen/bindgen-lib.ts"; import * as table from "../../../codegen/builtins/lib.ts"; const js = { capability: "js", throws: true } as const; -const method = { returns: b.any, ...js } as const; -const stream = { ...method, args: { streamId: b.any } } as const; +const buffer = b.bytes({ from: ["buffer"], held: "handle" }); + +/** What the session calls. Each gets the `context` of the options first. */ +export const H2Handlers = b.dictionary({ + streamStart: b.callable.optional, + streamHeaders: b.callable.optional, + streamEnd: b.callable.optional, + streamData: b.callable.optional, + streamError: b.callable.optional, + remoteSettings: b.callable.optional, + localSettings: b.callable.optional, + wantTrailers: b.callable.optional, + ping: b.callable.optional, + end: b.callable.optional, + error: b.callable.optional, + goaway: b.callable.optional, + aborted: b.callable.optional, + /** Takes the output of a session with no native socket. */ + write: b.callable, + altsvc: b.callable.optional, + origin: b.callable.optional, + frameError: b.callable.optional, + streamPush: b.callable.optional, +}); +/** `native` is the handle of a `net.Socket` or `tls.TLSSocket`, `settings` the session's options and initial settings, and `type` 0 for a server. */ +export const H2FrameParserOptions = b.dictionary({ + context: b.any, + handlers: H2Handlers, + native: b.any, + settings: b.any, + type: { type: b.u32.default(1), rustName: "session_type" }, +}); /** The session of `node:http2`. */ export const H2FrameParser = b.class({ rustPath: "crate::api::H2FrameParser", refCounted: true, finalize: true, + memoryCost: true, types: [table.exactType("$Node.H2FrameParser"), table.exactType("$Bindgen.H2FrameParser")], - construct: { passStructure: true, args: { options: b.any }, capability: "js", throws: true, length: 0 }, + construct: { + passStructure: true, + args: { options: H2FrameParserOptions }, + capability: "js", + throws: true, + length: 0, + }, /** `context` is the receiver of the handlers, which are the `handlers` of the options. */ internalFields: [ "context", @@ -35,51 +72,67 @@ export const H2FrameParser = b.class({ "onStreamPush", ], proto: { - altsvc: { ...js, args: { origin: b.any, value: b.any, streamId: b.any } }, + altsvc: { ...js, args: { origin: b.any, value: b.any, streamId: b.u32 } }, bufferSize: { returns: b.f64 }, - detach: { capability: "js", rustName: "detach_from_js" }, - emitAbortToAllStreams: { capability: "js" }, - emitErrorToAllStreams: { ...js, args: { code: b.any } }, + detach: { + args: { keepOutput: b.bool.loose.default(false) }, + capability: "js", + rustName: "detach_from_js", + length: 1, + }, + emitErrorToAllStreams: { args: { code: b.u32 }, capability: "js" }, flush: { returns: b.usize, capability: "js", rustName: "flush_from_js" }, - forEachStream: { args: { callback: b.any, thisArg: b.any }, capability: "js" }, + forEachStream: { args: { callback: b.callable, closed: b.bool.default(false) }, capability: "js" }, getCurrentState: { returns: b.any.returning("object"), capability: "js" }, - getEndAfterHeaders: { ...stream, returns: b.bool }, getFrameCounters: { returns: b.any.returning("object"), capability: "js" }, getNextStream: { returns: b.i32, capability: "js" }, - getStreamContext: stream, - getStreamState: { ...stream, returns: b.any.returning("object") }, - goaway: { ...js, args: { code: b.any, lastStreamID: b.any, opaqueData: b.any } }, - noTrailers: { ...js, args: { streamId: b.any } }, + getStreamContext: { args: { streamId: b.u32 }, returns: b.any, capability: "js" }, + getStreamState: { args: { streamId: b.u32 }, returns: b.any.returning("object", "undefined"), capability: "js" }, + goaway: { args: { code: b.u32, lastStreamID: b.i32.wrap, opaqueData: buffer }, capability: "js" }, + hasOpenStreams: { returns: b.bool }, + hasRejectedSend: { returns: b.bool }, + isStreamClosed: { args: { streamId: b.u32 }, returns: b.bool }, + noTrailers: { args: { streamId: b.u32 }, capability: "js" }, origin: { ...js, args: { origin: b.any } }, - ping: { ...js, args: { payload: b.any }, returns: b.bool, length: 0 }, + ping: { args: { payload: buffer }, returns: b.bool, capability: "js" }, pushPromise: { ...js, - args: { parentId: b.any, promisedId: b.any, headers: b.any, sensitiveHeaders: b.any }, + args: { parentId: b.u32, promisedId: b.u32, headers: b.any, sensitiveHeaders: b.any }, returns: b.any.returning("number"), }, - read: { ...js, args: { data: b.any } }, + read: { ...js, args: { data: buffer } }, + releaseStream: { args: { streamId: b.u32 }, returns: b.bool }, request: { ...js, - args: { streamId: b.any, context: b.any, headers: b.any, sensitiveHeaders: b.any, options: b.any }, - returns: b.any.returning("number", "undefined"), + args: { + streamId: b.u32, + context: b.any, + headers: b.any, + sensitiveHeaders: b.any, + options: b.any, + informational: b.bool.loose.default(false), + }, length: 2, }, - rstStream: { - ...js, - args: { streamId: b.any, code: b.any }, - returns: b.any.returning("boolean", "undefined"), - length: 1, - }, - sendTrailers: { ...js, args: { streamId: b.any, headers: b.any, sensitiveHeaders: b.any }, length: 2 }, - setLocalWindowSize: { ...js, args: { windowSize: b.any } }, + rstStream: { args: { streamId: b.u32, code: b.u32 }, capability: "js" }, + sendTrailers: { ...js, args: { streamId: b.u32, headers: b.any, sensitiveHeaders: b.any } }, + setLocalWindowSize: { args: { windowSize: b.u32 }, capability: "js" }, setNativeSocket: { args: { socket: b.any }, capability: "js" }, - setNextStreamID: { ...js, args: { streamId: b.any } }, - setStreamContext: { ...js, args: { streamId: b.any, context: b.any } }, - setStreamReading: { args: { streamId: b.any, reading: b.any }, capability: "js" }, - settings: { ...js, args: { settings: b.any }, returns: b.bool }, + setNextStreamID: { args: { streamId: b.u32.wrap }, capability: "js" }, + setStreamContext: { args: { streamId: b.u32, context: b.any }, capability: "js" }, + setStreamReading: { args: { streamId: b.u32, reading: b.bool }, capability: "js" }, + settings: { ...js, args: { settings: b.any } }, + validateRequest: { ...js, args: { headers: b.any } }, writeStream: { ...js, - args: { streamId: b.any, data: b.any, encoding: b.any, close: b.any, callback: b.any, deferCallback: b.any }, + args: { + streamId: b.u32, + data: b.any, + encoding: b.any, + close: b.bool, + callback: b.any, + deferCallback: b.bool.default(false), + }, returns: b.any.returning("boolean", "number"), length: 3, }, diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index b9ba74915f95..c122d6b657ef 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -1,36 +1,45 @@ -//! HTTP/2 frame parser. -#![allow( - non_camel_case_types, - non_upper_case_globals, - clippy::too_many_arguments -)] +//! The native session of `node:http2`: the JS binding, the transport and the send half. `h2/` has the protocol state and the receive half. use core::cell::{Cell, RefCell}; use core::ffi::c_void; use core::mem::ManuallyDrop; use core::ptr::NonNull; use std::borrow::Cow; +use std::collections::VecDeque; +use std::rc::Rc; +use crate::api::h2::connection::{Connection, FeedEnd, Sink, lib_error}; +use crate::api::h2::outbound_fields::{BlockRole, collect_fields, header_value_bytes}; +use crate::api::h2::stream::{ + PaddingStrategy, PendingFrame, RequestMethod, ResetBy, Stream, update, +}; use crate::api::socket::{TCPSocket, TLSSocket}; use crate::node::{Encoding, StringOrBuffer}; use crate::socket::NativeCallbacks; use crate::webcore::AutoFlusher; use bstr::BStr; -use bun_collections::{ByteVecExt, HashMap as BunHashMap, HiveArrayFallback, VecExt}; -use bun_core::strings; +use bun_collections::{ByteVecExt, HiveArrayFallback, VecExt}; use bun_http::lshpack; -use bun_jsc::AbortSignal; -use bun_jsc::ErrorCode as JscErrorCode; +use bun_http_types::h2::flow_control::DEFAULT_WINDOW_SIZE; +use bun_http_types::h2::frame::{ + CONNECTION_PREFACE, ErrorCode, FRAME_HEADER_SIZE, FrameHeader, FrameType, MAX_STREAM_ID, flags, + unparsed_rest, +}; +use bun_http_types::h2::settings::{self, SettingId, Settings}; +use bun_http_types::h2::stream::{Event as StreamEvent, State}; use bun_jsc::StringJsc as _; use bun_jsc::array_buffer::BinaryType; -use bun_jsc::bindgen::{JsLocal, JsScope, JsStructure, JsWrapper}; +use bun_jsc::bindgen::{Callable, JsBytes, JsLocal, JsScope, JsStructure, JsWrapper}; +use bun_jsc::rare_data::H2HeaderScratch; use bun_jsc::virtual_machine::VirtualMachine; use bun_jsc::{ - GlobalRef, JSGlobalObject, JSValue, JsCell, JsClass, JsRef, JsResult, StrongOptional, + GlobalRef, JSGlobalObject, JSValue, JsCell, JsClass, JsRef, JsResult, Strong, StrongOptional, }; use bun_ptr::RefPtr; -use crate::generated_bindgen::h2_frame_parser::js_H2FrameParser as js; +use crate::generated_bindgen::h2_frame_parser::{ + H2FrameParserOptions, H2Handlers, js_H2FrameParser as js, +}; bun_output::declare_scope!(H2FrameParser, visible); @@ -41,7 +50,7 @@ type GetCached = fn(JSValue) -> Option; // Constants // ────────────────────────────────────────────────────────────────────────── -const MAX_PAYLOAD_SIZE_WITHOUT_FRAME: usize = 16384 - FrameHeader::BYTE_SIZE - 1; +const MAX_PAYLOAD_SIZE_WITHOUT_FRAME: usize = 16384 - FRAME_HEADER_SIZE - 1; /// `Copy` view of [`NativeSocket`] for call sites to snapshot across /// re-entrant writes. BACKREF — the socket strictly outlives the attachment: @@ -133,7 +142,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. @@ -145,72 +153,15 @@ unsafe extern "C" { ) -> JSValue; } -// ────────────────────────────────────────────────────────────────────────── -// Local builder for throwing `HTTP2_INVALID_SETTING_VALUE*` errors; the -// ErrorCode table exposes them via `JscErrorCode::*`. -// ────────────────────────────────────────────────────────────────────────── -pub(crate) struct H2ErrBuilder<'a> { - global: &'a JSGlobalObject, - code: JscErrorCode, - msg: &'static str, -} -impl<'a> H2ErrBuilder<'a> { - #[inline] - pub(crate) fn throw(self) -> JsResult { - Err(self.code.throw(self.global, format_args!("{}", self.msg))) - } -} -pub(crate) trait H2GlobalErrExt { - fn err_http2_invalid_setting_value_range_error(&self, msg: &'static str) -> H2ErrBuilder<'_>; - fn err_http2_invalid_setting_value(&self, msg: &'static str) -> H2ErrBuilder<'_>; - fn err_http2_too_many_custom_settings(&self, msg: &'static str) -> H2ErrBuilder<'_>; -} -impl H2GlobalErrExt for JSGlobalObject { - #[inline] - fn err_http2_invalid_setting_value_range_error(&self, msg: &'static str) -> H2ErrBuilder<'_> { - H2ErrBuilder { - global: self, - code: JscErrorCode::HTTP2_INVALID_SETTING_VALUE_RangeError, - msg, - } - } - #[inline] - fn err_http2_invalid_setting_value(&self, msg: &'static str) -> H2ErrBuilder<'_> { - H2ErrBuilder { - global: self, - code: JscErrorCode::HTTP2_INVALID_SETTING_VALUE, - msg, - } - } - #[inline] - fn err_http2_too_many_custom_settings(&self, msg: &'static str) -> H2ErrBuilder<'_> { - H2ErrBuilder { - global: self, - code: JscErrorCode::HTTP2_TOO_MANY_CUSTOM_SETTINGS, - msg, - } - } -} - -const MAX_WINDOW_SIZE: u32 = i32::MAX as u32; -const MAX_HEADER_TABLE_SIZE: u32 = u32::MAX; -const MAX_STREAM_ID: u32 = i32::MAX as u32; -const MAX_FRAME_SIZE: u32 = 0xFF_FFFF; // u24::MAX // node's kMaxALTSVC: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js#L241 const MAX_ALTSVC_LENGTH: usize = 16384 - 2; -const DEFAULT_WINDOW_SIZE: u64 = u16::MAX as u64; -// Float versions for range validation before integer conversion -const MAX_WINDOW_SIZE_F64: f64 = MAX_WINDOW_SIZE as f64; -const MAX_HEADER_TABLE_SIZE_F64: f64 = MAX_HEADER_TABLE_SIZE as f64; -const MAX_FRAME_SIZE_F64: f64 = MAX_FRAME_SIZE as f64; // writeStream() return-value flag (bitwise-OR'd with the settled stream state, which is < 8): // the data was flushed without queueing and the engine did not invoke the write callback — // 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; @@ -228,405 +179,28 @@ const SETTING_BIT_ENABLE_CONNECT_PROTOCOL: u8 = 1 << 6; /// MAX_CUSTOM_SETTINGS custom entries, 6 bytes each. const MAX_SETTINGS_PAYLOAD_SIZE: usize = (7 + MAX_CUSTOM_SETTINGS) * 6; -#[derive(Clone, Copy, PartialEq, Eq, Default)] -pub(crate) enum PaddingStrategy { - #[default] - None, - Aligned, - Max, -} - -#[repr(u8)] -#[derive(Clone, Copy, PartialEq, Eq)] -enum FrameType { - HTTP_FRAME_DATA = 0x00, - HTTP_FRAME_HEADERS = 0x01, - HTTP_FRAME_RST_STREAM = 0x03, - HTTP_FRAME_SETTINGS = 0x04, - HTTP_FRAME_PING = 0x06, - HTTP_FRAME_GOAWAY = 0x07, - HTTP_FRAME_WINDOW_UPDATE = 0x08, - HTTP_FRAME_CONTINUATION = 0x09, // RFC 7540 Section 6.10: Continues header block fragments - HTTP_FRAME_ALTSVC = 0x0A, // https://datatracker.ietf.org/doc/html/rfc7838#section-7.2 - HTTP_FRAME_ORIGIN = 0x0C, // https://datatracker.ietf.org/doc/html/rfc8336#section-2 -} - -#[repr(u8)] -enum PingFrameFlags { - ACK = 0x1, -} - -#[repr(u8)] -enum DataFrameFlags { - END_STREAM = 0x1, - PADDED = 0x8, -} - -#[repr(u8)] -enum HeadersFrameFlags { - END_STREAM = 0x1, - END_HEADERS = 0x4, - PADDED = 0x8, - PRIORITY = 0x20, -} - -// Open set of wire values → newtype over u32 -#[repr(transparent)] -#[derive(Clone, Copy, PartialEq, Eq)] -pub(crate) struct ErrorCode(u32); -impl ErrorCode { - const NO_ERROR: Self = Self(0x0); - 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); -} - -// ────────────────────────────────────────────────────────────────────────── -// Packed wire structs -// ────────────────────────────────────────────────────────────────────────── - -#[repr(transparent)] -#[derive(Clone, Copy, Default)] -pub(crate) struct UInt31WithReserved(u32); - -impl UInt31WithReserved { - #[inline] - fn reserved(self) -> bool { - self.0 & 0x8000_0000 != 0 - } - #[inline] - fn uint31(self) -> u32 { - self.0 & 0x7fff_ffff - } - #[inline] - 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(); - if self.reserved() { - value |= 0x8000_0000; - } - value = value.swap_bytes(); - writer.write_all(&value.to_ne_bytes()).is_ok() - } -} - -// packed struct(u40): streamIdentifier: u32, weight: u8 -#[repr(C, packed)] -#[derive(Clone, Copy, Default)] -struct StreamPriority { - stream_identifier: u32, - weight: u8, -} -bun_core::impl_no_uninit!(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(bun_core::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(). -#[derive(Clone, Copy)] -pub(crate) struct FrameHeader { - length: u32, // u24 on the wire - type_: u8, - flags: u8, - stream_identifier: u32, -} -impl FrameHeader { - pub(crate) const BYTE_SIZE: usize = 9; - #[inline] - fn write(&self, writer: &mut impl WireWriter, frames_sent: &Cell) -> bool { - frames_sent.set(frames_sent.get() + 1); - let mut buf = [0u8; Self::BYTE_SIZE]; - buf[0] = ((self.length >> 16) & 0xFF) as u8; - buf[1] = ((self.length >> 8) & 0xFF) as u8; - buf[2] = (self.length & 0xFF) as u8; - buf[3] = self.type_; - buf[4] = self.flags; - buf[5..9].copy_from_slice(&self.stream_identifier.to_be_bytes()); - writer.write_all(&buf).is_ok() - } - /// Decode a complete 9-byte big-endian frame header. - /// - /// `FrameHeader` is not `#[repr(packed)]` (its `length` is a widened - /// `u32`), so the caller assembles the 9 raw bytes on the stack and hands - /// us the finished buffer — no per-instance or thread-local scratch needed. - #[inline] - fn decode(raw: &[u8; Self::BYTE_SIZE]) -> Self { - Self { - length: ((raw[0] as u32) << 16) | ((raw[1] as u32) << 8) | (raw[2] as u32), - type_: raw[3], - flags: raw[4], - stream_identifier: u32::from_be_bytes([raw[5], raw[6], raw[7], raw[8]]), - } - } -} - -/// The seven standard SETTINGS parameters (RFC 9113 §6.5.2). The wire form is produced by -/// `write_settings_payload`, which only emits the entries JS set explicitly. -#[derive(Clone, Copy)] -pub(crate) struct FullSettingsPayload { - header_table_size: u32, - enable_push: u32, - max_concurrent_streams: u32, - initial_window_size: u32, - max_frame_size: u32, - max_header_list_size: u32, - enable_connect_protocol: u32, -} -impl Default for FullSettingsPayload { - fn default() -> Self { - Self { - header_table_size: 4096, - enable_push: 1, - max_concurrent_streams: 4294967295, - initial_window_size: 65535, - max_frame_size: 16384, - max_header_list_size: 65535, - enable_connect_protocol: 0, - } - } -} -impl FullSettingsPayload { - pub(crate) fn to_engine_settings(&self) -> crate::api::h2::settings::Settings { - crate::api::h2::settings::Settings { - header_table_size: self.header_table_size, - enable_push: self.enable_push, - max_concurrent_streams: self.max_concurrent_streams, - initial_window_size: self.initial_window_size, - max_frame_size: self.max_frame_size, - max_header_list_size: self.max_header_list_size, - enable_connect_protocol: self.enable_connect_protocol, - } - } - - pub(crate) fn to_js(&self, global_object: &JSGlobalObject) -> JSValue { - let result = JSValue::create_empty_object(global_object, 8); - let header_table_size = self.header_table_size; - let enable_push = self.enable_push; - let max_concurrent_streams = self.max_concurrent_streams; - let initial_window_size = self.initial_window_size; - let max_frame_size = self.max_frame_size; - let max_header_list_size = self.max_header_list_size; - let enable_connect_protocol = self.enable_connect_protocol; - result.put( - global_object, - b"headerTableSize", - JSValue::js_number(header_table_size as f64), - ); - result.put(global_object, b"enablePush", JSValue::from(enable_push > 0)); - result.put( - global_object, - b"maxConcurrentStreams", - JSValue::js_number(max_concurrent_streams as f64), - ); - result.put( - global_object, - b"initialWindowSize", - JSValue::js_number(initial_window_size as f64), - ); - result.put( - global_object, - b"maxFrameSize", - JSValue::js_number(max_frame_size as f64), - ); - result.put( - global_object, - b"maxHeaderListSize", - JSValue::js_number(max_header_list_size as f64), - ); - result.put( - global_object, - b"maxHeaderSize", - JSValue::js_number(max_header_list_size as f64), - ); - result.put( - global_object, - b"enableConnectProtocol", - JSValue::from(enable_connect_protocol > 0), - ); - result - } -} - -/// Writer trait used for generic wire-serialization writer params. -/// All call sites use either a `FixedBufferStream` cursor or `DirectWriterStruct`. -use bun_io::Write as WireWriter; - -// ────────────────────────────────────────────────────────────────────────── -// Static header maps -// ────────────────────────────────────────────────────────────────────────── - -// A 1-entry set: a single slice compare; a lookup table buys nothing. -#[inline] -fn is_valid_response_pseudo_header(name: &[u8]) -> bool { - name == b":status" -} - -bun_core::comptime_string_set! { - static REQUEST_PSEUDO_HEADERS = { - b":path", - b":method", - b":scheme", - b":protocol", - b":authority", - }; -} - -#[inline] -fn is_valid_request_pseudo_header(name: &[u8]) -> bool { - REQUEST_PSEUDO_HEADERS.contains(name) -} - -#[inline] -fn is_valid_header_value(value: &[u8]) -> bool { - !strings::contains_any(value, b"\0\n\r") -} - -/// The wire bytes of an outbound header value. A value whose code units all fit -/// in one byte is written one byte per code unit. Node does the same -/// (`StringBytes::Write(..., LATIN1)` in `NgHeaders`), and the inbound side -/// reads the bytes back as latin-1. A value with a wider code unit is written as -/// UTF-8. Bun has no single rule above 0xFF: Node truncates such a code unit to -/// its low byte, and object-form `respond()` drops the value in JS -/// (`headerValueIsUnsendable`). ALTSVC and ORIGIN strings use the same rule. -fn header_value_bytes<'a>(value: &'a bun_jsc::JSStringView<'_>) -> Cow<'a, [u8]> { - if !value.is_utf16() { - return Cow::Borrowed(value.latin1()); - } - let units = value.utf16(); - if units.iter().any(|&unit| unit > 0xFF) { - return Cow::Owned(value.to_owned_slice()); - } - let mut bytes = vec![0u8; units.len()]; - strings::copy_u16_into_u8(&mut bytes, units); - Cow::Owned(bytes) -} - -#[inline] -pub(crate) fn is_malformed_field_name(name: &[u8]) -> bool { - let rest = match name.split_first() { - None => return true, - Some((b':', rest)) => rest, - Some(_) => name, - }; - rest.is_empty() - || !rest.iter().all(|&c| { - matches!( - c, - b'a'..=b'z' - | b'0'..=b'9' - | b'!' - | b'#' - | b'$' - | b'%' - | b'&' - | b'\'' - | b'*' - | b'+' - | b'-' - | b'.' - | b'^' - | b'_' - | b'`' - | b'|' - | b'~' - ) - }) -} - -#[inline] -pub(crate) fn is_malformed_field_value(value: &[u8]) -> bool { - strings::contains_any(value, b"\0\r\n") -} - -/// RFC 9110 §5.5: HTAB is the only control byte a field value can have. -#[inline] -pub(crate) fn has_control_byte(value: &[u8]) -> bool { - value.iter().any(|&c| (c < b' ' && c != b'\t') || c == 0x7f) -} - -/// RFC 9113 §8.2.1: a field value does not start or end with SP or HTAB. -#[inline] -pub(crate) fn has_edge_whitespace(value: &[u8]) -> bool { - matches!(value.first(), Some(b' ' | b'\t')) || matches!(value.last(), Some(b' ' | b'\t')) -} - -const SINGLE_VALUE_HEADERS_LEN: usize = 40; - -bun_core::comptime_string_map! { - /// Maps headers that must carry only a single value to a stable index in - /// `0..SINGLE_VALUE_HEADERS_LEN`. The index is used solely to address a - /// per-request `[bool; SINGLE_VALUE_HEADERS_LEN]` bitset for duplicate - /// detection — the concrete numeric value has no other meaning. - static SINGLE_VALUE_HEADERS: usize = { - b"tk" => 36, - b"age" => 9, - b"dnt" => 19, - b"date" => 18, - b"etag" => 20, - b"from" => 22, - b"host" => 23, - b":path" => 4, - b"range" => 33, - b":status" => 0, - b":method" => 1, - b":scheme" => 3, - b"expires" => 21, - b"referer" => 34, - b"if-match" => 24, - b"if-range" => 27, - b"location" => 30, - b":protocol" => 5, - b":authority" => 2, - b"user-agent" => 38, - b"content-md5" => 15, - b"retry-after" => 35, - b"content-type" => 17, - b"max-forwards" => 31, - b"authorization" => 10, - b"content-range" => 16, - b"if-none-match" => 26, - b"last-modified" => 29, - b"content-length" => 13, - b"content-encoding" => 11, - b"content-language" => 12, - b"content-location" => 14, - b"if-modified-since" => 25, - b"if-unmodified-since" => 28, - b"proxy-authorization" => 32, - b"access-control-max-age" => 7, - b"x-content-type-options" => 39, - b"upgrade-insecure-requests" => 37, - b"access-control-request-method" => 8, - b"access-control-allow-credentials" => 6, +fn settings_to_js(settings: &Settings, global_object: &JSGlobalObject) -> JSValue { + let result = JSValue::create_empty_object(global_object, 8); + let number = |key: &'static [u8], value: u32| { + result.put(global_object, key, JSValue::js_number(value as f64)); }; -} - -#[inline] -fn single_value_headers_index_of(name: &[u8]) -> Option { - SINGLE_VALUE_HEADERS.get(name).copied() + number(b"headerTableSize", settings.header_table_size); + result.put( + global_object, + b"enablePush", + JSValue::from(settings.enable_push), + ); + number(b"maxConcurrentStreams", settings.max_concurrent_streams); + number(b"initialWindowSize", settings.initial_window_size); + number(b"maxFrameSize", settings.max_frame_size); + number(b"maxHeaderListSize", settings.max_header_list_size); + number(b"maxHeaderSize", settings.max_header_list_size); + result.put( + global_object, + b"enableConnectProtocol", + JSValue::from(settings.enable_connect_protocol), + ); + result } // ────────────────────────────────────────────────────────────────────────── @@ -634,7 +208,6 @@ fn single_value_headers_index_of(name: &[u8]) -> Option { // ────────────────────────────────────────────────────────────────────────── struct Handlers { - binary_type: BinaryType, vm: &'static VirtualMachine, global_object: GlobalRef, // JSC_BORROW } @@ -719,103 +292,46 @@ impl Handlers { fn new(global_object: &JSGlobalObject) -> Handlers { Handlers { - binary_type: BinaryType::Buffer, // SAFETY: bun_vm() never returns null; VM outlives every JS object (effectively 'static). vm: global_object.bun_vm(), global_object: GlobalRef::from(global_object), } } - pub(crate) fn from_js( - global_object: &JSGlobalObject, - opts: JSValue, - this_value: JSValue, - ) -> JsResult { - let mut handlers = Handlers::new(global_object); - - if !opts.is_object() { - return Err(global_object - .throw_invalid_arguments(format_args!("Expected \"handlers\" to be an object"))); - } - - macro_rules! handler_pair { - ($field:ident, $key:literal) => {{ - if let Some(callback_value) = opts.get_truthy(global_object, $key)? { - if !callback_value.is_cell() || !callback_value.is_callable() { - return Err(global_object.throw_invalid_arguments(format_args!( - "Expected \"{}\" callback to be a function", - $key - ))); - } - js::$field( + /// Keeps the callbacks on the wrapper. + fn install(global_object: &JSGlobalObject, handlers: &H2Handlers<'_>, this_value: JSValue) { + macro_rules! install { + ($set_cached:ident, $callback:expr) => {{ + if let Some(callback) = $callback { + js::$set_cached( this_value, global_object, - callback_value.with_async_context_if_needed(global_object), + callback.to_js().with_async_context_if_needed(global_object), ); } }}; } - handler_pair!(on_stream_start_set_cached, "streamStart"); - handler_pair!(on_stream_headers_set_cached, "streamHeaders"); - handler_pair!(on_stream_end_set_cached, "streamEnd"); - handler_pair!(on_stream_data_set_cached, "streamData"); - handler_pair!(on_stream_error_set_cached, "streamError"); - handler_pair!(on_remote_settings_set_cached, "remoteSettings"); - handler_pair!(on_local_settings_set_cached, "localSettings"); - handler_pair!(on_want_trailers_set_cached, "wantTrailers"); - handler_pair!(on_ping_set_cached, "ping"); - handler_pair!(on_end_set_cached, "end"); - // .{ "onError", "error" } using fastGet(.error) now - handler_pair!(on_go_away_set_cached, "goaway"); - handler_pair!(on_aborted_set_cached, "aborted"); - handler_pair!(on_write_set_cached, "write"); - handler_pair!(on_alt_svc_set_cached, "altsvc"); - handler_pair!(on_origin_set_cached, "origin"); - handler_pair!(on_frame_error_set_cached, "frameError"); - handler_pair!(on_stream_push_set_cached, "streamPush"); - - if let Some(callback_value) = opts.fast_get(global_object, bun_jsc::BuiltinName::Error)? { - if !callback_value.is_cell() || !callback_value.is_callable() { - return Err(global_object.throw_invalid_arguments(format_args!( - "Expected \"error\" callback to be a function" - ))); - } - js::on_error_set_cached( - this_value, - global_object, - callback_value.with_async_context_if_needed(global_object), - ); - } - - // onWrite is required for duplex support or if more than 1 parser is attached to the same socket (unliked) - if js::on_write_get_cached(this_value).is_none() { - return Err(global_object - .throw_invalid_arguments(format_args!("Expected at least \"write\" callback"))); - } - - if let Some(binary_type_value) = opts.get_truthy(global_object, "binaryType")? { - if !binary_type_value.is_string() { - return Err(global_object.throw_invalid_arguments(format_args!( - "Expected \"binaryType\" to be a string" - ))); - } - handlers.binary_type = - match BinaryType::from_js_value(global_object, binary_type_value)? { - Some(bt) => bt, - None => { - return Err(global_object.throw_invalid_arguments(format_args!( - "Expected 'binaryType' to be 'ArrayBuffer', 'Uint8Array', or 'Buffer'", - ))); - } - }; - } - - Ok(handlers) + install!(on_stream_start_set_cached, handlers.stream_start); + install!(on_stream_headers_set_cached, handlers.stream_headers); + install!(on_stream_end_set_cached, handlers.stream_end); + install!(on_stream_data_set_cached, handlers.stream_data); + install!(on_stream_error_set_cached, handlers.stream_error); + install!(on_remote_settings_set_cached, handlers.remote_settings); + install!(on_local_settings_set_cached, handlers.local_settings); + install!(on_want_trailers_set_cached, handlers.want_trailers); + install!(on_ping_set_cached, handlers.ping); + install!(on_end_set_cached, handlers.end); + install!(on_error_set_cached, handlers.error); + install!(on_go_away_set_cached, handlers.goaway); + install!(on_aborted_set_cached, handlers.aborted); + install!(on_write_set_cached, Some(handlers.write)); + install!(on_alt_svc_set_cached, handlers.altsvc); + install!(on_origin_set_cached, handlers.origin); + install!(on_frame_error_set_cached, handlers.frame_error); + install!(on_stream_push_set_cached, handlers.stream_push); } } -use bun_io::FixedBufferStream; - // ────────────────────────────────────────────────────────────────────────── // H2FrameParser // ────────────────────────────────────────────────────────────────────────── @@ -897,14 +413,64 @@ 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 FlagGuard<'a>(&'a Cell); + +impl<'a> FlagGuard<'a> { + fn set(flag: &'a Cell) -> Self { + flag.set(true); + FlagGuard(flag) + } +} -impl Drop for DispatchGuard<'_> { +impl Drop for FlagGuard<'_> { fn drop(&mut self) { - self.0.set(self.0.get() - 1); + self.0.set(false); } } +/// A callback queued while `engine.receive()` runs, so that JS only runs between frames. `receive` calls it. +enum Deferred { + /// `handlers.(ctx, args...)` + Event { + event: GetCached, + 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(Rc), + /// Runs all of `Sink::on_stream_reset`, queued for the same reason. + StreamReset { + stream: Rc, + 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), +} + /// 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 @@ -914,7 +480,7 @@ struct TxFrameTracker { /// Payload bytes still owed on the current frame. remaining: u32, /// A frame header split across chunks is collected here until all 9 bytes are known. - header: [u8; FrameHeader::BYTE_SIZE], + header: [u8; FRAME_HEADER_SIZE], header_len: u8, /// A HEADERS/PUSH_PROMISE/CONTINUATION without END_HEADERS went out; the block is open. header_block_open: bool, @@ -926,7 +492,6 @@ impl TxFrameTracker { } fn advance(&mut self, mut chunk: &[u8]) { - const CONNECTION_PREFACE: &[u8] = crate::api::h2::wire::CONNECTION_PREFACE; while !chunk.is_empty() { if self.remaining > 0 { let take = (self.remaining as usize).min(chunk.len()); @@ -940,22 +505,19 @@ impl TxFrameTracker { continue; } let have = self.header_len as usize; - let take = (FrameHeader::BYTE_SIZE - have).min(chunk.len()); + let take = (FRAME_HEADER_SIZE - have).min(chunk.len()); self.header[have..have + take].copy_from_slice(&chunk[..take]); self.header_len += take as u8; chunk = &chunk[take..]; - if self.header_len as usize == FrameHeader::BYTE_SIZE { - let header = FrameHeader::decode(&self.header); + if self.header_len as usize == FRAME_HEADER_SIZE { + let header = FrameHeader::parse(&self.header); self.header_len = 0; self.remaining = header.length; - // PUSH_PROMISE is not a FrameType variant (the inbound path matches it raw too). - const PUSH_PROMISE: u8 = 0x05; - if header.type_ == FrameType::HTTP_FRAME_HEADERS as u8 - || header.type_ == PUSH_PROMISE - || header.type_ == FrameType::HTTP_FRAME_CONTINUATION as u8 - { - self.header_block_open = - header.flags & HeadersFrameFlags::END_HEADERS as u8 == 0; + if matches!( + FrameType::from_u8(header.frame_type), + Some(FrameType::Headers | FrameType::PushPromise | FrameType::Continuation) + ) { + self.header_block_open = !header.has(flags::END_HEADERS); } } } @@ -980,27 +542,6 @@ impl Drop for Keepalive<'_> { } } -/// A `&mut Stream` that only exists inside an armed dispatch scope (`enter_stream_dispatch`): -/// while it is live, rewrite_read defers stream frees, so user JS that re-enters `read()` -/// (option getters, header-value `toString`) cannot free the stream out from under the borrow. -struct GuardedStream<'a> { - stream: &'a mut Stream, - _dispatch: DispatchGuard<'a>, -} - -impl core::ops::Deref for GuardedStream<'_> { - type Target = Stream; - fn deref(&self) -> &Stream { - self.stream - } -} - -impl core::ops::DerefMut for GuardedStream<'_> { - fn deref_mut(&mut self) -> &mut Stream { - self.stream - } -} - // R-2 (host-fn re-entrancy): every JS-exposed method takes `&self`; per-field // interior mutability via `Cell` (Copy) / `JsCell` (non-Copy). #[derive(bun_ptr::RefCounted)] @@ -1012,9 +553,8 @@ pub(crate) struct H2FrameParser { strong_this: JsCell, global_this: GlobalRef, // JSC_BORROW — read-only after construction // allocator field dropped — global mimalloc - handlers: JsCell, + handlers: Handlers, native_socket: NativeSocket, - local_settings: Cell, /// Bitmask (SETTING_BIT_*) of standard SETTINGS explicitly provided by JS. node only /// serializes explicitly submitted settings — defaults are never put on the wire, so the /// initial SETTINGS frame for default options is empty. @@ -1032,102 +572,40 @@ pub(crate) struct H2FrameParser { remote_custom_settings_filter: JsCell>, /// Captured (id, value) pairs for the ids in remote_custom_settings_filter. remote_custom_settings: JsCell>, - /// Constructor-time SETTINGS_MAX_HEADER_LIST_SIZE, used to seed the engine's enforcement - /// limit when it is created lazily (nghttp2 only applies submitted local settings on ACK, so - /// a header block already in flight when the limit is lowered must not be rejected; the - /// engine raises/lowers its own limit as ACKs arrive). - enforced_max_header_list_size: Cell, - // only available after receiving settings or ACK - remote_settings: Cell>, // 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, - - // remote Window limits the upload of data - // remote window size for the connection - remote_window_size: Cell, - // remote used window size for the connection - remote_used_window_size: Cell, - - max_header_list_pairs: Cell, - /// node maxSettings session option: maximum entries accepted in a single SETTINGS frame. - max_settings: Cell, - /// 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). - pending_settings_window_submissions: - JsCell>, - /// Stream ids whose legacy-side lifecycle finished while a dispatch held the engine - /// borrow (the normal request path: receive() -> JS handler -> respond -> END_STREAM). - /// Drained into Connection::close_stream on the next rewrite_read batch. - pending_engine_stream_closes: JsCell>, - dispatch_depth: Cell, + max_rejected_streams: Cell, - max_session_invalid_frames: Cell, - strict_field_whitespace: Cell, - stream_reset_burst: Cell, - stream_reset_rate: Cell, - goaway_sent: Cell, - max_outstanding_settings: Cell, - outstanding_settings: Cell, rejected_streams: Cell, - max_session_memory: Cell, // this limit is in MB - queued_data_size: Cell, // this is in bytes + queued_data_size: Cell, // this is in bytes max_outstanding_pings: Cell, out_standing_pings: Cell, max_send_header_block_length: Cell, /// node strictSingleValueFields session option (default true): when false, duplicate /// single-value headers and array values for them are encoded as-is instead of rejected. strict_single_value_fields: Cell, - last_stream_id: Cell, - /// Highest PEER-initiated stream id processed (odd ids for a server, even for a - /// client). This — not `last_stream_id` — is what an auto-filled GOAWAY must carry: - /// RFC 9113 §6.8 last_stream_id refers to streams the RECEIVER initiated, and - /// nghttp2 servers reject a GOAWAY naming a client-initiated id with a connection - /// PROTOCOL_ERROR (node's last_proc_stream_id semantics). - last_peer_stream_id: Cell, - is_server: Cell, /// A frame callback left an exception pending in this batch (`Sink::should_stop`). left_exception: Cell, // 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>, - has_nonnative_backpressure: Cell, /// True while flush() has bytes out in an onWrite dispatch to a JS-backed socket. js_socket_flushing: Cell, /// 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, - /// Frames written by the legacy outbound encoder (perf_hooks http2 session stats). - frames_sent_legacy: Cell, - /// Engine counters mirrored at the end of each rewrite_read batch, so reading them - /// never contends with the engine borrow. - engine_frames_received: Cell, - engine_frames_sent: Cell, /// Where the bytes emitted through `write()` over a JS-backed transport stand relative to /// frame and header-block boundaries. tx_tracker: Cell, @@ -1137,20 +615,21 @@ pub(crate) struct H2FrameParser { native_keepalives: Cell, auto_flusher: JsCell, - padding_strategy: Cell, - // ---- from-scratch rewrite engine (src/runtime/api/bun/h2) ---- - // The fields above are the legacy frame state being retired; read()/host functions will route - // through `engine` instead. `None` until configured with is_server + settings. - engine: core::cell::RefCell>, + engine: Connection, + /// Frames written, for the perf_hooks session entry. + frames_sent: Cell, /// 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>, + inbound_tail: JsCell>, + /// Read cursor into `inbound_tail` while a batch has its remainder parked there, otherwise 0. + inbound_tail_pos: Cell, + /// `engine.receive()` is on the stack, so events go to `deferred`. + receiving: Cell, + deferred: JsCell>, /// 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, - /// stream id -> JS stream context object, for the rewrite engine's Sink callbacks. - sctx: JsCell>, + pending_push: Cell, /// In-progress decoded header array + sensitive-name array, accumulated across on_header. /// Packed name/value bytes of the header block being decoded (reused per block). hdr_block: JsCell>, @@ -1203,20 +682,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()) } @@ -1239,761 +704,260 @@ impl H2FrameParser { } } -/// The streams hashmap may mutate when growing we use this when we need to make sure its safe to iterate over it -/// -/// `bun_collections::HashMap` is backed by `std::collections::HashMap`, which -/// exposes no bucket index and randomises iteration order on every mutation, -/// so iterating while mutating is not possible directly. Instead we snapshot -/// the stream IDs at `init` and re-look-up -/// each one on demand: streams removed mid-loop are skipped, streams added -/// mid-loop are not visited, and nothing is yielded twice. That's the -/// guarantee the call sites actually rely on (flush / emit-to-all / detach). -pub(crate) struct StreamResumableIterator { - // Note: `streams` - // is `JsCell`-backed, so a shared backref suffices and the in-loop - // body can keep its own `&H2FrameParser` without provenance gymnastics. - // `ParentRef` encapsulates the back-pointer invariant (parser outlives the - // iterator — every call site constructs the iterator from a live `&Self` - // and drains it in the same scope) so `next()` derefs through safe `Deref`. - parser: bun_ptr::ParentRef, - ids: Vec, - index: usize, -} -impl StreamResumableIterator { - pub(crate) fn init(parser: &H2FrameParser) -> Self { - let ids = parser.streams.get().keys().copied().collect(); - Self { - parser: bun_ptr::ParentRef::new(parser), - ids, - index: 0, - } - } - pub(crate) fn next(&mut self) -> Option<*mut Stream> { - // R-2: `streams` is `JsCell`-backed (UnsafeCell), so the shared backref - // read here coexists soundly with the loop body's own `&self` accesses. - let streams = self.parser.streams.get(); - while let Some(&id) = self.ids.get(self.index) { - self.index += 1; - if let Some(&stream) = streams.get(&id) { - return Some(stream); - } - } - None - } -} - #[derive(Clone, Copy, PartialEq, Eq)] -pub(crate) enum FlushState { +enum FlushState { NoAction, Flushed, Backpressure, } -#[repr(u8)] -#[derive(Clone, Copy, PartialEq, Eq)] -enum StreamState { - IDLE = 1, - OPEN = 2, - HALF_CLOSED_LOCAL = 5, - HALF_CLOSED_REMOTE = 6, - CLOSED = 7, -} - -pub(crate) struct Stream { - id: u32, - state: StreamState, - js_context: StrongOptional, // jsc.Strong.Optional - wait_for_trailers: bool, - end_after_headers: bool, - padding_strategy: PaddingStrategy, - rst_code: u32, - weight: u16, - // 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, - - // when we have backpressure we queue the data e round robin the Streams - data_frame_queue: PendingQueue, -} +impl H2FrameParser { + /// Takes a DATA frame's payload out of both send windows (RFC 9113 §6.9.1). + fn consume_send_windows(&self, stream: &Stream, payload_size: usize) { + update(&stream.send_window, |w| w.consume(payload_size)); + update(&self.engine.send_window, |w| w.consume(payload_size)); + } -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, -} + /// Sends the first queued frame of `stream`, or as much of it as the send windows allow. + fn flush_queue(&self, stream: &Stream, written: &mut usize) -> FlushState { + if !stream.state().can_send() { + return FlushState::NoAction; + } + let Some((is_empty_frame, unsent)) = stream + .queue + .get() + .front() + .map(|frame| (frame.buffer.is_empty(), frame.unsent().len())) + else { + return FlushState::NoAction; + }; + let max_size = unsent.min(self.sendable_data_size(stream)); + if !is_empty_frame && max_size == 0 { + // An empty connection window stops every stream, so the caller can stop looking. + return if self.engine.send_window.get().available() == 0 { + FlushState::Backpressure + } else { + FlushState::NoAction + }; + } + self.queued_data_size + .set(self.queued_data_size.get() - max_size as u64); + *written += max_size; + self.consume_send_windows(stream, max_size); + + if max_size < unsent { + // Copied out, because JS under the write can queue more on this stream. + let chunk = stream.queue.with_mut(|queue| { + let frame = queue.front_mut()?; + let chunk = frame.unsent()[..max_size].to_vec(); + frame.offset += max_size; + Some(chunk) + }); + let sent = chunk + .is_none_or(|chunk| self.write_frame(FrameType::Data, 0, stream.id, &[&chunk])); + return if sent { + FlushState::Flushed + } else { + FlushState::Backpressure + }; + } -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) }; + let Some(frame) = stream.queue.with_mut(VecDeque::pop_front) else { + return FlushState::NoAction; + }; + let end_stream = frame.end_stream && !stream.wait_for_trailers.get(); + // Peers count an empty DATA frame without END_STREAM as a flood, so never send one. + let sent = (is_empty_frame && !end_stream) + || self.write_frame( + FrameType::Data, + if end_stream { flags::END_STREAM } else { 0 }, + stream.id, + &[frame.unsent()], + ); + self.outbound_queue_size + .set(self.outbound_queue_size.get() - 1); + if let Some(callback) = frame.callback.get() { + self.dispatch_write_callback(callback); + } + if frame.end_stream && stream.queue.get().is_empty() { + if stream.wait_for_trailers.get() { + self.dispatch(js::on_want_trailers_get_cached, stream.context()); + } else { + self.sent_end_stream(stream, false); + } + } + if sent { + FlushState::Flushed + } else { + FlushState::Backpressure } } -); -#[derive(Default)] -struct PendingQueue { - data: Vec, - front: usize, - len: usize, -} - -impl PendingQueue { - pub(crate) fn enqueue(&mut self, value: PendingFrame) { - self.data.push(value); - self.len += 1; - bun_output::scoped_log!(H2FrameParser, "PendingQueue.enqueue {}", self.len); - } + /// Queues DATA that can't go out now. Writes are merged into full frames. + fn queue_frame(&self, stream: &Stream, bytes: &[u8], callback: JSValue, end_stream: bool) { + let global = self.global_this; + let callback = if callback.is_callable() { + StrongOptional::create(callback, &global) + } else { + StrongOptional::empty() + }; + // Top up the last queued frame first. + let mut rest = bytes; + let mut callback = Some(callback); + let superseded = stream.queue.with_mut(|queue| { + let last = queue.back_mut()?; + if last.buffer.is_empty() && !rest.is_empty() { + last.buffer = Vec::with_capacity(MAX_PAYLOAD_SIZE_WITHOUT_FRAME); + } + let room = MAX_PAYLOAD_SIZE_WITHOUT_FRAME - last.buffer.len(); + let (merged, after) = rest.split_at(rest.len().min(room)); + last.buffer.extend_from_slice(merged); + rest = after; + if !rest.is_empty() { + return None; + } + last.end_stream = end_stream; + Some(core::mem::replace(&mut last.callback, callback.take()?)) + }); + self.queued_data_size + .set(self.queued_data_size.get() + (bytes.len() - rest.len()) as u64); + // A frame holds one callback, so the one it had is called now. + if let Some(superseded) = superseded { + if let Some(superseded) = superseded.get() { + self.dispatch_write_callback(superseded); + } + return; + } - pub(crate) fn peek_last(&mut self) -> Option<&mut PendingFrame> { - if self.len == 0 { - return None; + let mut buffer = Vec::new(); + if !rest.is_empty() { + // Room for a full frame, so later writes merge into this one without reallocating. + buffer.reserve_exact(MAX_PAYLOAD_SIZE_WITHOUT_FRAME); + buffer.extend_from_slice(rest); + global.vm().deprecated_report_extra_memory(rest.len()); } - let last = self.data.len() - 1; - Some(&mut self.data[last]) + stream.queue.with_mut(|queue| { + queue.push_back(PendingFrame { + end_stream, + offset: 0, + buffer, + callback: callback.take().unwrap_or_default(), + }) + }); + self.outbound_queue_size + .set(self.outbound_queue_size.get() + 1); + self.queued_data_size + .set(self.queued_data_size.get() + rest.len() as u64); } - pub(crate) fn peek_front(&mut self) -> Option<&mut PendingFrame> { - if self.len == 0 { - return None; + /// Drops the DATA a closed stream still had queued and calls the write callbacks. + fn drop_queued_data(&self, stream: &Stream) { + // A write callback can destroy the session and drop the socket's ref on this parser. + let _keepalive = self.keepalive(); + for frame in stream.queue.take() { + self.queued_data_size + .set(self.queued_data_size.get() - frame.unsent().len() as u64); + if let Some(callback) = frame.callback.get() { + self.dispatch_write_callback(callback); + } + self.outbound_queue_size + .set(self.outbound_queue_size.get() - 1); } - Some(&mut self.data[self.front]) } - pub(crate) fn dequeue(&mut self) -> Option { - if self.len == 0 { - bun_output::scoped_log!(H2FrameParser, "PendingQueue.dequeue null"); - return None; + /// We sent END_STREAM. Tells JS the state the stream is in now and returns it. `quiet` skips that for half-closed (local). + fn sent_end_stream(&self, stream: &Stream, quiet: bool) -> State { + let context = stream.context(); + context.ensure_still_alive(); + let _ = self + .engine + .streams + .apply(stream, StreamEvent::SendEndStream); + let state = stream.state(); + if state == State::Closed { + stream.close_delivered.set(true); + self.drop_queued_data(stream); } - let value = core::mem::take(&mut self.data[self.front]); - self.len -= 1; - if self.len == 0 { - self.front = 0; - self.data.clear(); - } else { - self.front += 1; + if !(quiet && state == State::HalfClosedLocal) { + self.dispatch_with_extra( + js::on_stream_end_get_cached, + context, + JSValue::js_number(state as u8 as f64), + ); } - bun_output::scoped_log!(H2FrameParser, "PendingQueue.dequeue {}", self.len); - Some(value) + state } +} - pub(crate) fn is_empty(&self) -> bool { - self.len == 0 - } +/// The per-VM header scratch, held for one outbound header call and put back on drop. +struct HeaderScratch<'a> { + global: &'a JSGlobalObject, + scratch: H2HeaderScratch, } -// PendingQueue::deinit handled by Drop on Vec +impl<'a> HeaderScratch<'a> { + fn take(global: &'a JSGlobalObject) -> Self { + Self { + global, + scratch: global + .bun_vm() + .as_mut() + .rare_data() + .take_h2_header_scratch(), + } + } +} -#[derive(Default)] -struct PendingFrame { - end_stream: bool, // end_stream flag - len: u32, // actually payload size - offset: u32, // offset into the buffer (if partial flush due to flow control) - buffer: Vec, // allocated buffer if len > 0 - callback: StrongOptional, // JSCallback for done +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 PendingFrame { - pub(crate) fn slice(&self) -> &[u8] { - &self.buffer[self.offset as usize..self.len as usize] +impl core::ops::Deref for HeaderScratch<'_> { + type Target = H2HeaderScratch; + fn deref(&self) -> &H2HeaderScratch { + &self.scratch } } -// PendingFrame::deinit handled by Drop (Vec frees, Strong deinits) - -impl Stream { - pub(crate) fn get_padding(&self, frame_len: usize, max_len: usize) -> u8 { - match self.padding_strategy { - PaddingStrategy::None => 0, - PaddingStrategy::Aligned => { - let diff = (frame_len + 9) % 8; - // already multiple of 8 - if diff == 0 { - return 0; - } - let mut padded_len = frame_len + (8 - diff); - // limit to maxLen - padded_len = padded_len.min(max_len); - padded_len.saturating_sub(frame_len).min(255) as u8 - } - PaddingStrategy::Max => max_len.saturating_sub(frame_len).min(255) as u8, - } +impl core::ops::DerefMut for HeaderScratch<'_> { + fn deref_mut(&mut self) -> &mut H2HeaderScratch { + &mut self.scratch } - - pub(crate) fn flush_queue( - &mut self, - client: &H2FrameParser, - written: &mut usize, - ) -> FlushState { - if !self.can_send_data() { - // empty or cannot send data - return FlushState::NoAction; - } - // try to flush one frame - let Some(front) = self.data_frame_queue.peek_front() else { - return FlushState::NoAction; - }; - let frame_len = front.len; - let frame_remaining = front.slice().len(); - - let mut owned_frame: Option = None; - let no_backpressure: bool = 'brk: { - 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 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 - }, - 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 - .min( - (self - .remote_window_size - .saturating_sub(self.remote_used_window_size)) - as usize, - ) - .min( - (client - .remote_window_size - .get() - .saturating_sub(client.remote_used_window_size.get())) - as usize, - ) - .min(MAX_PAYLOAD_SIZE_WITHOUT_FRAME); - if max_size == 0 { - bun_output::scoped_log!( - H2FrameParser, - "dataFrame flow control limited {} {} {} {} {} {}", - frame_remaining, - self.remote_window_size, - self.remote_used_window_size, - client.remote_window_size.get(), - client.remote_used_window_size.get(), - max_size - ); - // we are flow control limited lets return backpressure if is limited in the connection so we short circuit the flush - return if client.remote_window_size.get() - == client.remote_used_window_size.get() - { - FlushState::Backpressure - } else { - FlushState::NoAction - }; - } - if max_size < frame_remaining { - // we need to break the frame into smaller chunks - let Some(frame) = self.data_frame_queue.peek_front() else { - return FlushState::NoAction; - }; - let able_to_send = frame.slice()[0..max_size].to_vec(); - frame.offset += u32::try_from(max_size).expect("int cast"); - client - .queued_data_size - .set(client.queued_data_size.get() - able_to_send.len() as u64); - *written += able_to_send.len(); - - let padding = self.get_padding(able_to_send.len(), max_size - 1); - let payload_size = able_to_send.len() - + if padding != 0 { - padding as usize + 1 - } else { - 0 - }; - bun_output::scoped_log!( - H2FrameParser, - "padding: {} size: {} max_size: {} payload_size: {}", - padding, - able_to_send.len(), - max_size, - payload_size - ); - self.remote_used_window_size += payload_size as u64; - 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 { - flags |= DataFrameFlags::PADDED as u8; - } - let data_header = FrameHeader { - type_: FrameType::HTTP_FRAME_DATA as u8, - flags, - stream_identifier: self.id, - length: u32::try_from(payload_size).expect("int cast"), - }; - let _ = data_header.write(&mut writer, &client.frames_sent_legacy); - if padding != 0 { - break 'brk writer.write_padded(&able_to_send, padding).is_ok(); - } else { - break 'brk writer.write_all(&able_to_send).is_ok(); - } - } else { - // flush with some payload - owned_frame = self.data_frame_queue.dequeue(); - let Some(frame) = owned_frame.as_ref() else { - return FlushState::NoAction; - }; - let frame_slice: &[u8] = frame.slice(); - client - .queued_data_size - .set(client.queued_data_size.get() - frame_slice.len() as u64); - *written += frame_slice.len(); - - let padding = self.get_padding(frame_slice.len(), max_size - 1); - let payload_size = frame_slice.len() - + if padding != 0 { - padding as usize + 1 - } else { - 0 - }; - bun_output::scoped_log!( - H2FrameParser, - "padding: {} size: {} max_size: {} payload_size: {}", - padding, - frame_slice.len(), - max_size, - payload_size - ); - self.remote_used_window_size += payload_size as u64; - 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 { - 0 - }; - if padding != 0 { - flags |= DataFrameFlags::PADDED as u8; - } - let data_header = FrameHeader { - type_: FrameType::HTTP_FRAME_DATA as u8, - flags, - stream_identifier: self.id, - length: u32::try_from(payload_size).expect("int cast"), - }; - let _ = data_header.write(&mut writer, &client.frames_sent_legacy); - if padding != 0 { - break 'brk writer.write_padded(frame_slice, padding).is_ok(); - } else { - break 'brk writer.write_all(frame_slice).is_ok(); - } - } - } - }; - - if let Some(_frame) = owned_frame { - // only call the callback + free the frame if we write to the socket the full frame - client - .outbound_queue_size - .set(client.outbound_queue_size.get() - 1); - - if let Some(callback_value) = _frame.callback.get() { - client.dispatch_write_callback(callback_value); - } - if self.data_frame_queue.is_empty() { - if _frame.end_stream { - if self.wait_for_trailers { - client.dispatch(js::on_want_trailers_get_cached, self.get_identifier()); - } else { - let identifier = self.get_identifier(); - identifier.ensure_still_alive(); - if self.state == StreamState::HALF_CLOSED_REMOTE { - self.state = StreamState::CLOSED; - self.free_resources::(client); - } else { - self.state = StreamState::HALF_CLOSED_LOCAL; - } - client.dispatch_with_extra( - js::on_stream_end_get_cached, - identifier, - JSValue::js_number(self.state as u8 as f64), - ); - } - } - } - drop(_frame); - } - - if no_backpressure { - FlushState::Flushed - } else { - FlushState::Backpressure - } - } - - pub(crate) fn queue_frame( - &mut self, - client: &H2FrameParser, - bytes: &[u8], - callback: JSValue, - end_stream: bool, - ) { - let global_this = client.global_this; - - // Note: `dispatch_write_callback()` below re-enters JS, which can - // call back into `H2FrameParser` host-fns (e.g. `writeStream`) that - // look this `Stream` up by id from `client.streams` and reach - // `queue_frame()` again with a fresh `&mut Stream` aliasing this one. - // R-2: `client` is now `&H2FrameParser` (UnsafeCell-backed fields), so - // the parser-side noalias miscompile is structurally impossible. The - // `Stream`-side `&mut self` alias across re-entry remains; keep the - // `black_box` launder on `self`/`last_frame` as defense-in-depth until - // `Stream` itself is celled. - let this: *mut Self = core::hint::black_box(core::ptr::from_mut(self)); - // SAFETY: `this` is the live `&mut self` payload; no other `&` to - // `*this` exists between here and the dispatch call. - if let Some(last_frame_ref) = unsafe { (*this).data_frame_queue.peek_last() } { - // Raw, opaque-provenance pointer for post-dispatch accesses. - let last_frame: *mut PendingFrame = - core::hint::black_box(core::ptr::from_mut(last_frame_ref)); - // SAFETY: helper for the pre-dispatch accesses below; `last_frame` - // is the unique tail slot in `self.data_frame_queue.data`, valid - // until the dispatch call (after which we re-`black_box` before - // every access — see note above). - macro_rules! lf { - () => { - // SAFETY: `last_frame` points at the live tail slot of - // `self.data_frame_queue`; provenance is re-laundered via - // `black_box` before each post-dispatch expansion so no - // other `&mut` to the slot is live here (see note). - unsafe { &mut *last_frame } - }; - } - if bytes.is_empty() { - // just merge the end_stream - lf!().end_stream = end_stream; - // we can only hold 1 callback at a time so we conclude the last one, and keep the last one as pending - // this is fine is like a per-stream CORKING in a frame level - let old_callback = core::mem::replace( - &mut lf!().callback, - StrongOptional::create(callback, &global_this), - ); - if let Some(old_callback_value) = old_callback.get() { - // Escape `this` so a self-derived address is observable - // across the opaque JS call (belt-and-suspenders; either - // launder alone defeats the caching). - core::hint::black_box(this); - client.dispatch_write_callback(old_callback_value); - } - drop(old_callback); - return; - } - if lf!().len == 0 { - // we have an empty frame with means we can just use this frame with a new buffer - lf!().buffer = Vec::with_capacity(MAX_PAYLOAD_SIZE_WITHOUT_FRAME); - } - let max_size = MAX_PAYLOAD_SIZE_WITHOUT_FRAME as u32; - let remaining = max_size - lf!().len; - if remaining > 0 { - // ok we can cork frames - let consumed_len = (remaining as usize).min(bytes.len()); - let merge = &bytes[0..consumed_len]; - lf!().buffer.extend_from_slice(merge); - lf!().len += u32::try_from(consumed_len).expect("int cast"); - bun_output::scoped_log!(H2FrameParser, "dataFrame merged {}", consumed_len); - - client - .queued_data_size - .set(client.queued_data_size.get() + consumed_len as u64); - // lets fallthrough if we still have some data - let more_data = &bytes[consumed_len..]; - if more_data.is_empty() { - lf!().end_stream = end_stream; - // we can only hold 1 callback at a time so we conclude the last one, and keep the last one as pending - // this is fine is like a per-stream CORKING in a frame level - let old_callback = core::mem::replace( - &mut lf!().callback, - StrongOptional::create(callback, &global_this), - ); - if let Some(old_callback_value) = old_callback.get() { - core::hint::black_box(this); - client.dispatch_write_callback(old_callback_value); - } - drop(old_callback); - return; - } - // we keep the old callback because the new will be part of another frame - // SAFETY: `this` is the live `&mut self`; no borrow of `*this` - // is held here (the `last_frame` raw pointer is unused past - // this point). - return unsafe { (*this).queue_frame(client, more_data, callback, end_stream) }; - } - } - bun_output::scoped_log!( - H2FrameParser, - "{} queued {} {}", - if client.is_server.get() { - "server" - } else { - "client" - }, - bytes.len(), - end_stream - ); - - let frame = PendingFrame { - end_stream, - len: u32::try_from(bytes.len()).expect("int cast"), - offset: 0, - // we need to clone this data to send it later - buffer: if bytes.is_empty() { - Vec::new() - } else { - // Full-frame capacity so later writes cork into this frame without reallocating. - let mut buffer = Vec::with_capacity(MAX_PAYLOAD_SIZE_WITHOUT_FRAME); - buffer.extend_from_slice(bytes); - buffer - }, - callback: if callback.is_callable() { - StrongOptional::create(callback, &global_this) - } else { - StrongOptional::empty() - }, - }; - if !bytes.is_empty() { - global_this.vm().deprecated_report_extra_memory(bytes.len()); - } - bun_output::scoped_log!(H2FrameParser, "dataFrame enqueued {}", frame.len); - self.data_frame_queue.enqueue(frame); - client - .outbound_queue_size - .set(client.outbound_queue_size.get() + 1); - client - .queued_data_size - .set(client.queued_data_size.get() + bytes.len() as u64); - } - - pub(crate) fn init( - stream_identifier: u32, - initial_window_size: u32, - remote_window_size: u32, - padding_strategy: PaddingStrategy, - ) -> Stream { - Stream { - id: stream_identifier, - 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, - 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(), - } - } - - /// Returns true if the stream can still receive data from the remote peer. - /// Per RFC 7540 Section 5.1: - /// - OPEN: both endpoints can send and receive - /// - HALF_CLOSED_LOCAL: local sent END_STREAM, but can still receive from remote - /// - HALF_CLOSED_REMOTE: remote sent END_STREAM, no more data to receive - /// - CLOSED: stream is finished - pub(crate) fn can_receive_data(&self) -> bool { - matches!( - self.state, - StreamState::IDLE | 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 - ) - } - - pub(crate) fn set_context(&mut self, value: JSValue, global_object: &JSGlobalObject) { - let old = core::mem::replace( - &mut self.js_context, - StrongOptional::create(value, global_object), - ); - drop(old); - } - - pub(crate) fn get_identifier(&self) -> JSValue { - self.js_context - .get() - .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(); - } - - fn clean_queue(&mut self, client: &H2FrameParser) { - bun_output::scoped_log!( - H2FrameParser, - "cleanQueue len: {} front: {} outboundQueueSize: {}", - self.data_frame_queue.len, - self.data_frame_queue.front, - client.outbound_queue_size.get() - ); - - // dispatch_write_callback re-enters JS; a destroy there can drop the - // socket's ref and free `client` between iterations. Not during - // finalize: refcount is already 0 and a ref/deref would re-destroy. - let _keepalive = (!FINALIZING).then(|| client.keepalive()); - let mut queue = core::mem::take(&mut self.data_frame_queue); - while let Some(item) = queue.dequeue() { - let frame = item; - let len = frame.slice().len(); - bun_output::scoped_log!(H2FrameParser, "dataFrame dropped {}", len); - client - .queued_data_size - .set(client.queued_data_size.get() - len as u64); - if !FINALIZING { - if let Some(callback_value) = frame.callback.get() { - client.dispatch_write_callback(callback_value); - } - } - drop(frame); - client - .outbound_queue_size - .set(client.outbound_queue_size.get() - 1); - } - // queue dropped here - } - - /// this can be called multiple times - pub(crate) fn free_resources(&mut self, client: &H2FrameParser) { - // The rewrite engine only sees inbound traffic, so a completed request would leave - // its engine entry as HalfClosedRemote and its legacy slot + Box behind forever — - // one entry per request. Queue the id; the next rewrite_read batch evicts the engine - // entry and frees the legacy slot. Always deferred: every caller still holds - // `&mut Stream` into the map entry, and the engine cell may be mutably borrowed - // (stream completing synchronously inside receive()). - if !FINALIZING { - client - .pending_engine_stream_closes - .with_mut(|v| v.push(self.id)); - // Release the engine-dispatch context root too; without this the Strong JS - // stream object lives until the session dies. - client.sctx.with_mut(|m| { - m.remove(&self.id); - }); - } - self.detach_context(); - self.clean_queue::(client); - if let Some(signal) = self.signal.take() { - drop(signal); - } - } -} +} // ────────────────────────────────────────────────────────────────────────── // 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(lshpack::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.engine.hpack.with_mut(|hpack| { + hpack.encode_block(&scratch.block, &mut scratch.encoded)?; + Ok(()) }) } @@ -2001,34 +965,54 @@ impl H2FrameParser { /// explicitly plus any custom settings (node never serializes defaults — a session created /// with default options sends an empty SETTINGS frame). Returns the payload length. fn write_settings_payload(&self, out: &mut [u8; MAX_SETTINGS_PAYLOAD_SIZE]) -> usize { - let s = self.local_settings.get(); + let s = self.engine.local_settings.get(); let mask = self.explicit_settings.get(); let mut off = 0usize; let mut put = |out: &mut [u8; MAX_SETTINGS_PAYLOAD_SIZE], id: u16, value: u32| { - out[off..off + 2].copy_from_slice(&id.to_be_bytes()); - out[off + 2..off + 6].copy_from_slice(&value.to_be_bytes()); - off += 6; + out[off..off + settings::ENTRY_SIZE].copy_from_slice(&settings::entry(id, value)); + off += settings::ENTRY_SIZE; }; - if mask & SETTING_BIT_HEADER_TABLE_SIZE != 0 { - put(out, 0x1, s.header_table_size); - } - if mask & SETTING_BIT_ENABLE_PUSH != 0 { - put(out, 0x2, s.enable_push); - } - if mask & SETTING_BIT_MAX_CONCURRENT_STREAMS != 0 { - put(out, 0x3, s.max_concurrent_streams); - } - if mask & SETTING_BIT_INITIAL_WINDOW_SIZE != 0 { - put(out, 0x4, s.initial_window_size); - } - if mask & SETTING_BIT_MAX_FRAME_SIZE != 0 { - put(out, 0x5, s.max_frame_size); - } - if mask & SETTING_BIT_MAX_HEADER_LIST_SIZE != 0 { - put(out, 0x6, s.max_header_list_size); - } - if mask & SETTING_BIT_ENABLE_CONNECT_PROTOCOL != 0 { - put(out, 0x8, s.enable_connect_protocol); + let standard = [ + ( + SETTING_BIT_HEADER_TABLE_SIZE, + SettingId::HeaderTableSize, + s.header_table_size, + ), + ( + SETTING_BIT_ENABLE_PUSH, + SettingId::EnablePush, + u32::from(s.enable_push), + ), + ( + SETTING_BIT_MAX_CONCURRENT_STREAMS, + SettingId::MaxConcurrentStreams, + s.max_concurrent_streams, + ), + ( + SETTING_BIT_INITIAL_WINDOW_SIZE, + SettingId::InitialWindowSize, + s.initial_window_size, + ), + ( + SETTING_BIT_MAX_FRAME_SIZE, + SettingId::MaxFrameSize, + s.max_frame_size, + ), + ( + SETTING_BIT_MAX_HEADER_LIST_SIZE, + SettingId::MaxHeaderListSize, + s.max_header_list_size, + ), + ( + SETTING_BIT_ENABLE_CONNECT_PROTOCOL, + SettingId::EnableConnectProtocol, + u32::from(s.enable_connect_protocol), + ), + ]; + for (bit, id, value) in standard { + if mask & bit != 0 { + put(out, id as u16, value); + } } for (id, value) in self.wire_custom_settings.get().iter() { put(out, *id, *value); @@ -2036,175 +1020,63 @@ impl H2FrameParser { off } - pub(crate) fn set_settings(&self, settings: FullSettingsPayload) -> bool { - bun_output::scoped_log!(H2FrameParser, "HTTP_FRAME_SETTINGS ack false"); - - if self.outstanding_settings.get() >= self.max_outstanding_settings.get() { - self.send_go_away( - 0, - ErrorCode::MAX_PENDING_SETTINGS_ACK, - b"Maximum number of pending settings acknowledgements", - self.last_stream_id.get(), - true, - ); - return false; - } - - self.local_settings.set(settings); + fn set_settings(&self, settings: Settings) { + self.engine.local_settings.set(settings); let mut payload = [0u8; MAX_SETTINGS_PAYLOAD_SIZE]; let payload_len = self.write_settings_payload(&mut payload); - let mut buffer = [0u8; FrameHeader::BYTE_SIZE + MAX_SETTINGS_PAYLOAD_SIZE]; - let mut stream = FixedBufferStream::new(&mut buffer); - let settings_header = FrameHeader { - type_: FrameType::HTTP_FRAME_SETTINGS as u8, - flags: 0, - stream_identifier: 0, - length: payload_len as u32, - }; - let _ = settings_header.write(&mut stream, &self.frames_sent_legacy); - let _ = stream.write_all(&payload[..payload_len]); - - self.outstanding_settings - .set(self.outstanding_settings.get() + 1); - - // Remember which values this submission carries so the engine can attribute the peer's - // ACK to it (§6.5.3 - ACKs apply to outstanding SETTINGS in order). - self.pending_settings_window_submissions.with_mut(|v| { - v.push(crate::api::h2::connection::PendingLocalSettings { - settings: settings.to_engine_settings(), - }) - }); - let _ = self.write(&buffer[..FrameHeader::BYTE_SIZE + payload_len]); - 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( - js::on_aborted_get_cached, - identifier, - abort_reason, - JSValue::js_number(old_state as u8 as f64), - ); - let _ = self.write(&buffer); + // §6.5.3: the peer's ACKs apply to the outstanding SETTINGS frames in order. + self.engine + .pending_local_settings_acks + .with_mut(|v| v.push_back(settings)); + self.write_frame(FrameType::Settings, 0, 0, &[&payload[..payload_len]]); } - pub(crate) fn end_stream(&self, stream: &mut Stream, rst_code: ErrorCode) { - bun_output::scoped_log!( - H2FrameParser, - "HTTP_FRAME_RST_STREAM id: {} code: {}", - stream.id, - rst_code.0 - ); - if stream.state == StreamState::CLOSED { + /// Resets a stream that is still open and tells JS. `on_wire` is false if the peer never heard of the stream or the session is over. + fn reset_stream(&self, stream: &Stream, rst_code: ErrorCode, on_wire: bool) { + if !self.engine.streams.close(stream) { return; } - 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 = rst_code.0; - stream.rst_code = value; - value = value.swap_bytes(); - let _ = writer_stream.write_all(&value.to_ne_bytes()); - - stream.state = StreamState::CLOSED; - let identifier = stream.get_identifier(); - identifier.ensure_still_alive(); - stream.free_resources::(self); - if rst_code == ErrorCode::NO_ERROR { + stream.reset.set(Some(ResetBy::Us)); + stream.close_delivered.set(true); + let context = stream.context(); + context.ensure_still_alive(); + self.drop_queued_data(stream); + if on_wire { + // Written before the handler runs: it can submit a queued request, whose HEADERS must follow this frame. + self.write_frame( + FrameType::RstStream, + 0, + stream.id, + &[&rst_code.0.to_be_bytes()], + ); + } + if on_wire && rst_code == ErrorCode::NO_ERROR { self.dispatch_with_extra( js::on_stream_end_get_cached, - identifier, - JSValue::js_number(stream.state as u8 as f64), + context, + JSValue::js_number(State::Closed as u8 as f64), ); } else { self.dispatch_with_extra( js::on_stream_error_get_cached, - identifier, + context, JSValue::js_number(rst_code.0 as f64), ); } - - let _ = self.write(&buffer); } pub(crate) fn send_go_away( &self, - triggering_stream_id: u32, rst_code: ErrorCode, debug_data: &[u8], last_stream_id: u32, emit_error: bool, ) { - bun_output::scoped_log!( - H2FrameParser, - "HTTP_FRAME_GOAWAY {} code {} debug_data {} emitError {}", - triggering_stream_id, - rst_code.0, - BStr::new(debug_data), - emit_error - ); - self.goaway_sent.set(true); - let mut buffer = [0u8; FrameHeader::BYTE_SIZE + 8]; - let mut stream = FixedBufferStream::new(&mut buffer); - - // RFC 9113 section 6.8: GOAWAY frames are always sent on stream 0. A - // GOAWAY with a non-zero stream identifier is itself a connection - // error of type PROTOCOL_ERROR. The stream that triggered the GOAWAY - // is only used for logging above; the last processed stream id goes in - // the payload below. - let frame = FrameHeader { - type_: FrameType::HTTP_FRAME_GOAWAY as u8, - flags: 0, - stream_identifier: 0, - length: u32::try_from(8 + debug_data.len()).expect("int cast"), - }; - let _ = frame.write(&mut stream, &self.frames_sent_legacy); - let last_id = UInt31WithReserved::init(last_stream_id, false); - let _ = last_id.write(&mut stream); - let mut value: u32 = rst_code.0; - value = value.swap_bytes(); - let _ = stream.write_all(&value.to_ne_bytes()); - - let _ = self.write(&buffer); - if !debug_data.is_empty() { - let _ = self.write(debug_data); - } - let global = self.handlers.get().global(); - let chunk = match self.handlers.get().binary_type.to_js(debug_data, &global) { + self.engine + .write_go_away(self, last_stream_id, rst_code, debug_data); + let global = self.global(); + let chunk = match BinaryType::Buffer.to_js(debug_data, &global) { Ok(v) => v, Err(err) => { self.dispatch(js::on_error_get_cached, self.global().take_exception(err)); @@ -2217,13 +1089,13 @@ impl H2FrameParser { self.dispatch_with_2_extra( js::on_error_get_cached, JSValue::js_number(rst_code.0 as f64), - JSValue::js_number(self.last_stream_id.get() as f64), + JSValue::js_number(self.engine.last_stream_id.get() as f64), chunk, ); } self.dispatch_with_extra( js::on_end_get_cached, - JSValue::js_number(self.last_stream_id.get() as f64), + JSValue::js_number(self.engine.last_stream_id.get() as f64), chunk, ); } @@ -2238,53 +1110,19 @@ impl H2FrameParser { BStr::new(alt) ); - let mut buffer = [0u8; FrameHeader::BYTE_SIZE + 2]; - let mut stream = FixedBufferStream::new(&mut buffer); - - let frame = FrameHeader { - type_: FrameType::HTTP_FRAME_ALTSVC as u8, - flags: 0, + let origin_len = u16::try_from(origin_str.len()).expect("int cast"); + self.write_frame( + FrameType::AltSvc, + 0, stream_identifier, - length: u32::try_from(origin_str.len() + alt.len() + 2).expect("int cast"), - }; - let _ = frame.write(&mut stream, &self.frames_sent_legacy); - let _ = stream.write_all( - &u16::try_from(origin_str.len()) - .expect("int cast") - .to_be_bytes(), + &[&origin_len.to_be_bytes(), origin_str, alt], ); - let _ = self.write(&buffer); - if !origin_str.is_empty() { - let _ = self.write(origin_str); - } - if !alt.is_empty() { - let _ = self.write(alt); - } } - pub(crate) fn send_ping(&self, ack: bool, payload: &[u8]) { - bun_output::scoped_log!( - H2FrameParser, - "HTTP_FRAME_PING ack {} payload {}", - ack, - BStr::new(payload) - ); - - let mut buffer = [0u8; FrameHeader::BYTE_SIZE + 8]; - let mut stream = FixedBufferStream::new(&mut buffer); - if !ack { - self.out_standing_pings - .set(self.out_standing_pings.get() + 1); - } - let frame = FrameHeader { - type_: FrameType::HTTP_FRAME_PING as u8, - flags: if ack { PingFrameFlags::ACK as u8 } else { 0 }, - stream_identifier: 0, - length: 8, - }; - let _ = frame.write(&mut stream, &self.frames_sent_legacy); - let _ = stream.write_all(payload); - let _ = self.write(&buffer); + fn send_ping(&self, payload: &[u8]) { + self.out_standing_pings + .set(self.out_standing_pings.get() + 1); + self.write_frame(FrameType::Ping, 0, 0, &[payload]); } pub(crate) fn send_preface_and_settings(&self) { @@ -2292,88 +1130,94 @@ impl H2FrameParser { // PREFACE + Settings Frame let mut payload = [0u8; MAX_SETTINGS_PAYLOAD_SIZE]; let payload_len = self.write_settings_payload(&mut payload); - let mut preface_buffer = [0u8; 24 + FrameHeader::BYTE_SIZE + MAX_SETTINGS_PAYLOAD_SIZE]; - let mut preface_stream = FixedBufferStream::new(&mut preface_buffer); - let _ = preface_stream.write_all(b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"); - let settings_header = FrameHeader { - type_: FrameType::HTTP_FRAME_SETTINGS as u8, - flags: 0, - stream_identifier: 0, - length: payload_len as u32, - }; - self.outstanding_settings - .set(self.outstanding_settings.get() + 1); - self.pending_settings_window_submissions.with_mut(|v| { - v.push(crate::api::h2::connection::PendingLocalSettings { - settings: self.local_settings.get().to_engine_settings(), - }) - }); - let _ = settings_header.write(&mut preface_stream, &self.frames_sent_legacy); - let _ = preface_stream.write_all(&payload[..payload_len]); - let _ = self.write(&preface_buffer[..24 + FrameHeader::BYTE_SIZE + payload_len]); + self.engine + .pending_local_settings_acks + .with_mut(|v| v.push_back(self.engine.local_settings.get())); + let _ = self.write(CONNECTION_PREFACE); + self.write_frame(FrameType::Settings, 0, 0, &[&payload[..payload_len]]); } - pub(crate) fn send_window_update( - &self, - stream_identifier: u32, - window_size: UInt31WithReserved, - ) { - bun_output::scoped_log!( - H2FrameParser, - "HTTP_FRAME_WINDOW_UPDATE stream {} size {}", - stream_identifier, - window_size.uint31() - ); - let mut buffer = [0u8; FrameHeader::BYTE_SIZE + 4]; - let mut stream = FixedBufferStream::new(&mut buffer); - let settings_header = FrameHeader { - type_: FrameType::HTTP_FRAME_WINDOW_UPDATE as u8, - flags: 0, - stream_identifier, - length: 4, + /// Calls `handlers.(ctx, args...)` now. + fn deliver(&self, event: GetCached, args: &[JSValue]) { + let Some(this_value) = self.strong_this.get().try_get() else { + return; }; - let _ = settings_header.write(&mut stream, &self.frames_sent_legacy); - let _ = window_size.write(&mut stream); - let _ = self.write(&buffer); - } - - /// 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) + let Some(ctx_value) = js::context_get_cached(this_value) else { + return; + }; + let mut argv = [ctx_value; 5]; + argv[1..=args.len()].copy_from_slice(args); + let _ = + self.handlers + .call_event_handler(event, this_value, ctx_value, &argv[..=args.len()]); } - /// Reborrows a host fn's `*mut Stream` with the dispatch guard armed for the borrow's whole - /// lifetime: user JS the caller runs while holding it (option getters, `toString`) can - /// re-enter `read()` without freeing the stream. Use this instead of a raw `&mut *ptr`. - fn enter_stream_dispatch(&self, stream_ptr: *mut Stream) -> GuardedStream<'_> { - let _dispatch = self.enter_dispatch(); - GuardedStream { - // SAFETY: stream_ptr is the heap::alloc'd *mut Stream stored in self.streams; the - // map entry outlives the returned borrow because the armed dispatch depth defers - // the only free path (rewrite_read's pending close drain) while the guard is live. - stream: unsafe { &mut *stream_ptr }, - _dispatch, + /// Delivers an event, or queues it while `receive()` is on the stack. + fn dispatch_event(&self, event: GetCached, target: Target, extra: &[JSValue]) { + for value in extra { + value.ensure_still_alive(); } - } - - pub(crate) fn dispatch(&self, event: GetCached, value: JSValue) { - value.ensure_still_alive(); - let Some(this_value) = self.strong_this.get().try_get() else { + if !self.receiving.get() { + let mut args = [JSValue::UNDEFINED; 4]; + args[0] = match target { + Target::Value(value) => value, + Target::Stream(stream_id) => self.stream_context(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 Some(ctx_value) = js::context_get_cached(this_value) else { - return; + 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), }; - let _dispatch = self.enter_dispatch(); - let _ = self.handlers.get().call_event_handler( - event, - this_value, - ctx_value, - &[ctx_value, value], - ); + 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 `receive`, 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.stream_context(*id), + }; + } + self.deliver(event, &values[..argc as usize]); + } + Deferred::WriteCallback(callback) => self.dispatch_write_callback(callback.get()), + Deferred::StreamEnd(stream) => self.stream_end_now(&stream), + Deferred::StreamReset { stream, code } => self.stream_reset_now(&stream, code), + } + } + } + + pub(crate) fn dispatch(&self, event: GetCached, value: JSValue) { + self.dispatch_event(event, Target::Value(value), &[]); } /// `onWrite(context, bytes, transportClosed)`. The flag is true once the native socket closed. @@ -2385,8 +1229,7 @@ impl H2FrameParser { return JSValue::ZERO; }; bytes.ensure_still_alive(); - let _dispatch = self.enter_dispatch(); - self.handlers.get().call_event_handler_with_result( + self.handlers.call_event_handler_with_result( js::on_write_get_cached, this_value, &[ @@ -2398,26 +1241,19 @@ impl H2FrameParser { } pub(crate) fn dispatch_write_callback(&self, callback: JSValue) { - let _dispatch = self.enter_dispatch(); - let _ = self.handlers.get().call_write_callback(callback, &[]); + 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 _ = self.handlers.call_write_callback(callback, &[]); } pub(crate) fn dispatch_with_extra(&self, event: GetCached, value: JSValue, extra: JSValue) { - let Some(this_value) = self.strong_this.get().try_get() else { - return; - }; - let Some(ctx_value) = js::context_get_cached(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( @@ -2427,35 +1263,7 @@ impl H2FrameParser { extra: JSValue, extra2: JSValue, ) { - self.dispatch_values(event, [value, extra, extra2]); - } - - pub(crate) fn dispatch_with_3_extra( - &self, - event: GetCached, - value: JSValue, - extra: JSValue, - extra2: JSValue, - extra3: JSValue, - ) { - self.dispatch_values(event, [value, extra, extra2, extra3]); - } - - fn dispatch_values(&self, event: GetCached, values: [JSValue; N]) { - let Some(this_value) = self.strong_this.get().try_get() else { - return; - }; - let Some(ctx_value) = js::context_get_cached(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]); } /// A header block the HPACK encoder cannot emit fails the whole session in nghttp2, so node @@ -2679,6 +1487,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.get() { + return 0; + } + let _flushing = FlagGuard::set(&self.flushing_stream_queue); bun_output::scoped_log!( H2FrameParser, "flushStreamQueue {}", @@ -2689,18 +1502,16 @@ impl H2FrameParser { // try to send as much as we can until we reach backpressure or until we can't flush anymore while self.outbound_queue_size.get() > 0 && something_was_flushed { - let mut it = StreamResumableIterator::init(self); something_was_flushed = false; - while let Some(stream) = it.next() { - // 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 }; - // reach backpressure - let result = stream.flush_queue(self, &mut written); - match result { + for stream in self.engine.streams.snapshot() { + // The cork hides a refused write from flush_queue() until a write callback flushes it. + if self.has_backpressure() { + return written; + } + match self.flush_queue(&stream, &mut written) { FlushState::Flushed => something_was_flushed = true, - FlushState::NoAction => continue, // we can continue - FlushState::Backpressure => return written, // backpressure we need to return + FlushState::NoAction => {} + FlushState::Backpressure => return written, } } } @@ -2738,13 +1549,10 @@ impl H2FrameParser { let offset = self.write_buffer_offset.get(); let bytes_len = self.write_buffer.get().slice()[offset..].len(); if bytes_len > 0 { - let global = self.handlers.get().global(); + let global = self.global(); // A failed conversion means the VM is terminating (or OOM): report // nothing flushed instead of calling JS with an empty value. - let Ok(output_value) = self - .handlers - .get() - .binary_type + let Ok(output_value) = BinaryType::Buffer .to_js(&self.write_buffer.get().slice()[offset..], &global) else { return 0; @@ -2816,12 +1624,7 @@ impl H2FrameParser { return false; } // fallback to onWrite non-native callback - let code = match self - .handlers - .get() - .binary_type - .to_js(bytes, &self.handlers.get().global()) - { + let code = match BinaryType::Buffer.to_js(bytes, &self.global()) { Ok(output_value) => { let result = self.call_on_write(output_value); if result.is_number() { @@ -2861,20 +1664,19 @@ 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.queue.get().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. fn transport_write_runs_js(&self) -> bool { match self.native_socket.get() { BunSocket::None => true, - BunSocket::Tls(s) | BunSocket::TlsWriteonly(s) => matches!( - s.get().socket.get().socket, - bun_uws::InternalSocket::UpgradedDuplex(_) - ), - BunSocket::Tcp(s) | BunSocket::TcpWriteonly(s) => matches!( - s.get().socket.get().socket, - bun_uws::InternalSocket::UpgradedDuplex(_) - ), + BunSocket::Tls(s) | BunSocket::TlsWriteonly(s) => s.get().socket.get().write_runs_js(), + BunSocket::Tcp(s) | BunSocket::TcpWriteonly(s) => s.get().socket.get().write_runs_js(), } } @@ -3028,14 +1830,16 @@ impl H2FrameParser { } } - /// The kernel rejected a send of the session's TLS socket. The TLS layer closes the socket - /// once it has read what the peer sent before. - fn transport_send_rejected(&self) -> bool { + /// The kernel rejected a send of the session's socket, which closes once what the peer sent before has been read. + pub(crate) fn has_rejected_send(&self) -> bool { + if self.transport_write_fatal.get() { + return true; + } match self.native_socket.get() { + // The TLS layer sends its records later than `write` returns. BunSocket::Tls(socket) | BunSocket::TlsWriteonly(socket) => { - socket.get().socket.get().ssl_send_rejected() + socket.get().socket.get().rejected_send_error() != 0 } - // A plain socket is closed at once, which ends its streams before the error is reported. _ => false, } } @@ -3050,19 +1854,21 @@ impl H2FrameParser { pub(crate) fn on_auto_flush(&self) -> bool { let _keepalive = self.keepalive(); + // Flushing can call JS (a JS transport's write, write callbacks, the socket's 'close'). + self.global_this.bun_vm().deferred_task_called_js.set(true); if self.transport_write_fatal.get() { - // 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. + // 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, so only the registration's flag and ref are released here. 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); @@ -3076,7 +1882,7 @@ impl H2FrameParser { self.dispatch_with_2_extra( js::on_error_get_cached, JSValue::js_number(ErrorCode::COMPRESSION_ERROR.0 as f64), - JSValue::js_number(self.last_stream_id.get() as f64), + JSValue::js_number(self.engine.last_stream_id.get() as f64), JSValue::UNDEFINED, ); let _ = self.flush(); @@ -3403,82 +2209,30 @@ impl H2FrameParser { /// https://github.com/nodejs/node/blob/v26.3.0/src/node_http2.cc#L1665-L1689 fn latin1_to_js(&self, payload: &[u8]) -> JsResult { - let global = self.handlers.get().global(); + let global = self.global(); bun_core::String::clone_latin1(payload).into_js(&global) } - /// 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 - if stream_identifier == 0 { - return None; - } - - // already exists - if let Some(stream) = self.streams.get().get(&stream_identifier).copied() { - return Some(stream); - } - - if stream_identifier > self.last_stream_id.get() { - self.last_stream_id.set(stream_identifier); - } - let peer_parity: u32 = if self.is_server.get() { 1 } else { 0 }; - if stream_identifier % 2 == peer_parity - && stream_identifier > self.last_peer_stream_id.get() - { - 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 - } else { - self.local_settings.get().initial_window_size - }; - 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)); - + /// Calls `streamStart` for a new table entry and keeps the JS stream it returns. + fn announce_stream(&self, stream: &Stream) { let Some(this_value) = self.strong_this.get().try_get() else { - return Some(stream); + return; }; let Some(ctx_value) = js::context_get_cached(this_value) else { - return Some(stream); + return; }; let Some(callback) = js::on_stream_start_get_cached(this_value) else { - return Some(stream); + return; }; - - let global = self.handlers.get().global(); - // The callback runs arbitrary JS while `stream` is held (here and by every - // caller): arm the dispatch guard so a reentrant read() cannot free the box at - // depth 0. Bare guard, not enter_stream_dispatch — rst_stream reached from the - // callback takes its own `&mut` to this stream, so ours must wait for the return. - let _dispatch = self.enter_dispatch(); - // A top-level call of its own: a throwing `streamStart` is reported and - // yields no stream object. Called bare (no scope of its own, so no - // checkpoint per stream on the socket path); the parser drive is the - // landing frame that folds it. + let global = self.global(); if global.has_exception() { - return Some(stream); + return; } + // Called bare, without a scope of its own, so there's no checkpoint per stream. A throw is reported and yields no JS stream. let returned = match callback.call( &global, ctx_value, - &[ctx_value, JSValue::js_number(stream_identifier as f64)], + &[ctx_value, JSValue::js_number(stream.id as f64)], ) { Ok(v) => v, Err(err) => { @@ -3486,324 +2240,168 @@ impl H2FrameParser { JSValue::ZERO } }; - // streamStart returns the JS stream it created; storing it here saves the - // setStreamContext host call the JS layer used to make per stream. - // Skipped when the callback closed the stream: free_resources dropped its - // sctx root, and re-rooting would pin the dead JS stream until session death. - if returned.is_object() - && !self - .pending_engine_stream_closes - .get() - .contains(&stream_identifier) - { - self.sctx.with_mut(|m| { - m.insert(stream_identifier, StrongOptional::create(returned, &global)); - }); - self.enter_stream_dispatch(stream) - .set_context(returned, &global); - } - Some(stream) - } - - fn to_writer(&self) -> DirectWriterStruct { - DirectWriterStruct { - writer: bun_ptr::BackRef::new(self), + // Not if the callback closed the stream, which would pin the dead JS stream. + if returned.is_object() && !stream.is_closed() { + stream.set_context(returned, &global); } } } -/// Bridge the rewrite engine's `Settings` to the JS settings object via the legacy wire payload. -fn rewrite_settings_to_js(s: &crate::api::h2::settings::Settings, global: GlobalRef) -> JSValue { - let fp = FullSettingsPayload { - header_table_size: s.header_table_size, - enable_push: s.enable_push, - max_concurrent_streams: s.max_concurrent_streams, - initial_window_size: s.initial_window_size, - max_frame_size: s.max_frame_size, - max_header_list_size: s.max_header_list_size, - enable_connect_protocol: s.enable_connect_protocol, - }; - fp.to_js(&global) -} - impl H2FrameParser { - /// Bridge the legacy local SETTINGS payload to the rewrite engine's Settings. - fn rewrite_local_settings(&self) -> crate::api::h2::settings::Settings { - let s = self.local_settings.get(); - crate::api::h2::settings::Settings { - header_table_size: s.header_table_size, - enable_push: s.enable_push, - max_concurrent_streams: s.max_concurrent_streams, - initial_window_size: s.initial_window_size, - max_frame_size: s.max_frame_size, - max_header_list_size: s.max_header_list_size, - enable_connect_protocol: s.enable_connect_protocol, - } - } - - /// Lazily create the rewrite engine once is_server + local settings are known. - fn ensure_engine(&self) { - if self.engine.borrow().is_none() { - let mut conn = crate::api::h2::connection::Connection::new( - self.is_server.get(), - self.rewrite_local_settings(), - ); - // The engine is created lazily on the first inbound read, by which point settings() - // may already have submitted (still unACKed) values; received header blocks must be - // checked against the constructor-time limit until the peer ACKs a later submission. - conn.enforced_max_header_list_size = self.enforced_max_header_list_size.get(); - *self.engine.borrow_mut() = Some(conn); - } - } - - /// Resolve the JS stream context for the rewrite engine's dispatches: the `sctx` map (populated - /// by setStreamContext, i.e. server-side inbound streams) or the legacy stream's own context - /// (populated directly by the legacy request() for client-initiated streams). - fn rewrite_stream_ctx(&self, stream_id: u32) -> JSValue { - if let Some(ctx) = self.sctx.get().get(&stream_id).and_then(|s| s.get()) { - return ctx; - } - 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 - return unsafe { (*stream).get_identifier() }; - } - JSValue::UNDEFINED + /// What a handler gets as the stream with this id. + fn stream_context(&self, stream_id: u32) -> JSValue { + self.engine + .streams + .get(stream_id) + .map_or(JSValue::UNDEFINED, |stream| stream.context()) } - /// 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)); + /// Empties the scratch of decoded fields. A large block doesn't keep its size for the life of the session. + fn reset_header_scratch(&self) { + self.hdr_block.with_mut(|block| { + if block.capacity() > MAX_BUFFER_SIZE as usize { + *block = Vec::new(); } + block.clear(); }); + self.hdr_meta.with_mut(|meta| meta.clear()); } - /// 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) { - if let Ok(guard) = self.engine.try_borrow() { - if let Some(engine) = guard.as_ref() { - self.engine_frames_received.set(engine.frames_received); - self.engine_frames_sent.set(engine.frames_sent); - } + /// Runs at the start of a read, before any frame of it is parsed. + fn begin_batch(&self) { + // Only a drained transport resets the count. + if !self.has_unsent_output() { + self.engine.note_outbound_drained(); } + self.engine.streams.begin_read(); } - /// 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)); + /// Feeds inbound bytes to the engine and keeps what follows the last complete frame for the next read. + fn receive(&self, bytes: &[u8]) { + if self.draining.get() != Draining::No { 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. - 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(); - engine.max_settings = self.max_settings.get(); - engine.max_invalid_frames = self.max_session_invalid_frames.get(); - engine.strict_field_whitespace = self.strict_field_whitespace.get(); - engine.set_stream_reset_limit( - self.stream_reset_burst.get(), - self.stream_reset_rate.get(), - ); - // 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() { - engine.note_outbound_drained(); - } - // Apply any receive-window growth setLocalWindowSize() accumulated while a dispatch - // held this borrow. - let pending = self.pending_recv_window_growth.replace(0); - 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| { - for w in v.drain(..) { - engine.pending_local_settings_acks.push_back(w); - } - }); - // 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 { - self.pending_engine_stream_closes.with_mut(|v| { - for id in v.drain(..) { - engine.close_stream(id); - if let Some(stream) = self.streams.with_mut(|m| m.remove(&id)) { - // SAFETY: stream is the heap::alloc'd *mut Stream owned by the - // map entry just removed; free_resources ran when it was queued, - // dispatch_depth == 0 means no caller below us on the stack holds - // a `&mut Stream` across anything that can run user JS (every - // such site arms enter_dispatch), ids never repeat within a - // session, so this frees exactly once. - unsafe { - drop(bun_core::heap::take(stream)); - } - } - } - }); - } + // A JS transport can push bytes back while receive() is on the stack. The running batch picks them up. + if self.receiving.get() { + self.inbound_tail.with_mut(|t| t.extend_from_slice(bytes)); + return; } - 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); + // `inbound_tail` is either an incomplete frame or the bytes an outer batch parked before running callbacks. + let mut owned: Vec = self.inbound_tail.take(); + let mut pos = self.inbound_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); + } + self.begin_batch(); + loop { let feed = { - let mut guard = self.engine.borrow_mut(); - guard.as_mut().unwrap().receive(self, &combined) + 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); + self.engine.receive(self, input) }; - 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()); + pos += feed.consumed; + // Bytes a JS transport pushed back during receive() follow the current input. + let pushed = self.inbound_tail.take(); + if !pushed.is_empty() { + if direct { + owned.extend_from_slice(&bytes[pos..]); + direct = false; + pos = 0; } - }); - } - // 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). - loop { - if self.rewrite_tail.get().is_empty() { + owned.extend_from_slice(&pushed); + } + if feed.end == FeedEnd::Fatal { + // GOAWAY is on the wire and on_error tears the session down, so the rest is never parsed. + self.run_deferred(); break; } - let pending = self.rewrite_tail.with_mut(std::mem::take); - let feed = { - let mut guard = self.engine.borrow_mut(); - guard.as_mut().unwrap().receive(self, &pending) - }; - if feed.fatal { - self.rewrite_tail.with_mut(|t| t.clear()); - let _ = self.flush(); - return; + if feed.end == FeedEnd::Drained && !pushed.is_empty() { + // The pushed bytes may complete a frame. + continue; } - let consumed = feed.consumed; - self.rewrite_tail - .with_mut(|t| drop(t.splice(0..0, pending[consumed..].iter().copied()))); - if consumed == 0 { + let stopped = self.left_exception.get() || self.draining.get() != Draining::No; + if feed.end == FeedEnd::Drained || stopped { + // The engine parsed every complete frame or has to stop. Keep the rest for the next read. + self.inbound_tail.set(if direct { + bytes[pos..].to_vec() + } else { + unparsed_rest(owned, pos) + }); + 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.inbound_tail.set(owned); + self.inbound_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.inbound_tail.take(); + pos = self.inbound_tail_pos.replace(0).min(owned.len()); } + self.engine.streams.end_read(); // Uncork: flush the engine's queued control/response frames to the socket. let _ = self.flush(); } } -/// 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.) +impl Sink for H2FrameParser { + /// 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) { - self.engine_frames_received.set(received); - self.engine_frames_sent.set(sent); - } - - fn write(&self, bytes: &[u8]) -> crate::api::h2::connection::WriteResult { - if self.write(bytes) { - crate::api::h2::connection::WriteResult::Sent - } else { - crate::api::h2::connection::WriteResult::Queued + fn write_frame( + &self, + frame_type: FrameType, + frame_flags: u8, + stream_id: u32, + payload: &[&[u8]], + ) -> bool { + self.frames_sent.set(self.frames_sent.get() + 1); + let length: usize = payload.iter().map(|part| part.len()).sum(); + let mut sent = + self.write(&FrameHeader::new(frame_type, frame_flags, stream_id, length).to_bytes()); + for part in payload { + if !part.is_empty() { + sent = self.write(part); + } } + sent } - fn on_error(&self, lib_error_code: i32, _last: u32, debug: &[u8]) { + fn on_error(&self, lib_error_code: i32, debug: &[u8]) { // The engine detected a connection error and already wrote the GOAWAY: surface it to JS // as the negative nghttp2-style library error code (the JS handler builds node's // NghttpError from it: code ERR_HTTP2_ERROR, message nghttp2_strerror), then the end // callback so the session tears itself down and closes the socket. let g = self.global(); - let Some(chunk) = self.or_stop(self.handlers.get().binary_type.to_js(debug, &g)) else { + let Some(chunk) = self.or_stop(BinaryType::Buffer.to_js(debug, &g)) else { return; }; if lib_error_code != 0 { - // The responses of a client that is gone are still queued. The server's handler ends - // their streams without the session's error, as the close that follows would. - let client_is_gone = self.is_server.get() && self.transport_send_rejected(); - self.dispatch_with_3_extra( + self.dispatch_with_2_extra( js::on_error_get_cached, JSValue::js_number(lib_error_code as f64), - JSValue::js_number(self.last_stream_id.get() as f64), + JSValue::js_number(self.engine.last_stream_id.get() as f64), chunk, - JSValue::from(client_is_gone), ); } self.dispatch_with_extra( js::on_end_get_cached, - JSValue::js_number(self.last_stream_id.get() as f64), + JSValue::js_number(self.engine.last_stream_id.get() as f64), chunk, ); } @@ -3823,18 +2421,14 @@ impl crate::api::h2::connection::Sink for H2FrameParser { self.dispatch_with_2_extra( js::on_error_get_cached, code_js, - JSValue::js_number(self.last_stream_id.get() as f64), + JSValue::js_number(self.engine.last_stream_id.get() as f64), JSValue::UNDEFINED, ); } - fn on_local_settings(&self, settings: &crate::api::h2::settings::Settings) { - // Bridge: our SETTINGS was ACKed — release the legacy outstanding-settings slot so the - // legacy settings() host fn doesn't hit MAX_PENDING_SETTINGS_ACK. - self.outstanding_settings - .set(self.outstanding_settings.get().saturating_sub(1)); + fn on_local_settings(&self, settings: &Settings) { let g = self.global(); - let js = rewrite_settings_to_js(settings, g); + let js = settings_to_js(settings, &g); // node exposes the custom settings this side submitted on localSettings.customSettings. if !self.custom_settings.get().is_empty() { let custom = JSValue::create_empty_object(&g, self.custom_settings.get().len()); @@ -3865,36 +2459,9 @@ impl crate::api::h2::connection::Sink for H2FrameParser { }); } - 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 { - header_table_size: settings.header_table_size, - enable_push: settings.enable_push, - max_concurrent_streams: settings.max_concurrent_streams, - initial_window_size: settings.initial_window_size, - max_frame_size: settings.max_frame_size, - max_header_list_size: settings.max_header_list_size, - enable_connect_protocol: settings.enable_connect_protocol, - }; - 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 { - let _ = self.flush(); - } + fn on_remote_settings(&self, settings: &Settings) { let g = self.global(); - let js = rewrite_settings_to_js(settings, g); + let js = settings_to_js(settings, &g); // Custom setting ids the user asked to track (remoteCustomSettings) and that the peer has // sent are surfaced on remoteSettings.customSettings (node semantics). if !self.remote_custom_settings.get().is_empty() { @@ -3919,41 +2486,32 @@ impl crate::api::h2::connection::Sink for H2FrameParser { // "Protocol error"); the JS error handler destroys the session. if self.out_standing_pings.get() == 0 { let g = self.global(); - let Some(chunk) = self.or_stop( - self.handlers - .get() - .binary_type - .to_js(b"unsolicited PING ACK", &g), - ) else { + let Some(chunk) = + self.or_stop(BinaryType::Buffer.to_js(b"unsolicited PING ACK", &g)) + else { return; }; self.dispatch_with_2_extra( js::on_error_get_cached, - JSValue::js_number(crate::api::h2::wire::lib_error::PROTO as f64), - JSValue::js_number(self.last_stream_id.get() as f64), + JSValue::js_number(lib_error::PROTO as f64), + JSValue::js_number(self.engine.last_stream_id.get() as f64), chunk, ); return; } - // Balance the increment from send_ping: the legacy inbound path decremented this in - // handle_ping; the engine path must do the same or the outstanding-ping limit trips - // on long-lived sessions. self.out_standing_pings .set(self.out_standing_pings.get().saturating_sub(1)); } let g = self.global(); - let Some(buffer) = self.or_stop(self.handlers.get().binary_type.to_js(payload, &g)) else { + let Some(buffer) = self.or_stop(BinaryType::Buffer.to_js(payload, &g)) else { return; }; self.dispatch_with_extra(js::on_ping_get_cached, buffer, JSValue::from(is_ack)); } fn on_go_away(&self, code: u32, last: u32, debug: &[u8]) { - // Always a Buffer (possibly empty) to match the legacy dispatch shape. The lastStreamID - // surfaced to JS is the peer's Last-Stream-ID from the GOAWAY payload (node's documented - // semantics for the 'goaway' event). let g = self.global(); - let Some(chunk) = self.or_stop(self.handlers.get().binary_type.to_js(debug, &g)) else { + let Some(chunk) = self.or_stop(BinaryType::Buffer.to_js(debug, &g)) else { return; }; self.dispatch_with_2_extra( @@ -3964,21 +2522,7 @@ impl crate::api::h2::connection::Sink for H2FrameParser { ); } - fn on_window_update(&self, stream_id: u32, increment: u32) { - 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. - if stream_id == 0 { - self.remote_window_size - .set(self.remote_window_size.get() + 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 }; - } + fn on_send_window_increased(&self) { let _ = self.flush(); } @@ -4000,45 +2544,18 @@ impl crate::api::h2::connection::Sink for H2FrameParser { ); } - fn can_open_stream(&self) -> bool { - // node (Http2Session::OnBeginHeadersCallback): a new inbound stream is refused when the - // session is over its maxSessionMemory budget. - !self.is_over_session_memory_limit() - } - - fn is_local_stream(&self, stream_id: u32) -> bool { - // The legacy outbound created an entry in the legacy streams map for every locally - // initiated stream (request/respond), so membership there means "we sent HEADERS on it". - self.streams.get().contains_key(&stream_id) - } - - fn goaway_sent(&self) -> bool { - self.goaway_sent.get() - } - - fn highest_started_stream_id(&self) -> u32 { - // handle_received_stream_id raises this for every stream registered on this side - // (including locally-initiated ones) and eviction never lowers it. - self.last_stream_id.get() - } - - fn is_stream_reading(&self, stream_id: u32) -> bool { - match self.streams.get().get(&stream_id).copied() { - // SAFETY: stream is *mut Stream from self.streams; valid while the map entry exists - Some(stream) => unsafe { !(*stream).reading_paused }, - None => true, - } + fn buffered_output(&self) -> usize { + self.write_buffer.get().len_u32() as usize + self.queued_data_size.get() as usize } fn on_push_promise(&self, _parent_id: u32, promised_id: u32) { // 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); + self.pending_push.set(promised_id); } fn on_origin(&self, payload: &[u8]) { - // Match the legacy dispatch shape: a single origin is passed as a string, multiple origins - // as an array — one onOrigin dispatch per ORIGIN frame. + // One origin is passed as a string, several as an array. if !self.can_dispatch(js::on_origin_get_cached) { return; } @@ -4085,12 +2602,14 @@ impl crate::api::h2::connection::Sink for H2FrameParser { self.dispatch(js::on_origin_get_cached, origin_value); } - fn on_stream_open(&self, stream_id: u32) { - // 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); + fn on_stream_open(&self, stream: &Stream) { + self.announce_stream(stream); + } + + fn on_headers_refused(&self) { + self.reset_header_scratch(); + // The next HEADERS must not be taken for this block's PUSH_PROMISE. + self.pending_push.set(0); } fn on_header(&self, _stream_id: u32, name: &[u8], value: &[u8], never_index: bool) { @@ -4110,12 +2629,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, 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). @@ -4128,20 +2642,18 @@ impl crate::api::h2::connection::Sink for H2FrameParser { // SAFETY: block/meta are live Vec borrows for the call duration; the // returned tuple is rooted by the conservative stack scan until // dispatched below. - let v = bun_jsc::call_zero_is_throw(&g, || unsafe { + bun_jsc::call_zero_is_throw(&g, || unsafe { Bun__h2__materializeHeaders(&g, block.as_ptr(), meta.as_ptr(), field_count) - }); - block.clear(); - meta.clear(); - v + }) }) }); + self.reset_header_scratch(); let Some(tuple) = self.or_stop(tuple) else { return; }; - if self.rewrite_pending_push.get() == stream_id && stream_id != 0 { + if self.pending_push.get() == stream_id && stream_id != 0 { // A PUSH_PROMISE header block: surface the promised request to JS as a pushed stream. - self.rewrite_pending_push.set(0); + self.pending_push.set(0); self.dispatch_with_2_extra( js::on_stream_push_get_cached, JSValue::js_number(stream_id as f64), @@ -4149,180 +2661,97 @@ 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( js::on_stream_headers_get_cached, - 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 { + let Some(chunk) = self.or_stop(BinaryType::Buffer.to_js(data, &g)) else { return; }; - self.dispatch_with_extra(js::on_stream_data_get_cached, stream_ctx, chunk); - } - - fn on_stream_end(&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), - // mirroring the legacy handle_data/headers END_STREAM logic. - let mut effective = state; - 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 - let legacy_state = unsafe { (*stream).state }; - if state == 6 - && matches!( - legacy_state, - StreamState::HALF_CLOSED_LOCAL | StreamState::CLOSED - ) - { - effective = 7; - } - // SAFETY: stream is *mut Stream from self.streams; valid while the map entry exists - unsafe { - (*stream).state = match effective { - 5 => StreamState::HALF_CLOSED_LOCAL, - 6 => StreamState::HALF_CLOSED_REMOTE, - 7 => StreamState::CLOSED, - _ => legacy_state, - }; - } - } - let stream_ctx = self.rewrite_stream_ctx(stream_id); - self.dispatch_with_extra( - js::on_stream_end_get_cached, - stream_ctx, - JSValue::js_number(effective as f64), + self.dispatch_event( + js::on_stream_data_get_cached, + Target::Stream(stream_id), + &[chunk], ); - if effective == 7 { - // Fully closed. In the sync-response flow the JS handler already half-closed the - // local side during the HEADERS dispatch, so the legacy send-close branch saw - // OPEN and skipped its teardown — free the legacy context here or it (its Strong - // JS stream root, and the engine's map entry) leaks per request. - 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).free_resources::(self) }; - } - // Release the per-stream JS context root so it can be collected (also done by - // free_resources, but a stream may have no legacy entry). - self.sctx.with_mut(|m| { - m.remove(&stream_id); - }); + } + + fn on_stream_end(&self, stream: &Rc) { + if self.receiving.get() { + self.deferred + .with_mut(|q| q.push_back(Deferred::StreamEnd(Rc::clone(stream)))); + return; } + self.stream_end_now(stream); } - 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. + fn on_stream_rejected(&self) { 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(), + self.engine.last_stream_id.get(), true, ); } } - fn on_stream_reset(&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 - // next HEADERS dispatch as a push. - self.hdr_block.with_mut(|b| b.clear()); - self.hdr_meta.with_mut(|m| m.clear()); - 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; - 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; - } + fn on_stream_reset(&self, stream: &Rc, code: u32) { + if self.receiving.get() { + let stream = Rc::clone(stream); + self.deferred + .with_mut(|q| q.push_back(Deferred::StreamReset { stream, code })); + return; } - let stream_ctx = self.rewrite_stream_ctx(stream_id); - 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.stream_reset_now(stream, code); + } +} + +impl H2FrameParser { + fn stream_end_now(&self, stream: &Stream) { + // JS reset the stream in a callback of the frame that ended it. + if stream.is_closed() { + return; + } + let _ = self + .engine + .streams + .apply(stream, StreamEvent::RecvEndStream); + let state = stream.state(); + self.dispatch_with_extra( + js::on_stream_end_get_cached, + stream.context(), + JSValue::js_number(state as u8 as f64), + ); + if state == State::Closed { + stream.close_delivered.set(true); + self.drop_queued_data(stream); + } + } + + fn stream_reset_now(&self, stream: &Stream, code: u32) { + if code == ErrorCode::CANCEL.0 { + // Like node, a peer's CANCEL is 'aborted' and a close with rstCode 8, not an 'error'. + self.dispatch_with_extra( js::on_aborted_get_cached, - stream_ctx, + stream.context(), JSValue::UNDEFINED, - JSValue::js_number(old_state as f64), ); } else { self.dispatch_with_extra( js::on_stream_error_get_cached, - stream_ctx, + stream.context(), JSValue::js_number(code as f64), ); } - // The reset closes the stream; free the legacy slot (queueing the engine eviction) - // and release its JS context root, mirroring the on_stream_end full-close path. - 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).free_resources::(self) }; - } - self.sctx.with_mut(|m| { - m.remove(&stream_id); - }); - } -} - -// Note: holds a `BackRef` so the borrow of the parser ends -// at `to_writer()`'s return — `Stream::flush_queue` interleaves field -// reads/writes on the parser between `writer.write()` calls. R-2: `write()` -// takes `&self` (Cell/JsCell-backed), so a shared back-reference is sufficient -// and the `BackRef` invariant (parser outlives this struct) holds by -// construction. -struct DirectWriterStruct { - writer: bun_ptr::BackRef, -} -impl bun_io::Write for DirectWriterStruct { - fn write_all(&mut self, data: &[u8]) -> bun_io::Result<()> { - if self.writer.write(data) { - Ok(()) - } else { - Err(bun_core::Error::WriteFailed) - } - } -} - -impl DirectWriterStruct { - /// The payload of a PADDED DATA frame (RFC 9113 6.1); the caller wrote the frame header. - fn write_padded(&mut self, data: &[u8], padding: u8) -> bun_io::Result<()> { - let payload_size = 1 + data.len() + padding as usize; - // Taken by value so a re-entrant call gets its own; see take_h2_padded_frame_buffer. - let global = self.writer.global(); - let mut buffer = global - .bun_vm() - .as_mut() - .rare_data() - .take_h2_padded_frame_buffer(); - buffer[0] = padding; - buffer[1..=data.len()].copy_from_slice(data); - buffer[1 + data.len()..payload_size].fill(0); - let result = self.write_all(&buffer[..payload_size]); - global - .bun_vm() - .as_mut() - .rare_data() - .put_back_h2_padded_frame_buffer(buffer); - result + stream.close_delivered.set(true); + self.drop_queued_data(stream); } } @@ -4331,206 +2760,88 @@ impl DirectWriterStruct { // ────────────────────────────────────────────────────────────────────────── impl H2FrameParser { - pub(crate) fn load_settings_from_js_value( + /// Reads the settings of a `settings()` call or of the session's options. JS has validated them (`validateSettings`). + fn load_settings_from_js_value( &self, global_object: &JSGlobalObject, options: JSValue, ) -> JsResult<()> { - if !options.is_object() { - return Err(global_object.throw(format_args!("Expected settings to be a object"))); - } - - // R-2: read-modify-write the `Cell` via a local copy. - let mut local_settings = self.local_settings.get(); - // The wire-emit mask is per-call: node's updateSettingsBuffer starts flags at 0 and only - // sends entries the user provided in this call. local_settings remains cumulative. + let mut local_settings = self.engine.local_settings.get(); + // Like node's updateSettingsBuffer, a SETTINGS frame only has the entries of this call. `local_settings` accumulates. let mut explicit_settings: u8 = 0; macro_rules! number_setting { - ($key:literal, $field:ident, $bit:expr, $min:expr, $max:expr, $err:literal) => {{ - 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 { - return global_object - .err_http2_invalid_setting_value_range_error($err) - .throw(); - } - local_settings.$field = value as u32; - explicit_settings |= $bit; - } else if !v.is_empty_or_undefined_or_null() { - return global_object - .err_http2_invalid_setting_value_range_error(concat!( - "Expected ", - $key, - " to be a number" - )) - .throw(); - } + ($key:literal, $field:ident, $bit:expr) => {{ + if let Some(v) = options.get(global_object, $key)? + && v.is_number() + { + local_settings.$field = v.as_number() as u32; + explicit_settings |= $bit; + } + }}; + } + macro_rules! bool_setting { + ($key:literal, $field:ident, $bit:expr) => {{ + if let Some(v) = options.get(global_object, $key)? + && v.is_boolean() + { + local_settings.$field = v.as_boolean(); + explicit_settings |= $bit; } }}; } - number_setting!( "headerTableSize", header_table_size, - SETTING_BIT_HEADER_TABLE_SIZE, - 0, - MAX_HEADER_TABLE_SIZE_F64, - "Expected headerTableSize to be a number between 0 and 2^32-1" + SETTING_BIT_HEADER_TABLE_SIZE ); - - if let Some(enable_push) = options.get(global_object, "enablePush")? { - if enable_push.is_boolean() { - local_settings.enable_push = if enable_push.as_boolean() { 1 } else { 0 }; - explicit_settings |= SETTING_BIT_ENABLE_PUSH; - } else if !enable_push.is_undefined() { - return global_object - .err_http2_invalid_setting_value("Expected enablePush to be a boolean") - .throw(); - } - } - - 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 { - return global_object - .err_http2_invalid_setting_value_range_error( - "Expected initialWindowSize to be a number between 0 and 2^32-1", - ) - .throw(); - } - bun_output::scoped_log!(H2FrameParser, "initialWindowSize: {}", value as u32); - local_settings.initial_window_size = value as u32; - explicit_settings |= SETTING_BIT_INITIAL_WINDOW_SIZE; - } else if !v.is_empty_or_undefined_or_null() { - return global_object - .err_http2_invalid_setting_value_range_error( - "Expected initialWindowSize to be a number", - ) - .throw(); - } - } - + bool_setting!("enablePush", enable_push, SETTING_BIT_ENABLE_PUSH); number_setting!( - "maxFrameSize", - max_frame_size, - SETTING_BIT_MAX_FRAME_SIZE, - 16384, - MAX_FRAME_SIZE_F64, - "Expected maxFrameSize to be a number between 16,384 and 2^24-1" + "initialWindowSize", + initial_window_size, + SETTING_BIT_INITIAL_WINDOW_SIZE ); + number_setting!("maxFrameSize", max_frame_size, SETTING_BIT_MAX_FRAME_SIZE); number_setting!( "maxConcurrentStreams", max_concurrent_streams, - SETTING_BIT_MAX_CONCURRENT_STREAMS, - 0, - MAX_HEADER_TABLE_SIZE_F64, - "Expected maxConcurrentStreams to be a number between 0 and 2^32-1" + SETTING_BIT_MAX_CONCURRENT_STREAMS ); number_setting!( "maxHeaderListSize", max_header_list_size, - SETTING_BIT_MAX_HEADER_LIST_SIZE, - 0, - MAX_HEADER_TABLE_SIZE_F64, - "Expected maxHeaderListSize to be a number between 0 and 2^32-1" + SETTING_BIT_MAX_HEADER_LIST_SIZE ); number_setting!( "maxHeaderSize", max_header_list_size, - SETTING_BIT_MAX_HEADER_LIST_SIZE, - 0, - MAX_HEADER_TABLE_SIZE_F64, - "Expected maxHeaderSize to be a number between 0 and 2^32-1" + SETTING_BIT_MAX_HEADER_LIST_SIZE + ); + bool_setting!( + "enableConnectProtocol", + enable_connect_protocol, + SETTING_BIT_ENABLE_CONNECT_PROTOCOL ); - if let Some(ecp) = options.get(global_object, "enableConnectProtocol")? { - if ecp.is_boolean() { - local_settings.enable_connect_protocol = if ecp.as_boolean() { 1 } else { 0 }; - explicit_settings |= SETTING_BIT_ENABLE_CONNECT_PROTOCOL; - } else if !ecp.is_undefined() { - return global_object - .err_http2_invalid_setting_value( - "Expected enableConnectProtocol to be a boolean", - ) - .throw(); - } - } - - // Stage customSettings before committing anything — a later validation throw must not - // leave partial state installed for the next submission. + // Staged, so that a getter that throws below leaves nothing half installed. let mut staged_custom: Vec<(u16, u32)> = Vec::new(); - // Validate customSettings and remember them so they go on the wire with our SETTINGS. - if let Some(custom_settings) = options.get(global_object, "customSettings")? { - if !custom_settings.is_undefined() { - let Some(custom_settings_obj) = custom_settings.get_object() else { - return global_object - .err_http2_invalid_setting_value("Expected customSettings to be an object") - .throw(); - }; - - let mut count: usize = 0; - let iter = bun_jsc::JSPropertyIterator::init( - global_object, - custom_settings_obj, - bun_jsc::JSPropertyIteratorOptions { - skip_empty_name: false, - include_value: true, - ..Default::default() - }, - )?; - - while let Some((prop_name, setting_value)) = iter.next()? { - count += 1; - if count > MAX_CUSTOM_SETTINGS { - return global_object - .err_http2_too_many_custom_settings( - "Number of custom settings exceeds MAX_ADDITIONAL_SETTINGS", - ) - .throw(); - } - - // Validate setting ID (key) is in range [0, 0xFFFF] - let setting_id_str = prop_name.to_utf8(); - // Parse bytes directly (ASCII decimal); do not insert - // UTF-8 validation on external data. - let Some(setting_id) = - bun_core::parse_int::(setting_id_str.slice(), 10).ok() - else { - return global_object - .err_http2_invalid_setting_value_range_error( - "Invalid custom setting identifier", - ) - .throw(); - }; - if setting_id > 0xFFFF { - return global_object - .err_http2_invalid_setting_value_range_error( - "Invalid custom setting identifier", - ) - .throw(); - } - - // 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 { - return global_object - .err_http2_invalid_setting_value_range_error( - "Invalid custom setting value", - ) - .throw(); - } - staged_custom.push((setting_id as u16, value as u32)); - } else { - return global_object - .err_http2_invalid_setting_value_range_error( - "Expected custom setting value to be a number", - ) - .throw(); - } + if let Some(custom_settings) = options.get(global_object, "customSettings")? + && let Some(custom_settings) = custom_settings.get_object() + { + let iter = bun_jsc::JSPropertyIterator::init( + global_object, + custom_settings, + bun_jsc::JSPropertyIteratorOptions { + skip_empty_name: false, + include_value: true, + ..Default::default() + }, + )?; + while let Some((id, value)) = iter.next()? { + if let Ok(id) = bun_core::parse_int::(id.to_utf8().slice(), 10) + && value.is_number() + { + staged_custom.push((id, value.as_number() as u32)); } } } @@ -4561,7 +2872,7 @@ impl H2FrameParser { } } - self.local_settings.set(local_settings); + self.engine.local_settings.set(local_settings); self.explicit_settings.set(explicit_settings); self.custom_settings.with_mut(|cs| { for &(id, value) in &staged_custom { @@ -4583,90 +2894,36 @@ impl H2FrameParser { Ok(()) } - pub(crate) fn settings(&self, cx: &mut JsScope<'_>, settings: JsLocal<'_>) -> JsResult { - let global_object = cx.global(); - let settings = settings.to_js(); - self.load_settings_from_js_value(global_object, settings)?; - - Ok(self.set_settings(self.local_settings.get())) + pub(crate) fn settings(&self, cx: &mut JsScope<'_>, settings: JsLocal<'_>) -> JsResult<()> { + self.load_settings_from_js_value(cx.global(), settings.to_js())?; + self.set_settings(self.engine.local_settings.get()); + Ok(()) } - pub(crate) fn set_local_window_size( - &self, - cx: &mut JsScope<'_>, - window_size: JsLocal<'_>, - ) -> JsResult<()> { - let global_object = cx.global(); - let window_size = window_size.to_js(); - if !window_size.is_number() { - return Err(global_object - .throw_invalid_arguments(format_args!("Expected windowSize to be a number"))); - } - let window_size_value: u32 = window_size.to_u32(); - if self.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 = self.window_size.get(); + pub(crate) fn set_local_window_size(&self, _: &mut JsScope<'_>, window_size_value: u32) { self.window_size.set(window_size_value as u64); - if self.local_settings.get().initial_window_size < window_size_value { - let mut s = self.local_settings.get(); - s.initial_window_size = window_size_value; - self.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; - self.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. - match self.engine.try_borrow_mut() { - Ok(mut guard) => match guard.as_mut() { - Some(engine) => engine.recv_window.grow(increment as i64), - None => { - // The engine is created lazily on the first inbound read; carry the - // growth forward so it applies when that happens. - self.pending_recv_window_growth - .set(self.pending_recv_window_growth.get() + increment as i64); - } - }, - Err(_) => { - // A dispatch is in progress; accumulate the delta for rewrite_read to apply - // when the borrow is released. - self.pending_recv_window_growth - .set(self.pending_recv_window_growth.get() + increment as i64); - } - } - } - 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 stream.used_window_size > window_size_value as u64 { - continue; - } - stream.window_size = window_size_value as u64; + // Only the connection window moves. Stream windows follow SETTINGS_INITIAL_WINDOW_SIZE. + let advertised = self.engine.recv_window.get().size(); + if i64::from(window_size_value) > advertised { + let increment = (i64::from(window_size_value) - advertised) as u32; + update(&self.engine.recv_window, |w| w.grow(i64::from(increment))); + // Write last. A JS transport can answer with DATA from inside self write. + self.engine.send_window_update(self, 0, increment); } - Ok(()) } pub(crate) fn get_frame_counters(&self, cx: &mut JsScope<'_>) -> JSValue { let global_object = cx.global(); - self.sync_engine_frame_counters(); let result = JSValue::create_empty_object(global_object, 2); result.put( global_object, b"framesReceived", - JSValue::js_number(self.engine_frames_received.get() as f64), + JSValue::js_number(self.engine.frames_received.get() as f64), ); result.put( global_object, b"framesSent", - JSValue::js_number( - (self.frames_sent_legacy.get() + self.engine_frames_sent.get()) as f64, - ), + JSValue::js_number(self.frames_sent.get() as f64), ); result } @@ -4682,7 +2939,7 @@ impl H2FrameParser { result.put( global_object, b"effectiveRecvDataLength", - JSValue::js_number((self.window_size.get() - self.used_window_size.get()) as f64), + JSValue::js_number(self.window_size.get() as f64), ); result.put( global_object, @@ -4692,13 +2949,12 @@ impl H2FrameParser { result.put( global_object, b"lastProcStreamID", - JSValue::js_number(self.last_stream_id.get() as f64), + JSValue::js_number(self.engine.last_stream_id.get() as f64), ); - let settings = self.remote_settings.get().unwrap_or_default(); + let settings = self.engine.remote_settings.get(); let remote_iws = settings.initial_window_size; - let local_iws = self.local_settings.get().initial_window_size; - let local_hts = self.local_settings.get().header_table_size; + let local_hts = self.engine.local_settings.get().header_table_size; result.put( global_object, b"remoteWindowSize", @@ -4707,7 +2963,7 @@ impl H2FrameParser { result.put( global_object, b"localWindowSize", - JSValue::js_number(local_iws as f64), + JSValue::js_number(self.engine.recv_window.get().size() as f64), ); result.put( global_object, @@ -4727,160 +2983,61 @@ impl H2FrameParser { result } - pub(crate) fn goaway( + pub(crate) fn goaway<'a>( &self, - cx: &mut JsScope<'_>, - code: JsLocal<'_>, - last_stream_id: JsLocal<'_>, - opaque_data: JsLocal<'_>, - ) -> JsResult<()> { - let global_object = cx.global(); - let code = code.to_js(); - let last_stream_id = last_stream_id.to_js(); - let opaque_data = opaque_data.to_js(); - if !code.is_number() { - return Err(global_object.throw(format_args!("Expected errorCode to be a number"))); - } - let error_code = code.to_int32(); - - if !last_stream_id.is_undefined_or_null() && !last_stream_id.is_number() { - return Err(global_object.throw(format_args!("Expected lastStreamId to be a number"))); - } - let id = if last_stream_id.is_number() { - last_stream_id.to_int32() - } else { - 0 - }; + cx: &mut JsScope<'a>, + code: u32, + last_stream_id: i32, + opaque_data: JsBytes<'a>, + ) { + // Copied, because a write to a JS transport runs JavaScript, which can detach the buffer. + let opaque_data = opaque_data.get(&cx.heap).to_vec(); // As in node, 0 or less keeps the last processed id: Last-Stream-ID=0 tells the peer to retry every stream. - let last_stream_id = if id > 0 { - u32::try_from(id).expect("int cast") - } else { - self.last_peer_stream_id.get() - }; - if !opaque_data.is_undefined_or_null() { - if let Some(array_buffer) = opaque_data.as_array_buffer(global_object) { - // Own the bytes: write() re-enters JS on JS-backed sockets and can detach this. - let copied = array_buffer.byte_slice().to_vec(); - self.send_go_away( - 0, - ErrorCode(error_code as u32), - &copied, - last_stream_id, - false, - ); - return Ok(()); - } - } - - self.send_go_away(0, ErrorCode(error_code as u32), b"", last_stream_id, false); - Ok(()) + let last_stream_id = u32::try_from(last_stream_id) + .ok() + .filter(|id| *id > 0) + .unwrap_or_else(|| self.engine.last_peer_id.get()); + self.send_go_away(ErrorCode(code), &opaque_data, last_stream_id, false); } - pub(crate) fn ping(&self, cx: &mut JsScope<'_>, payload: JsLocal<'_>) -> JsResult { - let global_object = cx.global(); - let payload = payload.to_js(); - // node (Http2Session::AddPing): when the outstanding-ping budget is exhausted, ping() - // returns false and the JS callback is invoked with ERR_HTTP2_PING_CANCEL — it does NOT - // throw. + /// Like node's Http2Session::AddPing, returns false once maxOutstandingPings are unanswered. + pub(crate) fn ping<'a>(&self, cx: &mut JsScope<'a>, payload: JsBytes<'a>) -> bool { if self.out_standing_pings.get() >= self.max_outstanding_pings.get() { - return Ok(false); - } - - if let Some(array_buffer) = payload.as_array_buffer(global_object) { - let slice = array_buffer.slice(); - self.send_ping(false, slice); - return Ok(true); + return false; } - - Err(global_object.throw(format_args!("Expected payload to be a Buffer"))) + let payload = payload.get(&cx.heap).to_vec(); + self.send_ping(&payload); + true } pub(crate) fn origin(&self, cx: &mut JsScope<'_>, origin: JsLocal<'_>) -> JsResult<()> { let global_object = cx.global(); let origin = origin.to_js(); - if origin.is_undefined_or_null() { - // empty origin frame - let mut buffer = [0u8; FrameHeader::BYTE_SIZE]; - let mut stream = FixedBufferStream::new(&mut buffer); - - let frame = FrameHeader { - type_: FrameType::HTTP_FRAME_ORIGIN as u8, - flags: 0, - stream_identifier: 0, - length: 0, - }; - let _ = frame.write(&mut stream, &self.frames_sent_legacy); - let _ = self.write(&buffer); - return Ok(()); - } - // https://github.com/nodejs/node/blob/v26.3.0/src/node_http2.cc#L486-L489 - if origin.is_string() { - let origin_view = origin.to_js_string_view(global_object)?; - let origin_bytes = header_value_bytes(&origin_view); - let slice: &[u8] = &origin_bytes; - if slice.len() + 2 > 16384 { + let mut payload = Vec::new(); + let mut push = |item: JSValue| -> JsResult<()> { + let view = item.to_js_string_view(global_object)?; + let bytes = header_value_bytes(&view); + if payload.len() + 2 + bytes.len() > 16384 { let exception = global_object.to_type_error( bun_jsc::ErrorCode::HTTP2_ORIGIN_LENGTH, format_args!("HTTP/2 ORIGIN frames are limited to 16382 bytes"), ); return Err(global_object.throw_value(exception)); } - - let mut buffer = [0u8; FrameHeader::BYTE_SIZE + 2]; - let mut stream = FixedBufferStream::new(&mut buffer); - - let frame = FrameHeader { - type_: FrameType::HTTP_FRAME_ORIGIN as u8, - flags: 0, - stream_identifier: 0, - length: u32::try_from(slice.len() + 2).expect("int cast"), - }; - let _ = frame.write(&mut stream, &self.frames_sent_legacy); - let _ = stream.write_all(&u16::try_from(slice.len()).expect("int cast").to_be_bytes()); - let _ = self.write(&buffer); - if !slice.is_empty() { - let _ = self.write(slice); - } - } else if origin.is_array() { - let mut buffer = vec![0u8; FrameHeader::BYTE_SIZE + 16384]; - // Heap-allocated to avoid a 16K stack frame. - let mut stream = FixedBufferStream::new(&mut buffer); - stream.seek_to(FrameHeader::BYTE_SIZE); - let mut value_iter = origin.array_iterator(global_object)?; - - while let Some(item) = value_iter.next()? { - if !item.is_string() { - return Err(global_object.throw_invalid_arguments(format_args!( - "Expected origin to be a string or an array of strings" - ))); - } - let origin_view = item.to_js_string_view(global_object)?; - let origin_bytes = header_value_bytes(&origin_view); - let slice = origin_bytes.as_ref(); - let fits = u16::try_from(slice.len()).is_ok_and(|len| { - stream.write_all(&len.to_be_bytes()).is_ok() && stream.write_all(slice).is_ok() - }); - if !fits { - let exception = global_object.to_type_error( - bun_jsc::ErrorCode::HTTP2_ORIGIN_LENGTH, - format_args!("HTTP/2 ORIGIN frames are limited to 16382 bytes"), - ); - return Err(global_object.throw_value(exception)); - } + payload.extend_from_slice(&(bytes.len() as u16).to_be_bytes()); + payload.extend_from_slice(&bytes); + Ok(()) + }; + if origin.is_array() { + let mut origins = origin.array_iterator(global_object)?; + while let Some(item) = origins.next()? { + push(item)?; } - - let total_length: u32 = u32::try_from(stream.pos).expect("int cast"); - let frame = FrameHeader { - type_: FrameType::HTTP_FRAME_ORIGIN as u8, - flags: 0, - stream_identifier: 0, - length: total_length - FrameHeader::BYTE_SIZE as u32, // payload length - }; - stream.reset(); - let _ = frame.write(&mut stream, &self.frames_sent_legacy); - let _ = self.write(&buffer[0..total_length as usize]); + } else if origin.is_string() { + push(origin)?; } + self.write_frame(FrameType::Origin, 0, 0, &[&payload]); Ok(()) } @@ -4889,44 +3046,14 @@ impl H2FrameParser { cx: &mut JsScope<'_>, origin: JsLocal<'_>, value: JsLocal<'_>, - stream_id: JsLocal<'_>, + stream_id: u32, ) -> JsResult<()> { let global_object = cx.global(); - let mut origin_view = None; - let mut value_view = None; - let origin = origin.to_js(); - if !origin.is_undefined_or_null() { - if !origin.is_string() { - return Err( - global_object.throw_invalid_argument_type_value(b"origin", b"origin", origin) - ); - } - origin_view = Some(origin.to_js_string_view(global_object)?); - } - - let value = value.to_js(); - if !value.is_undefined_or_null() { - if !value.is_string() { - return Err( - global_object.throw_invalid_argument_type_value(b"value", b"value", value) - ); - } - value_view = Some(value.to_js_string_view(global_object)?); - } - - let stream_id = stream_id.to_js(); - let stream_id: u32 = if stream_id.is_undefined_or_null() { - 0 - } else if stream_id.is_number() { - stream_id.to_u32() - } else { - return Err(global_object.throw(format_args!("Expected streamId to be a number"))); - }; + let origin_view = origin.to_js().to_js_string_view(global_object)?; + let value_view = value.to_js().to_js_string_view(global_object)?; // https://github.com/nodejs/node/blob/v26.3.0/src/node_http2.cc#L3233-L3239 - let origin_bytes = origin_view.as_ref().map(header_value_bytes); - let value_bytes = value_view.as_ref().map(header_value_bytes); - let origin = origin_bytes.as_deref().unwrap_or_default(); - let value = value_bytes.as_deref().unwrap_or_default(); + let origin = header_value_bytes(&origin_view); + let value = header_value_bytes(&value_view); if origin.len() + value.len() > MAX_ALTSVC_LENGTH { let exception = global_object.to_type_error( bun_jsc::ErrorCode::HTTP2_ALTSVC_LENGTH, @@ -4936,80 +3063,51 @@ impl H2FrameParser { } if stream_id > 0 { // dont error but dont send frame to invalid stream id - if self.streams.get().get(&stream_id).is_none() { + if self.engine.streams.get(stream_id).is_none() { return Ok(()); } } - self.send_alt_svc(stream_id, origin, value); + self.send_alt_svc(stream_id, &origin, &value); Ok(()) } - pub(crate) fn get_end_after_headers( - &self, - cx: &mut JsScope<'_>, - stream_id: JsLocal<'_>, - ) -> JsResult { - let global_object = cx.global(); - let stream_id = stream_id.to_js(); - if !stream_id.is_number() { - return Err(global_object.throw(format_args!("Invalid stream id"))); - } - - let stream_id = stream_id.to_u32(); - if stream_id == 0 { - return Err(global_object.throw(format_args!("Invalid stream id"))); - } - - let Some(stream) = self.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(unsafe { (*stream).end_after_headers }) - } - - pub(crate) fn get_stream_state( - &self, - cx: &mut JsScope<'_>, - stream_id: JsLocal<'_>, - ) -> JsResult { + pub(crate) fn get_stream_state(&self, cx: &mut JsScope<'_>, stream_id: u32) -> JSValue { let global_object = cx.global(); - let stream_id = stream_id.to_js(); - if !stream_id.is_number() { - return Err(global_object.throw(format_args!("Invalid stream id"))); - } - - let stream_id = stream_id.to_u32(); - if stream_id == 0 { - return Err(global_object.throw(format_args!("Invalid stream id"))); - } - - let Some(stream) = self.streams.get().get(&stream_id).copied() else { - return Err(global_object.throw(format_args!("Invalid stream id"))); + // A closed stream keeps its entry until its `Http2Stream` is destroyed. + let Some(stream) = self.engine.streams.get(stream_id) else { + return JSValue::UNDEFINED; }; - // SAFETY: stream is a *mut Stream from self.streams (heap::alloc); valid while the map entry exists - let stream = unsafe { &mut *stream }; + let stream_state = stream.state(); + // A reserved stream only has the half that carries the pushed response. + let local_close = matches!( + stream_state, + State::HalfClosedLocal | State::ReservedRemote | State::Closed + ); + let remote_close = matches!( + stream_state, + State::HalfClosedRemote | State::ReservedLocal | State::Closed + ); let state = JSValue::create_empty_object(global_object, 6); state.put( global_object, b"localWindowSize", - JSValue::js_number(stream.window_size as f64), + JSValue::js_number(stream.recv_window.get().size() as f64), ); state.put( global_object, b"state", - JSValue::js_number(stream.state as u8 as f64), + JSValue::js_number(stream_state as u8 as f64), ); state.put( global_object, b"localClose", - JSValue::js_number(if stream.can_send_data() { 0.0 } else { 1.0 }), + JSValue::js_number(f64::from(u8::from(local_close))), ); state.put( global_object, b"remoteClose", - JSValue::js_number(if stream.can_receive_data() { 0.0 } else { 1.0 }), + JSValue::js_number(f64::from(u8::from(remote_close))), ); // TODO: sumDependencyWeight state.put( @@ -5017,120 +3115,54 @@ 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) + state } - pub(crate) fn rst_stream( - &self, - cx: &mut JsScope<'_>, - stream_id: JsLocal<'_>, - code: JsLocal<'_>, - ) -> JsResult { - let global_object = cx.global(); - bun_output::scoped_log!(H2FrameParser, "rstStream"); - let stream_id = stream_id.to_js(); - let code = code.to_js(); - if !stream_id.is_number() { - return Err(global_object.throw(format_args!("Invalid stream id"))); - } - - let stream_id = stream_id.to_u32(); - if stream_id == 0 || stream_id > MAX_STREAM_ID { - return Err(global_object.throw(format_args!("Invalid stream id"))); - } - - if !code.is_number() { - return Err(global_object.throw(format_args!("Invalid ErrorCode"))); - } - let error_code = code.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 && self.is_server.get() { - 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, - ); - return Ok(JSValue::UNDEFINED); - } + /// An id without an entry is closed already, so there's nothing to reset (RFC 9113 §5.1). + pub(crate) fn rst_stream(&self, _: &mut JsScope<'_>, stream_id: u32, code: u32) { + if let Some(stream) = self.engine.streams.get(stream_id) { + self.reset_stream(&stream, ErrorCode(code), true); } - - let Some(stream) = self.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 - // built here rather than through the engine so this stays callable from inside an - // engine dispatch (the engine cell may already be borrowed). - // - // A NO_ERROR reset for an unknown id is always a no-op: it reaches here from the - // deferred JS close path (rstNextTick after _destroy) once a cleanly-completed - // stream's entry has been evicted. Node sends no RST for cleanly-closed streams; - // writing one makes the peer answer with RST(STREAM_CLOSED) per request. - if error_code != ErrorCode::NO_ERROR.0 { - let mut frame = [0u8; 13]; - frame[2] = 4; // length = 4 - frame[3] = 3; // RST_STREAM - frame[5..9].copy_from_slice(&stream_id.to_be_bytes()); - frame[9..13].copy_from_slice(&error_code.to_be_bytes()); - // Hand-serialized (bypasses FrameHeader::write), so account for - // it the way every other outbound frame site does. - self.frames_sent_legacy - .set(self.frames_sent_legacy.get() + 1); - self.write(&frame); - let _ = self.flush(); - } - return Ok(JSValue::TRUE); - }; - - // SAFETY: stream is a *mut Stream from self.streams; valid while the map entry exists - self.end_stream(unsafe { &mut *stream }, ErrorCode(error_code)); - - Ok(JSValue::TRUE) } -} -// ────────────────────────────────────────────────────────────────────────── -// H2FrameParser impl — JS host fns (part 2) -// ────────────────────────────────────────────────────────────────────────── + /// JS destroyed the `Http2Stream`. Returns true if the stream is closed, so there's nothing left to reset. + pub(crate) fn release_stream(&self, stream_id: u32) -> bool { + self.engine + .streams + .get(stream_id) + .is_none_or(|stream| self.engine.streams.release(&stream)) + } -impl H2FrameParser { - // get memory usage in bytes - fn get_session_memory_usage_bytes(&self) -> usize { - let stream_count = self.streams.get().len(); - self.write_buffer.get().len_u32() as usize - + self.queued_data_size.get() as usize - + stream_count * core::mem::size_of::() + pub(crate) fn is_stream_closed(&self, stream_id: u32) -> bool { + self.engine + .streams + .get(stream_id) + .is_none_or(|stream| stream.is_closed()) } - // node's IsAvailableSessionMemory: byte-exact comparison against the configured MiB budget. - // The previous integer-MiB floor allowed nearly one extra MiB through. - fn is_over_session_memory_limit(&self) -> bool { - self.get_session_memory_usage_bytes() > self.max_session_memory.get() as usize * 1024 * 1024 + pub(crate) fn has_open_streams(&self) -> bool { + self.engine.streams.open_local() + self.engine.streams.open_peer() > 0 } - // get memory in bytes pub(crate) fn buffer_size(&self) -> f64 { - (self.write_buffer.get().len_u32() as u64 + self.queued_data_size.get()) as f64 + self.buffered_output() as f64 + } + + /// 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.engine.send_window.get().available()) + .min(stream.send_window.get().available()) } /// 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( &self, - stream: &mut Stream, + stream: &Stream, payload: &[u8], callback: JSValue, options: SendDataOptions, @@ -5143,7 +3175,7 @@ impl H2FrameParser { bun_output::scoped_log!( H2FrameParser, "HTTP_FRAME_DATA {} sendData({}, {}, {})", - if self.is_server.get() { + if self.engine.is_server.get() { "server" } else { "client" @@ -5157,45 +3189,30 @@ impl H2FrameParser { let mut enqueued = false; let _keepalive = self.keepalive(); - let can_close = close && !stream.wait_for_trailers; + let can_close = close && !stream.wait_for_trailers.get(); 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 { - let mut writer = self.to_writer(); - let _ = data_header.write(&mut writer, &self.frames_sent_legacy); + self.queue_frame(stream, b"", callback, close); + } else if can_close { + // Peers count an empty DATA frame without END_STREAM as a flood, so never send one. + self.write_frame(FrameType::Data, flags::END_STREAM, stream_id, &[]); } } else { let mut offset: usize = 0; 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.state().can_send() { + 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; @@ -5208,18 +3225,15 @@ 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(); enqueued = true; // write the full frame in memory and queue the frame // the callback will only be called after the last frame is sended - stream.queue_frame( - self, + self.queue_frame( + stream, slice, if offset >= payload.len() { callback @@ -5229,7 +3243,7 @@ impl H2FrameParser { offset >= payload.len() && close, ); } else { - let padding = stream.get_padding(size, max_size - 1); + let padding = stream.padding_for(size, max_size - 1); let payload_size = size + if padding != 0 { padding as usize + 1 @@ -5244,32 +3258,23 @@ impl H2FrameParser { max_size, payload_size ); - 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 { - 0 - }; + self.consume_send_windows(stream, payload_size); + let mut flags: u8 = if end_stream { flags::END_STREAM } else { 0 }; if padding != 0 { - flags |= DataFrameFlags::PADDED as u8; + flags |= flags::PADDED; } - let data_header = FrameHeader { - type_: FrameType::HTTP_FRAME_DATA as u8, - flags, - stream_identifier: stream_id, - length: u32::try_from(payload_size).expect("int cast"), - }; if payload.len() <= MAX_PAYLOAD_SIZE_WITHOUT_FRAME { // Single-frame payload: the cork coalesces it with neighbors. - let mut writer = self.to_writer(); - let _ = data_header.write(&mut writer, &self.frames_sent_legacy); if padding != 0 { - let _ = writer.write_padded(slice, padding); + let zeros = &[0u8; 255][..padding as usize]; + self.write_frame( + FrameType::Data, + flags, + stream_id, + &[&[padding], slice, zeros], + ); } else { - let _ = writer.write_all(slice); + self.write_frame(FrameType::Data, flags, stream_id, &[slice]); } } else { // Multi-frame payload: accumulate header + slice in the batch so @@ -5289,7 +3294,7 @@ impl H2FrameParser { } else { payload.len() + (payload.len() / MAX_PAYLOAD_SIZE_WITHOUT_FRAME + 2) - * FrameHeader::BYTE_SIZE + * FRAME_HEADER_SIZE }); self.drain_cork_into(batch); if vectorize && !batch.is_empty() { @@ -5302,7 +3307,11 @@ impl H2FrameParser { } } let header_off = batch.len(); - let _ = data_header.write(batch, &self.frames_sent_legacy); + self.frames_sent.set(self.frames_sent.get() + 1); + batch.extend_from_slice( + &FrameHeader::new(FrameType::Data, flags, stream_id, payload_size) + .to_bytes(), + ); if padding != 0 { batch.push(padding); batch.extend_from_slice(slice); @@ -5345,59 +3354,33 @@ impl H2FrameParser { self.dispatch_write_callback(callback); } if close { - if stream.wait_for_trailers { - self.dispatch(js::on_want_trailers_get_cached, stream.get_identifier()); + if stream.wait_for_trailers.get() { + self.dispatch(js::on_want_trailers_get_cached, stream.context()); } else { - let identifier = stream.get_identifier(); - identifier.ensure_still_alive(); - if stream.state == StreamState::HALF_CLOSED_REMOTE { - stream.state = StreamState::CLOSED; - stream.free_resources::(self); - } else { - stream.state = StreamState::HALF_CLOSED_LOCAL; - } - settled_state = stream.state as u8; - if !(suppress_half_closed_local_dispatch - && stream.state == StreamState::HALF_CLOSED_LOCAL) - { - self.dispatch_with_extra( - js::on_stream_end_get_cached, - identifier, - JSValue::js_number(stream.state as u8 as f64), - ); - } + settled_state = + self.sent_end_stream(stream, suppress_half_closed_local_dispatch) as u8; } } } (settled_state, callback_deferred) } - fn stream_from_js( - &self, - global_object: &JSGlobalObject, - stream_id: JSValue, - ) -> JsResult<*mut Stream> { - if !stream_id.is_number() { - return Err(global_object.throw(format_args!("Expected stream to be a number"))); - } - - let stream_id = stream_id.to_u32(); - if stream_id == 0 || stream_id > MAX_STREAM_ID { - return Err(global_object.throw(format_args!("Invalid stream id"))); + pub(crate) fn no_trailers(&self, _: &mut JsScope<'_>, stream_id: u32) { + // JS learns about a native reset on the next tick and can still end the stream until then. + if let Some(stream) = self.open_stream(stream_id) { + self.send_no_trailers(&stream); } - - let Some(stream) = self.streams.get().get(&stream_id).copied() else { - return Err(global_object.throw(format_args!("Invalid stream id"))); - }; - Ok(stream) } - pub(crate) fn no_trailers(&self, cx: &mut JsScope<'_>, stream_id: JsLocal<'_>) -> JsResult<()> { - let stream = self.stream_from_js(cx.global(), stream_id.to_js())?; - // SAFETY: stream is a *mut Stream from self.streams (heap::alloc); valid while the map entry exists - let stream = unsafe { &mut *stream }; + fn open_stream(&self, stream_id: u32) -> Option> { + self.engine + .streams + .get(stream_id) + .filter(|stream| !stream.is_closed()) + } - stream.wait_for_trailers = false; + fn send_no_trailers(&self, stream: &Stream) { + stream.wait_for_trailers.set(false); let _ = self.send_data( stream, b"", @@ -5408,463 +3391,168 @@ impl H2FrameParser { defer_write_callback: false, }, ); - Ok(()) - } - - /// node's strictSingleValueFields option (default true): when disabled, duplicate - /// single-value headers are encoded as-is instead of being rejected. - fn single_value_index_checked(&self, name: &[u8]) -> Option { - if !self.strict_single_value_fields.get() { - return None; - } - single_value_headers_index_of(name) } /// setStreamReading(streamId, reading): the JS readable for the stream paused (false) or /// resumed (true). While paused the engine stops replenishing the stream's receive window; /// on resume the deferred replenishment is sent immediately so a peer stalled on a zero /// window is released without waiting for further inbound traffic. - pub(crate) fn set_stream_reading( - &self, - _: &mut JsScope<'_>, - stream_id: JsLocal<'_>, - reading: JsLocal<'_>, - ) { - let stream_id = stream_id.to_js(); - let reading = reading.to_js(); - if !stream_id.is_number() { - return; - } - let stream_id = stream_id.to_u32(); - let reading = reading.to_boolean(); - let Some(stream) = self.streams.get().get(&stream_id).copied() else { + pub(crate) fn set_stream_reading(&self, _: &mut JsScope<'_>, stream_id: u32, reading: bool) { + let Some(stream) = self.engine.streams.get(stream_id) else { // The stream already finished (or never reached the wire); nothing to backpressure. return; }; - // SAFETY: stream is a *mut Stream from self.streams (heap::alloc); valid while the map entry exists - unsafe { (*stream).reading_paused = !reading }; + stream.reading_paused.set(!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) = self.engine.try_borrow_mut() { - if let Some(engine) = guard.as_mut() { - engine.replenish_stream(self, stream_id); - } + // A batch replenishes at its end. Per frame it would hide a peer that overruns the window. + if self.engine.streams.is_reading() { + self.engine.queue_replenish(&stream); + } else { + self.engine.replenish_stream(self, &stream); } let _ = self.flush(); } } - /// validate header name and convert to lowecase if needed - fn to_valid_header_name<'a>(in_: &'a [u8], out: &'a mut [u8]) -> crate::Result<&'a [u8]> { - if in_.len() > 4096 { - return Err(crate::Error::InvalidHeaderName); - } - debug_assert!(out.len() >= in_.len()); - let mut in_slice = in_; - let mut out_slice = &mut out[..]; - let mut any = false; - // lets validate and convert to lowercase in one pass - 'begin: loop { - for (i, &c) in in_slice.iter().enumerate() { - match c { - b'A'..=b'Z' => { - out_slice[..i].copy_from_slice(&in_slice[0..i]); - out_slice[i] = c.to_ascii_lowercase(); - let end = i + 1; - in_slice = &in_slice[end..]; - out_slice = &mut out_slice[end..]; - any = true; - continue 'begin; - } - b'a'..=b'z' - | b'0'..=b'9' - | b'!' - | b'#' - | b'$' - | b'%' - | b'&' - | b'\'' - | b'*' - | b'+' - | b'-' - | b'.' - | b'^' - | b'_' - | b'`' - | b'|' - | b'~' => {} - b':' => { - // only allow pseudoheaders at the beginning - if i != 0 || any { - return Err(crate::Error::InvalidHeaderName); - } - continue; - } - _ => return Err(crate::Error::InvalidHeaderName), - } - } - - if any { - out_slice[..in_slice.len()].copy_from_slice(in_slice); + /// Writes `block` as one HEADERS or PUSH_PROMISE frame plus the CONTINUATION frames it needs (RFC 9113 §4.3). `prefix` is a PUSH_PROMISE's promised stream id. + fn write_header_block( + &self, + frame_type: FrameType, + frame_flags: u8, + stream_id: u32, + prefix: &[u8], + block: &[u8], + ) { + let max_frame_size = self.engine.remote_settings.get().max_frame_size as usize; + let (first, mut rest) = block.split_at(block.len().min(max_frame_size - prefix.len())); + let end_headers = |rest: &[u8]| { + if rest.is_empty() { + flags::END_HEADERS + } else { + 0 } - break 'begin; + }; + self.write_frame( + frame_type, + frame_flags | end_headers(rest), + stream_id, + &[prefix, first], + ); + while !rest.is_empty() { + let (fragment, after) = rest.split_at(rest.len().min(max_frame_size)); + rest = after; + self.write_frame( + FrameType::Continuation, + end_headers(rest), + stream_id, + &[fragment], + ); } - - Ok(if any { &out[0..in_.len()] } else { in_ }) } - /// Property lookups by name go through `getIfPropertyExistsImpl`, which must - /// not be handed an integer-index-like name (debug assert). Header names that - /// are all ASCII digits are valid HTTP tokens but can never be marked - /// sensitive, so callers skip the lookup for them. - fn is_index_like_name(name: &[u8]) -> bool { - if name.is_empty() { - return false; - } - for &c in name { - if !c.is_ascii_digit() { - return false; - } - } - true + /// Throws what `request()` or `pushPromise()` would throw for the names of `headers`, without running a value's toString(). + pub(crate) fn validate_request( + &self, + cx: &mut JsScope<'_>, + headers: JsLocal<'_>, + ) -> JsResult<()> { + collect_fields( + cx.global(), + headers.to_js(), + JSValue::UNDEFINED, + BlockRole::Request, + self.strict_single_value_fields.get(), + None, + ) } pub(crate) fn send_trailers( &self, cx: &mut JsScope<'_>, - stream_id: JsLocal<'_>, + stream_id: u32, headers: JsLocal<'_>, sensitive_headers: JsLocal<'_>, ) -> JsResult<()> { let global_object = cx.global(); let headers = headers.to_js(); let sensitive_headers = sensitive_headers.to_js(); - let stream_ptr = self.stream_from_js(global_object, stream_id.to_js())?; - // The header/sensitive-object getters and value coercions below can run user JS - // while `stream` is borrowed. - let mut stream = self.enter_stream_dispatch(stream_ptr); - - let Some(headers_obj) = headers.get_object() else { - return Err(global_object.throw(format_args!("Expected headers to be an object"))); + let Some(stream) = self.open_stream(stream_id) else { + return Ok(()); }; - if !sensitive_headers.is_object() { - return Err( - 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"))); - } - // max header name length for lshpack - let mut name_buffer = [0u8; 4096]; - - let iter = bun_jsc::JSPropertyIterator::init( + let mut scratch = HeaderScratch::take(global_object); + collect_fields( global_object, - headers_obj, - bun_jsc::JSPropertyIteratorOptions { - skip_empty_name: false, - include_value: true, - ..Default::default() - }, + headers, + sensitive_headers, + BlockRole::Trailers, + self.strict_single_value_fields.get(), + Some(&mut scratch.block), )?; - 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 { - continue; - } - - let name_slice = header_name.to_utf8(); - let name = name_slice.slice(); - - if name.first() == Some(&b':') { - let exception = global_object.to_type_error( - bun_jsc::ErrorCode::HTTP2_INVALID_PSEUDOHEADER, - format_args!( - "\"{}\" is an invalid pseudoheader or is used incorrectly", - BStr::new(name) - ), - ); - 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(_) => { - let exception = global_object.to_type_error( - bun_jsc::ErrorCode::INVALID_HTTP_TOKEN, - format_args!( - "The arguments Header name is invalid. Received {}", - BStr::new(name) - ), - ); - return Err(global_object.throw_value(exception)); - } - }; - - // `false`: it could not be encoded, and the stream is ended. - let mut handle_encode = |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, - format_args!("Invalid value for header \"{}\"", BStr::new(validated_name)), - ); - return Err(global_object.throw_value(exception)); - } - bun_output::scoped_log!( - H2FrameParser, - "encode header {} {}", - BStr::new(validated_name), - BStr::new(value) - ); - match self.encode_header_into_list( - &mut encoded_headers, - validated_name, - value, - never_index, - ) { - Ok(_) => Ok(true), - 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(); - self.dispatch_with_2_extra( - js::on_frame_error_get_cached, - 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; - self.end_stream(&mut stream, ErrorCode::FRAME_SIZE_ERROR); - self.send_go_away( - triggering_id, - ErrorCode::NO_ERROR, - b"", - self.last_stream_id.get(), - true, - ); - Ok(false) - } - } - }; - - if js_value.js_type().is_array() { - let mut value_iter = js_value.array_iterator(global_object)?; - - if let Some(idx) = self.single_value_index_checked(validated_name) { - if value_iter.len > 1 || single_value_headers[idx] { - let exception = global_object.to_type_error( - bun_jsc::ErrorCode::HTTP2_HEADER_SINGLE_VALUE, - format_args!( - "Header field \"{}\" must only have a single value", - BStr::new(validated_name) - ), - ); - return Err(global_object.throw_value(exception)); - } - single_value_headers[idx] = true; - } - - 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 - // trip a debug assert in getIfPropertyExistsImpl) and can never be sensitive. - let never_index = if Self::is_index_like_name(validated_name) { - false - } else { - match sensitive_headers.get_truthy(global_object, validated_name)? { - Some(_) => true, - None => sensitive_headers.get_truthy(global_object, name)?.is_some(), - } - }; - - let value_bytes = header_value_bytes(&value_view); - let value = value_bytes.as_ref(); - - if !handle_encode(value, never_index)? { - return Ok(()); - } - } - } else { - if let Some(idx) = self.single_value_index_checked(validated_name) { - if single_value_headers[idx] { - let exception = global_object.to_type_error( - bun_jsc::ErrorCode::HTTP2_HEADER_SINGLE_VALUE, - format_args!( - "Header field \"{}\" must only have a single value", - BStr::new(validated_name) - ), - ); - return Err(global_object.throw_value(exception)); - } - single_value_headers[idx] = true; - } - let value_view = js_value.to_js_string_view(global_object)?; - - // 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) { - false - } else { - match sensitive_headers.get_truthy(global_object, validated_name)? { - Some(_) => true, - None => sensitive_headers.get_truthy(global_object, name)?.is_some(), - } - }; - - 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) - ); - - if !handle_encode(value, never_index)? { - return Ok(()); - } - } + // node submits an empty END_STREAM DATA frame when the trailers have no fields. + if scratch.block.is_empty() { + self.send_no_trailers(&stream); + return Ok(()); } - let encoded_data = encoded_headers.as_slice(); - let encoded_size = encoded_data.len(); - - // RFC 7540 Section 8.1: Trailers are sent as a HEADERS frame with END_STREAM flag - let base_flags: u8 = HeadersFrameFlags::END_STREAM as u8; - // RFC 7540 Section 4.2: SETTINGS_MAX_FRAME_SIZE determines max frame payload - let actual_max_frame_size = self - .remote_settings - .get() - .unwrap_or_else(|| self.local_settings.get()) - .max_frame_size as usize; - bun_output::scoped_log!(H2FrameParser, "trailers encoded_size {}", encoded_size); - - let mut writer = self.to_writer(); - - if encoded_size <= actual_max_frame_size { - // Single HEADERS frame - header block fits in one frame - let frame = FrameHeader { - type_: FrameType::HTTP_FRAME_HEADERS as u8, - flags: base_flags | HeadersFrameFlags::END_HEADERS as u8, - stream_identifier: stream.id, - length: u32::try_from(encoded_size).expect("int cast"), - }; - let _ = frame.write(&mut writer, &self.frames_sent_legacy); - let _ = writer.write_all(encoded_data); + let encoded = if self.is_over_send_limit(&scratch.block) { + Err(crate::Error::UnableToEncode) } else { - bun_output::scoped_log!( - H2FrameParser, - "Using CONTINUATION frames for trailers: encoded_size={} max_frame_size={}", - encoded_size, - actual_max_frame_size - ); - - let first_chunk_size = actual_max_frame_size; - - let headers_frame = FrameHeader { - type_: FrameType::HTTP_FRAME_HEADERS as u8, - flags: base_flags, // END_STREAM but NOT END_HEADERS - stream_identifier: stream.id, - length: u32::try_from(first_chunk_size).expect("int cast"), - }; - let _ = headers_frame.write(&mut writer, &self.frames_sent_legacy); - let _ = writer.write_all(&encoded_data[0..first_chunk_size]); - - let mut offset: usize = first_chunk_size; - while offset < encoded_size { - let remaining = encoded_size - offset; - let chunk_size = remaining.min(actual_max_frame_size); - let is_last = offset + chunk_size >= encoded_size; - - let cont_frame = FrameHeader { - type_: FrameType::HTTP_FRAME_CONTINUATION as u8, - flags: if is_last { - HeadersFrameFlags::END_HEADERS as u8 - } else { - 0 - }, - stream_identifier: stream.id, - length: u32::try_from(chunk_size).expect("int cast"), - }; - let _ = cont_frame.write(&mut writer, &self.frames_sent_legacy); - let _ = writer.write_all(&encoded_data[offset..offset + chunk_size]); - - offset += chunk_size; + self.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). + self.dispatch_with_2_extra( + js::on_frame_error_get_cached, + stream.context(), + JSValue::js_number(FrameType::Headers as u8 as f64), + JSValue::js_number(ErrorCode::FRAME_SIZE_ERROR.0 as f64), + ); + self.reset_stream(&stream, ErrorCode::FRAME_SIZE_ERROR, true); + return Ok(()); } } - let identifier = stream.get_identifier(); - identifier.ensure_still_alive(); - if stream.state == StreamState::HALF_CLOSED_REMOTE { - stream.state = StreamState::CLOSED; - stream.free_resources::(self); - } else { - stream.state = StreamState::HALF_CLOSED_LOCAL; - } - self.dispatch_with_extra( - js::on_stream_end_get_cached, - identifier, - JSValue::js_number(stream.state as u8 as f64), + self.write_header_block( + FrameType::Headers, + flags::END_STREAM, + stream.id, + b"", + &scratch.encoded, ); + self.sent_end_stream(&stream, false); Ok(()) } pub(crate) fn write_stream( &self, cx: &mut JsScope<'_>, - stream_id: JsLocal<'_>, + stream_id: u32, data: JsLocal<'_>, encoding: JsLocal<'_>, - close: JsLocal<'_>, + close: bool, callback: JsLocal<'_>, - defer_callback: JsLocal<'_>, + defer_write_callback: bool, ) -> JsResult { let global_object = cx.global(); let data = data.to_js(); let encoding = encoding.to_js(); - let close = close.to_js().to_boolean(); let callback = callback.to_js(); - let defer_callback = defer_callback.to_js(); - let stream_ptr = self.stream_from_js(global_object, stream_id.to_js())?; - // Coercing `data` (a String subclass's toString) can run user JS while `stream` - // is borrowed. - let mut stream = self.enter_stream_dispatch(stream_ptr); - if !stream.can_send_data() { + let Some(stream) = self + .engine + .streams + .get(stream_id) + .filter(|stream| stream.state().can_send()) + else { self.dispatch_write_callback(callback); return Ok(JSValue::FALSE); - } + }; let encoding: Encoding = 'brk: { if encoding.is_undefined() { @@ -5902,13 +3590,13 @@ impl H2FrameParser { let payload = self.stable_payload(buffer.slice()); let (settled_state, callback_deferred) = self.send_data( - &mut stream, + &stream, &payload, callback, SendDataOptions { close, suppress_half_closed_local_dispatch: true, - defer_write_callback: defer_callback.to_boolean(), + defer_write_callback, }, ); @@ -5927,8 +3615,8 @@ impl H2FrameParser { /// `set_next_stream_id` can park `last_stream_id` anywhere in the u32 range, so the step /// saturates; callers that open the stream reject anything above `MAX_STREAM_ID`. fn get_next_stream_id(&self) -> u32 { - let stream_id = self.last_stream_id.get(); - if self.is_server.get() { + let stream_id = self.engine.last_stream_id.get(); + if self.engine.is_server.get() { if stream_id.is_multiple_of(2) { stream_id.saturating_add(2) } else { @@ -5941,21 +3629,9 @@ impl H2FrameParser { } } - pub(crate) fn set_next_stream_id( - &self, - cx: &mut JsScope<'_>, - stream_id: JsLocal<'_>, - ) -> JsResult<()> { - let global_object = cx.global(); - let stream_id = stream_id.to_js(); - if !stream_id.is_number() { - return Err(global_object.throw(format_args!("Expected stream_id to be a number"))); - } - // Store the id `get_next_stream_id` steps from. A fractional id passes the JS layer's - // `id <= 0` check and truncates to 0 here; 0 (and 1 on a client) has no predecessor, - // so the subtraction saturates to the initial state instead of wrapping. - let next_stream_id = stream_id.to_u32(); - let last_stream_id = if self.is_server.get() { + pub(crate) fn set_next_stream_id(&self, _: &mut JsScope<'_>, next_stream_id: u32) { + // Store the id `get_next_stream_id` steps from. 0, and 1 on a client, have no predecessor, so the subtraction saturates. + let last_stream_id = if self.engine.is_server.get() { if next_stream_id.is_multiple_of(2) { next_stream_id.saturating_sub(2) } else { @@ -5966,8 +3642,7 @@ impl H2FrameParser { } else { next_stream_id.saturating_sub(2) }; - self.last_stream_id.set(last_stream_id); - Ok(()) + self.engine.last_stream_id.set(last_stream_id); } pub(crate) fn get_next_stream(&self, _: &mut JsScope<'_>) -> i32 { @@ -5975,9 +3650,16 @@ impl H2FrameParser { if id > MAX_STREAM_ID { return -1; } - if self.handle_received_stream_id(id).is_none() { - return -1; + // RFC 9113 §5.1.2. Like node, a server's pushes aren't held to the client's limit. + if !self.engine.is_server.get() + && self.engine.streams.open_local() + >= self.engine.remote_settings.get().max_concurrent_streams + { + return 0; } + self.engine.last_stream_id.set(id); + let stream = self.engine.insert_stream(id, State::Idle); + self.announce_stream(&stream); id as i32 } @@ -5987,1273 +3669,290 @@ impl H2FrameParser { pub(crate) fn push_promise( &self, cx: &mut JsScope<'_>, - parent_id: JsLocal<'_>, - promised_id: JsLocal<'_>, + parent_id: u32, + promised_id: u32, headers: JsLocal<'_>, sensitive_headers: JsLocal<'_>, ) -> JsResult { let global_object = cx.global(); - if !self.is_server.get() { - return Err( - global_object.throw(format_args!("Push streams can only be created by servers")) - ); - } - let parent_id = parent_id.to_js(); - let promised_id = promised_id.to_js(); let headers = headers.to_js(); let sensitive_headers = sensitive_headers.to_js(); - if !parent_id.is_number() { - return Err(global_object.throw(format_args!("Expected parentId to be a number"))); - } - if !promised_id.is_number() { - return Err(global_object.throw(format_args!("Expected promisedId to be a number"))); - } - let parent_id = parent_id.to_u32(); - let promised_id = promised_id.to_u32(); - if promised_id > MAX_STREAM_ID { - return Ok(JSValue::js_number(-1.0)); - } - if parent_id == 0 || parent_id > MAX_STREAM_ID || promised_id == 0 { - return Err(global_object.throw(format_args!("Invalid stream id"))); - } - let Some(headers_obj) = headers.get_object() else { - return Err(global_object.throw(format_args!("Expected headers to be an object"))); - }; - - let mut name_buffer = [0u8; 4096]; - let mut encoded_headers: Vec = Vec::new(); - let mut single_value_headers = [false; SINGLE_VALUE_HEADERS_LEN]; + let mut scratch = HeaderScratch::take(global_object); + collect_fields( + global_object, + headers, + sensitive_headers, + BlockRole::Request, + self.strict_single_value_fields.get(), + Some(&mut scratch.block), + )?; - // A PUSH_PROMISE carries a REQUEST, so request pseudo-headers are valid even on the server. - // Pseudo-headers must be encoded first, so iterate twice. - for ignore_pseudo_headers in 0..2usize { - let iter = bun_jsc::JSPropertyIterator::init( - global_object, - headers_obj, - bun_jsc::JSPropertyIteratorOptions { - skip_empty_name: false, - include_value: true, - ..Default::default() - }, - )?; - while let Some((header_name, js_value)) = iter.next()? { - if header_name.length() == 0 { - continue; - } - let name_slice = header_name.to_utf8(); - let name = name_slice.slice(); - let validated_name = match Self::to_valid_header_name(name, &mut name_buffer[..]) { - Ok(n) => n, - Err(_) => { - let exception = global_object.to_type_error( - bun_jsc::ErrorCode::INVALID_HTTP_TOKEN, - format_args!( - "The arguments Header name is invalid. Received \"{}\"", - BStr::new(name) - ), - ); - return Err(global_object.throw_value(exception)); - } - }; - if name.first() == Some(&b':') { - if ignore_pseudo_headers == 1 { - continue; - } - if !is_valid_request_pseudo_header(validated_name) { - return Err(global_object - .err( - JscErrorCode::HTTP2_INVALID_PSEUDOHEADER, - format_args!( - "\"{}\" is an invalid pseudoheader or is used incorrectly", - BStr::new(name) - ), - ) - .throw()); - } - } 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) { - false - } else { - match sensitive_headers.get_truthy(global_object, validated_name)? { - Some(_) => true, - None => sensitive_headers.get_truthy(global_object, name)?.is_some(), - } - }; - let mut encode_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 - .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 self - .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. - self.schedule_header_compression_session_error(); - return Ok(Some(JSValue::js_number(-1.0))); - } - Ok(None) - }; - if js_value.js_type().is_array() { - let mut value_iter = js_value.array_iterator(global_object)?; - if let Some(idx) = self.single_value_index_checked(validated_name) { - if value_iter.len > 1 || single_value_headers[idx] { - return Err(global_object - .err( - JscErrorCode::HTTP2_HEADER_SINGLE_VALUE, - format_args!( - "Header field \"{}\" must only have a single value", - BStr::new(validated_name) - ), - ) - .throw()); - } - 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); - } - } - } else { - if let Some(idx) = self.single_value_index_checked(validated_name) { - if single_value_headers[idx] { - return Err(global_object - .err( - JscErrorCode::HTTP2_HEADER_SINGLE_VALUE, - format_args!( - "Header field \"{}\" must only have a single value", - BStr::new(validated_name) - ), - ) - .throw()); - } - single_value_headers[idx] = true; - } - if let Some(ret) = encode_value(js_value)? { - return Ok(ret); - } - } + match self.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"))); } - } - - let max_frame = - self.remote_settings - .get() - .map(|s| s.max_frame_size) - .unwrap_or_else(|| self.local_settings.get().max_frame_size) as usize; - let payload_size = 4 + encoded_headers.len(); - if payload_size <= max_frame { - // PUSH_PROMISE frame: 9-byte header + 4-byte promised stream id + the header block. - let mut hdr_buf = [0u8; FrameHeader::BYTE_SIZE + 4]; - let mut ws = FixedBufferStream::new(&mut hdr_buf); - let frame = FrameHeader { - type_: 0x05, - flags: 0x04, // END_HEADERS - stream_identifier: parent_id, - length: payload_size as u32, - }; - let _ = frame.write(&mut ws, &self.frames_sent_legacy); - let promised_be = (promised_id & 0x7fff_ffff).swap_bytes(); - let _ = ws.write_all(&promised_be.to_ne_bytes()); - let _ = self.write(&hdr_buf); - let _ = self.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 - // parent stream carry the rest; the last one sets END_HEADERS. Mirrors the - // send_trailers()/request() splitting. - let first_chunk = max_frame - 4; - let mut hdr_buf = [0u8; FrameHeader::BYTE_SIZE + 4]; - let mut ws = FixedBufferStream::new(&mut hdr_buf); - let frame = FrameHeader { - type_: 0x05, - flags: 0, // continued below - stream_identifier: parent_id, - length: max_frame as u32, - }; - let _ = frame.write(&mut ws, &self.frames_sent_legacy); - let promised_be = (promised_id & 0x7fff_ffff).swap_bytes(); - let _ = ws.write_all(&promised_be.to_ne_bytes()); - let _ = self.write(&hdr_buf); - let _ = self.write(&encoded_headers[..first_chunk]); - - let mut offset = first_chunk; - while offset < encoded_headers.len() { - let chunk = (encoded_headers.len() - offset).min(max_frame); - let is_last = offset + chunk >= encoded_headers.len(); - let mut cont_buf = [0u8; FrameHeader::BYTE_SIZE]; - let mut cs = FixedBufferStream::new(&mut cont_buf); - let cont = FrameHeader { - type_: FrameType::HTTP_FRAME_CONTINUATION as u8, - flags: if is_last { 0x04 } else { 0 }, // END_HEADERS on the final fragment - stream_identifier: parent_id, - length: chunk as u32, - }; - let _ = cont.write(&mut cs, &self.frames_sent_legacy); - let _ = self.write(&cont_buf); - let _ = self.write(&encoded_headers[offset..offset + chunk]); - offset += chunk; + Err(_) => { + // node never surfaces self through the pushStream callback. + self.schedule_header_compression_session_error(); + return Ok(JSValue::js_number(-1.0)); } } + if let Some(promised) = self.engine.streams.get(promised_id) { + let _ = self + .engine + .streams + .apply(&promised, StreamEvent::SendPushPromise); + } + self.write_header_block( + FrameType::PushPromise, + 0, + parent_id, + &(promised_id & MAX_STREAM_ID).to_be_bytes(), + &scratch.encoded, + ); let _ = self.flush(); Ok(JSValue::js_number(promised_id as f64)) } - pub(crate) fn get_stream_context( - &self, - cx: &mut JsScope<'_>, - stream_id: JsLocal<'_>, - ) -> JsResult { - let global_object = cx.global(); - let stream_id = stream_id.to_js(); - if !stream_id.is_number() { - return Err(global_object.throw(format_args!("Expected stream_id to be a number"))); - } - - let Some(stream) = self.streams.get().get(&stream_id.to_u32()).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(unsafe { (*stream).js_context.get() }.unwrap_or(JSValue::UNDEFINED)) + pub(crate) fn get_stream_context(&self, _: &mut JsScope<'_>, stream_id: u32) -> JSValue { + self.engine + .streams + .get(stream_id) + .and_then(|stream| stream.js_context.get().get()) + .unwrap_or(JSValue::UNDEFINED) } + /// `undefined` closes a pushed stream whose PUSH_PROMISE never left. It was never on the wire, so no reset is sent. pub(crate) fn set_stream_context( &self, cx: &mut JsScope<'_>, - stream_id: JsLocal<'_>, + stream_id: u32, context: JsLocal<'_>, - ) -> JsResult<()> { - let global_object = cx.global(); - let stream_id = stream_id.to_js(); + ) { + let Some(stream) = self.engine.streams.get(stream_id) else { + return; + }; let context = context.to_js(); - if !stream_id.is_number() { - return Err(global_object.throw(format_args!("Expected stream_id to be a number"))); - } - let stream_id = stream_id.to_u32(); - if context.is_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. - self.sctx.with_mut(|m| { - m.remove(&stream_id); - }); - return Ok(()); - } - if !context.is_object() { - return Err(global_object.throw(format_args!("Expected context to be an object"))); - } - - // Rewrite engine: record the JS stream context for the engine's Sink callbacks. Dropping a - // previous entry releases its root (StrongOptional: Drop -> destroy). - self.sctx.with_mut(|m| { - m.insert(stream_id, StrongOptional::create(context, global_object)); - }); - - // Legacy path: also set on the legacy stream if it still exists (best-effort). - 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).set_context(context, global_object) }; + if context.is_object() { + stream.set_context(context, cx.global()); + } else { + self.engine.streams.release(&stream); + self.engine.streams.close(&stream); } - Ok(()) } + /// Calls `callback(stream, isClosed)` for the closed streams, or for those whose close JS hasn't handled yet. One in its close handler is both. pub(crate) fn for_each_stream( &self, cx: &mut JsScope<'_>, - callback: JsLocal<'_>, - this_arg: JsLocal<'_>, + callback: JsLocal<'_, Callable>, + closed: bool, ) { let global_object = cx.global(); let callback = callback.to_js(); - if !callback.is_callable() { - return; - } - let this_arg = this_arg.to_js(); - let mut it = StreamResumableIterator::init(self); - while let Some(stream) = it.next() { - // SAFETY: stream is *mut Stream from self.streams; valid while the map entry exists - let Some(value) = (unsafe { (*stream).js_context.get() }) else { + for stream in self.engine.streams.snapshot() { + let wanted = if closed { + stream.is_closed() + } else { + !stream.close_delivered.get() + }; + if !wanted { + continue; + } + let Some(value) = stream.js_context.get().get() else { continue; }; - self.handlers.get().vm.event_loop_mut().run_callback( + self.handlers.vm.event_loop_mut().run_callback( bun_event_loop::ContextId::NONE, callback, global_object, - this_arg, - &[value], - ); - } - } - - pub(crate) fn emit_abort_to_all_streams(&self, _: &mut JsScope<'_>) { - // R-2: StreamResumableIterator stores a `ParentRef`; `streams` is `JsCell`-backed, - // so the loop body can keep using `self` directly. - let mut it = StreamResumableIterator::init(self); - 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 `self`, 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 self.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::(self); - self.dispatch_with_2_extra( - js::on_aborted_get_cached, - identifier, - JSValue::UNDEFINED, - JSValue::js_number(old_state as u8 as f64), - ); - } - } - } - - pub(crate) fn emit_error_to_all_streams( - &self, - cx: &mut JsScope<'_>, - code: JsLocal<'_>, - ) -> JsResult<()> { - let global_object = cx.global(); - let code = code.to_js(); - // Like `goaway`: only numbers reach `to_u32` (it requires one), and the code is read - // once before any `&mut Stream` exists instead of once per stream inside the loop. - if !code.is_number() { - return Err(global_object.throw(format_args!("Expected errorCode to be a number"))); - } - let rst_code = code.to_u32(); - - // R-2: StreamResumableIterator stores a `ParentRef`; `streams` is `JsCell`-backed, - // so the loop body can keep using `self` directly. - let mut it = StreamResumableIterator::init(self); - 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 `self`, so no aliasing. - let stream = unsafe { &mut *stream_ptr }; - if stream.state != StreamState::CLOSED { - stream.state = StreamState::CLOSED; - stream.rst_code = rst_code; - let identifier = stream.get_identifier(); - identifier.ensure_still_alive(); - stream.free_resources::(self); - self.dispatch_with_extra(js::on_stream_error_get_cached, identifier, code); - } - } - Ok(()) - } - - pub(crate) fn flush_from_js(&self, _: &mut JsScope<'_>) -> usize { - self.flush() - } - - pub(crate) fn request( - &self, - cx: &mut JsScope<'_>, - stream_id: JsLocal<'_>, - context: JsLocal<'_>, - headers: JsLocal<'_>, - sensitive_headers: JsLocal<'_>, - options: JsLocal<'_>, - ) -> JsResult { - let global_object = cx.global(); - bun_output::scoped_log!(H2FrameParser, "request"); - - let stream_id = stream_id.to_js(); - let context = context.to_js(); - let headers = headers.to_js(); - let sensitive_headers = sensitive_headers.to_js(); - let options = options.to_js(); - let Some(headers_obj) = headers.get_object() else { - return Err(global_object.throw(format_args!("Expected headers to be an object"))); - }; - - if !sensitive_headers.is_object() { - return Err( - global_object.throw(format_args!("Expected sensitiveHeaders to be an object")) + JSValue::UNDEFINED, + &[value, JSValue::from(stream.is_closed())], ); } - 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"))); - } - // max header name length for lshpack - let mut name_buffer = [0u8; 4096]; - let stream_id: u32 = if stream_id.is_number() { - stream_id.to_u32() - } else { - self.get_next_stream_id() - }; - if stream_id > MAX_STREAM_ID { - return Ok(JSValue::js_number(-1.0)); - } - - // we iterate twice, because pseudo headers must be sent first, but can appear anywhere in the headers object - let mut single_value_headers = [false; SINGLE_VALUE_HEADERS_LEN]; - - // Raw (flat [name, value, ...] array) headers form: encode each pair in - // its given order, pseudo-headers first (same two-pass split as the - // object form below), preserving interleaved duplicates on the wire. - let headers_are_raw_pairs = headers.js_type().is_array(); - if headers_are_raw_pairs { - for ignore_pseudo_headers in 0..2usize { - let mut pairs = headers.array_iterator(global_object)?; - 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() { - continue; - } - - let name_view = name_js.to_js_string_view(global_object)?; - let name_slice = name_view.to_utf8(); - let name = name_slice.slice(); - if name.is_empty() { - continue; - } - - let validated_name = - match Self::to_valid_header_name(name, &mut name_buffer[..]) { - Ok(n) => n, - Err(_) => { - let exception = global_object.to_type_error( - bun_jsc::ErrorCode::INVALID_HTTP_TOKEN, - format_args!( - "The arguments Header name is invalid. Received \"{}\"", - BStr::new(name) - ), - ); - return Err(global_object.throw_value(exception)); - } - }; - - if name.first() == Some(&b':') { - if ignore_pseudo_headers == 1 { - continue; - } - - if self.is_server.get() { - if !is_valid_response_pseudo_header(validated_name) { - return Err(global_object.err(JscErrorCode::HTTP2_INVALID_PSEUDOHEADER, format_args!("\"{}\" is an invalid pseudoheader or is used incorrectly", BStr::new(name))).throw()); - } - } else if !is_valid_request_pseudo_header(validated_name) { - return Err(global_object - .err( - JscErrorCode::HTTP2_INVALID_PSEUDOHEADER, - format_args!( - "\"{}\" is an invalid pseudoheader or is used incorrectly", - BStr::new(name) - ), - ) - .throw()); - } - } else if ignore_pseudo_headers == 0 { - continue; - } - - if let Some(idx) = self.single_value_index_checked(validated_name) { - if single_value_headers[idx] { - let exception = global_object.to_type_error( - bun_jsc::ErrorCode::HTTP2_HEADER_SINGLE_VALUE, - format_args!( - "Header field \"{}\" must only have a single value", - BStr::new(validated_name) - ), - ); - return Err(global_object.throw_value(exception)); - } - 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 { - match sensitive_headers.get_truthy(global_object, validated_name)? { - Some(_) => true, - None => sensitive_headers.get_truthy(global_object, name)?.is_some(), - } - }; - - 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) = self.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) = self.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 context.is_object() { - stream.set_context(context, global_object); - } - self.schedule_header_compression_session_error(); - return Ok(JSValue::js_number(stream_id as f64)); - } - } - } - } - - for ignore_pseudo_headers in 0..(if headers_are_raw_pairs { - 0usize - } else { - 2usize - }) { - // Note: `bun_jsc::JSPropertyIterator` (runtime-options variant) lacks `.reset()`; - // re-initialize per pass instead — the observable property walk is the same. - let iter = bun_jsc::JSPropertyIterator::init( - global_object, - headers_obj, - bun_jsc::JSPropertyIteratorOptions { - skip_empty_name: false, - include_value: true, - ..Default::default() - }, - )?; - - while let Some((header_name, js_value)) = iter.next()? { - if header_name.length() == 0 { - continue; - } - - let name_slice = header_name.to_utf8(); - let name = name_slice.slice(); - - let validated_name = match Self::to_valid_header_name(name, &mut name_buffer[..]) { - Ok(n) => n, - Err(_) => { - let exception = global_object.to_type_error( - bun_jsc::ErrorCode::INVALID_HTTP_TOKEN, - format_args!( - "The arguments Header name is invalid. Received \"{}\"", - BStr::new(name) - ), - ); - return Err(global_object.throw_value(exception)); - } - }; - - if name.first() == Some(&b':') { - if ignore_pseudo_headers == 1 { - continue; - } - - if self.is_server.get() { - if !is_valid_response_pseudo_header(validated_name) { - return Err(global_object - .err( - JscErrorCode::HTTP2_INVALID_PSEUDOHEADER, - format_args!( - "\"{}\" is an invalid pseudoheader or is used incorrectly", - BStr::new(name) - ), - ) - .throw()); - } - } else { - if !is_valid_request_pseudo_header(validated_name) { - return Err(global_object - .err( - JscErrorCode::HTTP2_INVALID_PSEUDOHEADER, - format_args!( - "\"{}\" is an invalid pseudoheader or is used incorrectly", - BStr::new(name) - ), - ) - .throw()); - } - } - } else if ignore_pseudo_headers == 0 { - 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)?; - - if let Some(idx) = self.single_value_index_checked(validated_name) { - if value_iter.len > 1 || single_value_headers[idx] { - let exception = global_object.to_type_error( - bun_jsc::ErrorCode::HTTP2_HEADER_SINGLE_VALUE, - format_args!( - "Header field \"{}\" must only have a single value", - BStr::new(validated_name) - ), - ); - return Err(global_object.throw_value(exception)); - } - 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()); - } - - let value_view = item.to_js_string_view(global_object)?; - - let never_index = if Self::is_index_like_name(validated_name) { - false - } else { - match sensitive_headers.get_truthy(global_object, validated_name)? { - Some(_) => true, - None => { - sensitive_headers.get_truthy(global_object, name)?.is_some() - } - } - }; - - 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) = self.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) = self.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 context.is_object() { - stream.set_context(context, global_object); - } - self.schedule_header_compression_session_error(); - return Ok(JSValue::UNDEFINED); - } - } - } else if !js_value.is_empty_or_undefined_or_null() { - bun_output::scoped_log!(H2FrameParser, "single header {}", BStr::new(name)); - if let Some(idx) = self.single_value_index_checked(validated_name) { - if single_value_headers[idx] { - let exception = global_object.to_type_error( - bun_jsc::ErrorCode::HTTP2_HEADER_SINGLE_VALUE, - format_args!( - "Header field \"{}\" must only have a single value", - BStr::new(validated_name) - ), - ); - return Err(global_object.throw_value(exception)); - } - single_value_headers[idx] = true; - } - let value_view = js_value.to_js_string_view(global_object)?; - - let never_index = if Self::is_index_like_name(validated_name) { - false - } else { - match sensitive_headers.get_truthy(global_object, validated_name)? { - Some(_) => true, - None => sensitive_headers.get_truthy(global_object, name)?.is_some(), - } - }; - - 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) = self.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) = self.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 context.is_object() { - stream.set_context(context, global_object); - } - self.schedule_header_compression_session_error(); - return Ok(JSValue::js_number(stream_id as f64)); - } - } - } + } + + pub(crate) fn emit_error_to_all_streams(&self, _: &mut JsScope<'_>, code: u32) { + for stream in self.engine.streams.snapshot() { + self.reset_stream(&stream, ErrorCode(code), false); } - let encoded_size = encoded_headers.len(); + } + + pub(crate) fn flush_from_js(&self, _: &mut JsScope<'_>) -> usize { + self.flush() + } + + pub(crate) fn request( + &self, + cx: &mut JsScope<'_>, + stream_id: u32, + context: JsLocal<'_>, + headers: JsLocal<'_>, + sensitive_headers: JsLocal<'_>, + options: JsLocal<'_>, + informational: bool, + ) -> JsResult<()> { + let global_object = cx.global(); + bun_output::scoped_log!(H2FrameParser, "request"); - let Some(stream_ptr) = self.handle_received_stream_id(stream_id) else { - return Ok(JSValue::js_number(-1.0)); + let context = context.to_js(); + let headers = headers.to_js(); + let sensitive_headers = sensitive_headers.to_js(); + let options = options.to_js(); + // The stream can have closed without JS having run its handler for that yet. + let Some(stream) = self.open_stream(stream_id) else { + return Ok(()); }; - // The `options` getters below can run user JS while `stream` is borrowed. - let mut stream = self.enter_stream_dispatch(stream_ptr); - if context.is_object() { - stream.set_context(context, 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 scratch = HeaderScratch::take(global_object); let mut wait_for_trailers: bool = false; let mut end_stream: bool = false; - if !options.is_undefined_or_null() { + let prepared = (|| -> JsResult<()> { + collect_fields( + global_object, + headers, + sensitive_headers, + if self.engine.is_server.get() { + BlockRole::Response + } else { + BlockRole::Request + }, + self.strict_single_value_fields.get(), + Some(&mut scratch.block), + )?; if !options.is_object() { - stream.state = StreamState::CLOSED; - stream.rst_code = ErrorCode::INTERNAL_ERROR.0; - self.dispatch_with_extra( - js::on_stream_error_get_cached, - stream.get_identifier(), - JSValue::js_number(stream.rst_code as f64), - ); - return Ok(JSValue::js_number(stream_id as f64)); + return Ok(()); } - if let Some(padding_js) = options.get(global_object, "paddingStrategy")? { if padding_js.is_number() { - stream.padding_strategy = match padding_js.to_u32() { + stream.padding_strategy.set(match padding_js.to_u32() { 1 => PaddingStrategy::Aligned, 2 => PaddingStrategy::Max, _ => PaddingStrategy::None, - }; - } - } - - 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; + }); } } - - if let Some(silent_js) = options.get(global_object, "silent")? { - if silent_js.is_boolean() { - silent = silent_js.as_boolean(); - } else { - return Err(global_object.throw_invalid_argument_type_value( - b"options.silent", - b"boolean", - silent_js, - )); + if let Some(trailers_js) = options.get(global_object, "waitForTrailers")? { + if trailers_js.is_boolean() { + wait_for_trailers = trailers_js.as_boolean(); } } - if let Some(end_stream_js) = options.get(global_object, "endStream")? { - 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 || self.is_server.get() { - flags |= HeadersFrameFlags::END_STREAM as u8; - } - } - } else { + if !end_stream_js.is_boolean() { return Err(global_object.throw_invalid_argument_type_value( b"options.endStream", b"boolean", end_stream_js, )); } + end_stream = end_stream_js.as_boolean(); } - - 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; - self.dispatch_with_extra( - js::on_stream_error_get_cached, - 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; - self.dispatch_with_extra( - js::on_stream_error_get_cached, - 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; - self.dispatch_with_extra( - js::on_stream_error_get_cached, - 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)); - self.abort_stream(&mut stream, wrapped); - return Ok(JSValue::js_number(stream_id as f64)); - } - stream.attach_signal(self, signal_); - } else { - return Err(global_object.throw_invalid_argument_type_value( - b"options.signal", - b"AbortSignal", - signal_arg, - )); - } - } + Ok(()) + })(); + if let Err(err) = prepared { + // The id is used up, but the peer never heard of the stream. + if stream.state() == State::Idle { + self.engine.streams.close(&stream); + } + return Err(err); + } + stream.request_method.set( + scratch + .block + .get(b":method") + .map(RequestMethod::from_method), + ); + if context.is_object() { + stream.set_context(context, global_object); } + let mut flags: u8 = if end_stream { flags::END_STREAM } else { 0 }; - // too much memory being use - if self.is_over_session_memory_limit() { - stream.state = StreamState::CLOSED; - stream.rst_code = ErrorCode::ENHANCE_YOUR_CALM.0; + // A server only gets here for a stream that's already open. Like Node, a client's own streams don't count here. + if !self.engine.is_server.get() + && self.buffered_output() > self.engine.max_session_memory.get() + { self.rejected_streams.set(self.rejected_streams.get() + 1); - self.dispatch_with_extra( - js::on_stream_error_get_cached, - stream.get_identifier(), - JSValue::js_number(stream.rst_code as f64), - ); + self.reset_stream(&stream, ErrorCode::ENHANCE_YOUR_CALM, false); if self.rejected_streams.get() >= self.max_rejected_streams.get() { - let global = self.handlers.get().global(); - let chunk = self - .handlers - .get() - .binary_type - .to_js(b"ENHANCE_YOUR_CALM", &global)?; + let global = self.global(); + let chunk = BinaryType::Buffer.to_js(b"ENHANCE_YOUR_CALM", &global)?; self.dispatch_with_2_extra( js::on_error_get_cached, JSValue::js_number(ErrorCode::ENHANCE_YOUR_CALM.0 as f64), - JSValue::js_number(self.last_stream_id.get() as f64), + JSValue::js_number(self.engine.last_stream_id.get() as f64), chunk, ); } - 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; + return Ok(()); } - - bun_output::scoped_log!(H2FrameParser, "request encoded_size {}", encoded_size); - - // Check if headers block exceeds maxSendHeaderBlockLength - if self.max_send_header_block_length.get() != 0 - && encoded_size > self.max_send_header_block_length.get() as usize - { - stream.state = StreamState::CLOSED; - stream.rst_code = ErrorCode::REFUSED_STREAM.0; - + if self.is_over_send_limit(&scratch.block) { self.dispatch_with_2_extra( js::on_frame_error_get_cached, - stream.get_identifier(), - JSValue::js_number(FrameType::HTTP_FRAME_HEADERS as u8 as f64), + stream.context(), + JSValue::js_number(FrameType::Headers as u8 as f64), JSValue::js_number(ErrorCode::FRAME_SIZE_ERROR.0 as f64), ); - - self.dispatch_with_extra( - js::on_stream_error_get_cached, - stream.get_identifier(), - JSValue::js_number(stream.rst_code as f64), - ); - return Ok(JSValue::js_number(stream_id as f64)); + if !self.engine.is_server.get() { + // The peer never saw this request, so it gets no RST_STREAM. + self.reset_stream(&stream, ErrorCode::REFUSED_STREAM, false); + } else if !informational { + self.reset_stream(&stream, ErrorCode::FRAME_SIZE_ERROR, true); + } + return Ok(()); } - let actual_max_frame_size = self - .remote_settings - .get() - .unwrap_or_else(|| self.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; - // 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 - // header block. CONTINUATION frames cannot carry padding, so it is - // disabled whenever the block does not fit in a single HEADERS frame. - let padding: u8 = if encoded_size >= available_payload { + // The encode updates the HPACK table, so nothing may return or throw until the block is written. + if let Err(err) = self.encode_header_list(&mut scratch) { + if matches!(err, crate::Error::Alloc(_)) { + return Err(global_object.throw(format_args!("Failed to allocate header buffer"))); + } + self.schedule_header_compression_session_error(); + return Ok(()); + } + let max_frame_size = self.engine.remote_settings.get().max_frame_size as usize; + let encoded_size = scratch.encoded.len(); + // CONTINUATION frames can't be padded, so only a block that fits one frame is. One byte is the Pad Length field. + let padding: u8 = if encoded_size >= max_frame_size { 0 } else { - stream.get_padding(encoded_size, available_payload - 1) - }; - let padding_overhead: usize = if padding != 0 { - padding as usize + 1 - } else { - 0 + stream.padding_for(encoded_size, max_frame_size - 1) }; - let headers_frame_max_payload = available_payload - padding_overhead; - - let mut writer = self.to_writer(); - - // 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; - bun_output::scoped_log!( - H2FrameParser, - "padding: {} size: {} max_size: {} payload_size: {}", - padding, - encoded_size, - encoded_headers.len(), - payload_size - ); - - 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 { - type_: FrameType::HTTP_FRAME_HEADERS as u8, - flags, - stream_identifier: stream.id, - length: u32::try_from(payload_size).expect("int cast"), - }; - let _ = frame.write(&mut writer, &self.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. - 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 - buffer.copy_within(0..encoded_size, 1); - buffer[0] = padding; - let _ = writer.write_all(buffer); - } else { - let _ = writer.write_all(&encoded_headers); - } - } else { - bun_output::scoped_log!( - H2FrameParser, - "Using CONTINUATION frames: encoded_size={} max_frame_payload={}", - encoded_size, - actual_max_frame_size - ); - - let first_chunk_size = actual_max_frame_size - priority_overhead; - 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 - }), - stream_identifier: stream.id, - length: u32::try_from(first_chunk_size + priority_overhead).expect("int cast"), - }; - let _ = headers_frame.write(&mut writer, &self.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]); - - let mut offset: usize = first_chunk_size; - while offset < encoded_size { - let remaining = encoded_size - offset; - let chunk_size = remaining.min(actual_max_frame_size); - let is_last = offset + chunk_size >= encoded_size; - - let cont_frame = FrameHeader { - type_: FrameType::HTTP_FRAME_CONTINUATION as u8, - flags: if is_last { - HeadersFrameFlags::END_HEADERS as u8 - } else { - 0 - }, - stream_identifier: stream.id, - length: u32::try_from(chunk_size).expect("int cast"), - }; - let _ = cont_frame.write(&mut writer, &self.frames_sent_legacy); - let _ = writer.write_all(&encoded_headers[offset..offset + chunk_size]); - - offset += chunk_size; - } - } - + if padding != 0 { + flags |= flags::PADDED; + // Doesn't allocate, because encode_header_list reserved the room. + scratch.encoded.insert(0, padding); + scratch + .encoded + .resize(1 + encoded_size + padding as usize, 0); + } + // Before the write: a synchronous JS transport handles the peer's answer inside it. + let _ = self.engine.streams.apply(&stream, StreamEvent::SendHeaders); + self.write_header_block(FrameType::Headers, flags, stream_id, b"", &scratch.encoded); if end_stream { - stream.end_after_headers = true; - - if wait_for_trailers { - stream.state = StreamState::HALF_CLOSED_LOCAL; - self.dispatch(js::on_want_trailers_get_cached, 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 - // send_data / send_trailers: transition the state forward and - // dispatch onStreamEnd — without this a headers-only END_STREAM - // response regressed the state to HALF_CLOSED_LOCAL and never - // told JS, leaking the stream (and the session's connection - // count) until socket close. - let identifier = stream.get_identifier(); - identifier.ensure_still_alive(); - if stream.state == StreamState::HALF_CLOSED_REMOTE { - stream.state = StreamState::CLOSED; - stream.free_resources::(self); - } else { - stream.state = StreamState::HALF_CLOSED_LOCAL; - } - self.dispatch_with_extra( - js::on_stream_end_get_cached, - identifier, - JSValue::js_number(stream.state as u8 as f64), - ); + self.sent_end_stream(&stream, false); } else { - stream.wait_for_trailers = wait_for_trailers; + stream.wait_for_trailers.set(wait_for_trailers); } - - if silent { - // TODO: should we make use of this in the future? We validate it. - } - - let _ = length; - Ok(JSValue::js_number(stream_id as f64)) + Ok(()) } - pub(crate) fn read(&self, cx: &mut JsScope<'_>, data: JsLocal<'_>) -> JsResult<()> { - let global_object = cx.global(); - let data = data.to_js(); - // Same engine-driven inbound path as on_native_read (JS-fed sockets / proxied streams). - // The engine dispatches into JS between frames, and a handler can detach/transfer this - // ArrayBuffer; copy the bytes so the parse never reads freed memory. - if let Some(array_buffer) = data.as_array_buffer(global_object) { - let copied = array_buffer.byte_slice().to_vec(); - self.rewrite_read(&copied); - if self.left_exception.replace(false) { - return Err(bun_jsc::JsError::Thrown); - } - Ok(()) - } else { - Err(global_object.throw(format_args!("Expected data to be a Buffer or ArrayBuffer"))) + /// What a JS transport read. `on_native_read` is the same for a native socket. + pub(crate) fn read<'a>(&self, cx: &mut JsScope<'a>, data: JsBytes<'a>) -> JsResult<()> { + // A handler can detach or transfer the buffer between two frames, so a copy is parsed. + let copied = data.get(&cx.heap).to_vec(); + self.receive(&copied); + if self.left_exception.replace(false) { + return Err(bun_jsc::JsError::Thrown); } + Ok(()) } pub(crate) fn on_native_read(&self, data: &[u8]) -> JsResult<()> { bun_output::scoped_log!(H2FrameParser, "onNativeRead"); let _keepalive = self.keepalive(); // Engine-driven inbound: all reads flow through the rewritten connection engine. - self.rewrite_read(data); + self.receive(data); // What a frame callback left pending stopped the engine (`should_stop`); // it belongs to the socket dispatch that delivered these bytes. if self.left_exception.replace(false) { @@ -7279,6 +3978,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) { @@ -7288,6 +4035,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); + } } pub(crate) fn set_native_socket(&self, _: &mut JsScope<'_>, socket: JsLocal<'_>) { @@ -7320,93 +4072,50 @@ impl H2FrameParser { pub(crate) fn constructor<'a>( cx: &mut JsScope<'a>, structure: JsStructure<'a, Self>, - options: JsLocal<'a>, + options: &H2FrameParserOptions<'a>, ) -> JsResult> { let global_object = cx.global(); - let options = options.to_js(); - - if !options.is_object() { - return Err( - global_object.throw_invalid_arguments(format_args!("expected options as argument")) - ); - } - - let Some(context_obj) = options.get(global_object, "context")? else { - return Err(global_object.throw(format_args!("Expected \"context\" option"))); - }; - let mut handler_js = JSValue::ZERO; - if let Some(handlers_) = options.get(global_object, "handlers")? { - handler_js = handlers_; - } let init = H2FrameParser { abort_handle: bun_jsc::AbortHandle::for_owner::(), ref_count: bun_ptr::RefCount::init(), native_keepalives: Cell::new(0), - handlers: JsCell::new(Handlers::new(global_object)), + handlers: Handlers::new(global_object), global_this: GlobalRef::from(global_object), strong_this: JsCell::new(JsRef::empty()), native_socket: NativeSocket::default(), - local_settings: Cell::new(FullSettingsPayload::default()), explicit_settings: Cell::new(0), custom_settings: JsCell::new(Vec::new()), wire_custom_settings: JsCell::new(Vec::new()), remote_custom_settings_filter: JsCell::new(Vec::new()), remote_custom_settings: JsCell::new(Vec::new()), - enforced_max_header_list_size: Cell::new( - FullSettingsPayload::default().max_header_list_size, - ), - remote_settings: Cell::new(None), - window_size: Cell::new(DEFAULT_WINDOW_SIZE), - used_window_size: Cell::new(0), - 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()), + window_size: Cell::new(u64::from(DEFAULT_WINDOW_SIZE)), max_rejected_streams: Cell::new(100), - max_session_invalid_frames: Cell::new(1000), - strict_field_whitespace: Cell::new(true), - stream_reset_burst: Cell::new(crate::api::h2::connection::DEFAULT_STREAM_RESET_BURST), - stream_reset_rate: Cell::new(crate::api::h2::connection::DEFAULT_STREAM_RESET_RATE), - goaway_sent: Cell::new(false), - max_outstanding_settings: Cell::new(10), - outstanding_settings: Cell::new(0), rejected_streams: Cell::new(0), - max_session_memory: Cell::new(10), 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), - is_server: Cell::new(false), 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), - engine_frames_sent: Cell::new(0), tx_tracker: Cell::new(TxFrameTracker::default()), auto_flusher: JsCell::new(AutoFlusher::default()), - padding_strategy: Cell::new(PaddingStrategy::None), - engine: core::cell::RefCell::new(None), - rewrite_tail: JsCell::new(Vec::new()), - rewrite_pending_push: Cell::new(0), - sctx: JsCell::new(BunHashMap::default()), + engine: Connection::default(), + frames_sent: Cell::new(0), + inbound_tail: JsCell::new(Vec::new()), + inbound_tail_pos: Cell::new(0), + receiving: Cell::new(false), + deferred: JsCell::new(VecDeque::new()), + pending_push: Cell::new(0), hdr_block: JsCell::new(Vec::new()), hdr_meta: JsCell::new(Vec::new()), }; @@ -7436,149 +4145,141 @@ impl H2FrameParser { // SAFETY: `this_value` holds a ref. let this_ref = unsafe { &*this }; // Detaches the socket attached below when a later `?` throws. - let guard = scopeguard::guard(this_ref, Self::detach); - this_ref - .handlers - .set(Handlers::from_js(global_object, handler_js, js_value)?); - - // check if socket is provided, and if it is a valid native socket - if let Some(socket_js) = options.get(global_object, "native")? { - if let Some(socket) = TLSSocket::from_js(socket_js) { - bun_output::scoped_log!(H2FrameParser, "TLSSocket attached"); - this_ref - .native_socket - .attach::(socket, this_ref.ref_guard()); - let _ = this_ref.flush(); - } else if let Some(socket) = TCPSocket::from_js(socket_js) { - bun_output::scoped_log!(H2FrameParser, "TCPSocket attached"); - this_ref - .native_socket - .attach::(socket, this_ref.ref_guard()); - let _ = this_ref.flush(); - } - } - if let Some(settings_js) = options.get(global_object, "settings")? { - if !settings_js.is_empty_or_undefined_or_null() { - bun_output::scoped_log!(H2FrameParser, "settings received in the constructor"); - this_ref.load_settings_from_js_value(global_object, settings_js)?; - // The constructor settings ride on the connection preface, so received header - // blocks are checked against them right away; later settings() submissions only - // take effect for enforcement once the peer ACKs them. - this_ref - .enforced_max_header_list_size - .set(this_ref.local_settings.get().max_header_list_size); - - if let Some(max_pings) = settings_js.get(global_object, "maxOutstandingPings")? { - if max_pings.is_number() { - this_ref - .max_outstanding_pings - .set(max_pings.to_uint64_no_truncate()); - } - } - if let Some(max_memory) = settings_js.get(global_object, "maxSessionMemory")? { - if max_memory.is_number() { - this_ref - .max_session_memory - .set(Self::session_option_u32(max_memory).max(1)); - } - } - if let Some(max_header_list_pairs) = - settings_js.get(global_object, "maxHeaderListPairs")? - { - if max_header_list_pairs.is_number() { - this_ref - .max_header_list_pairs - .set(Self::session_option_u32(max_header_list_pairs).max(4)); - } + let guard = scopeguard::guard(this_ref, |this: &Self| this.detach(false)); + Handlers::install(global_object, &options.handlers, js_value); + + let socket_js = options.native.to_js(); + if let Some(socket) = TLSSocket::from_js(socket_js) { + bun_output::scoped_log!(H2FrameParser, "TLSSocket attached"); + this_ref + .native_socket + .attach::(socket, this_ref.ref_guard()); + let _ = this_ref.flush(); + } else if let Some(socket) = TCPSocket::from_js(socket_js) { + bun_output::scoped_log!(H2FrameParser, "TCPSocket attached"); + this_ref + .native_socket + .attach::(socket, this_ref.ref_guard()); + let _ = this_ref.flush(); + } + let settings_js = options.settings.to_js(); + if !settings_js.is_empty_or_undefined_or_null() { + bun_output::scoped_log!(H2FrameParser, "settings received in the constructor"); + this_ref.load_settings_from_js_value(global_object, settings_js)?; + // These settings go out with the preface, so they bind the peer at once. Those of a later settings() do once the peer ACKs them. + this_ref + .engine + .enforced_max_header_list_size + .set(this_ref.engine.local_settings.get().max_header_list_size); + + if let Some(max_pings) = settings_js.get(global_object, "maxOutstandingPings")? { + if max_pings.is_number() { + this_ref + .max_outstanding_pings + .set(max_pings.to_uint64_no_truncate()); } - if let Some(max_settings) = settings_js.get(global_object, "maxSettings")? { - if max_settings.is_number() { - this_ref - .max_settings - .set(Self::session_option_u32(max_settings).max(1)); - } + } + if let Some(max_memory) = settings_js.get(global_object, "maxSessionMemory")? { + if max_memory.is_number() { + let megabytes = Self::session_option_u32(max_memory).max(1) as usize; + this_ref + .engine + .max_session_memory + .set(megabytes.saturating_mul(1024 * 1024)); } - if let Some(max_rejected_streams) = - settings_js.get(global_object, "maxSessionRejectedStreams")? - { - if max_rejected_streams.is_number() { - this_ref - .max_rejected_streams - .set(Self::session_option_u32(max_rejected_streams)); - } + } + if let Some(max_header_list_pairs) = + settings_js.get(global_object, "maxHeaderListPairs")? + { + if max_header_list_pairs.is_number() { + this_ref + .engine + .max_header_list_pairs + .set(Self::session_option_u32(max_header_list_pairs).max(4)); } - if let Some(max_session_invalid_frames) = - settings_js.get(global_object, "maxSessionInvalidFrames")? - { - if max_session_invalid_frames.is_number() { - this_ref - .max_session_invalid_frames - .set(Self::session_option_u32(max_session_invalid_frames)); - } + } + if let Some(max_settings) = settings_js.get(global_object, "maxSettings")? { + if max_settings.is_number() { + this_ref + .engine + .max_settings + .set(Self::session_option_u32(max_settings).max(1)); } - if let Some(strict) = - settings_js.get(global_object, "strictFieldWhitespaceValidation")? - { - if strict.is_boolean() { - this_ref.strict_field_whitespace.set(strict.as_boolean()); - } + } + if let Some(max_rejected_streams) = + settings_js.get(global_object, "maxSessionRejectedStreams")? + { + if max_rejected_streams.is_number() { + this_ref + .max_rejected_streams + .set(Self::session_option_u32(max_rejected_streams)); } - // node: MathMax(1, x) per key (util.js), applied when both are given (node_http2.cc). - if let Some(reset_burst) = settings_js.get(global_object, "streamResetBurst")? - && let Some(reset_rate) = settings_js.get(global_object, "streamResetRate")? - && reset_burst.is_number() - && reset_rate.is_number() - { - this_ref.stream_reset_burst.set(reset_burst.to_u32().max(1)); - this_ref.stream_reset_rate.set(reset_rate.to_u32().max(1)); + } + if let Some(max_session_invalid_frames) = + settings_js.get(global_object, "maxSessionInvalidFrames")? + { + if max_session_invalid_frames.is_number() { + this_ref + .engine + .max_invalid_frames + .set(Self::session_option_u32(max_session_invalid_frames)); } - if let Some(max_outstanding_settings) = - settings_js.get(global_object, "maxOutstandingSettings")? - { - if max_outstanding_settings.is_number() { - this_ref - .max_outstanding_settings - .set(Self::session_option_u32(max_outstanding_settings).max(1)); - } + } + if let Some(strict) = + settings_js.get(global_object, "strictFieldWhitespaceValidation")? + { + if strict.is_boolean() { + this_ref + .engine + .strict_field_whitespace + .set(strict.as_boolean()); } - if let Some(max_send_header_block_length) = - settings_js.get(global_object, "maxSendHeaderBlockLength")? - { - if max_send_header_block_length.is_number() { - this_ref - .max_send_header_block_length - .set(max_send_header_block_length.to_int32() as u32); - } + } + // node: MathMax(1, x) per key (util.js), applied when both are given (node_http2.cc). + if let Some(reset_burst) = settings_js.get(global_object, "streamResetBurst")? + && let Some(reset_rate) = settings_js.get(global_object, "streamResetRate")? + && reset_burst.is_number() + && reset_rate.is_number() + { + this_ref.engine.set_stream_reset_limit( + reset_burst.to_u32().max(1), + reset_rate.to_u32().max(1), + ); + } + if let Some(max_send_header_block_length) = + settings_js.get(global_object, "maxSendHeaderBlockLength")? + { + if max_send_header_block_length.is_number() { + this_ref + .max_send_header_block_length + .set(max_send_header_block_length.to_int32() as u32); } - if let Some(strict_single_value) = - settings_js.get(global_object, "strictSingleValueFields")? - { - if strict_single_value.is_boolean() { - this_ref - .strict_single_value_fields - .set(strict_single_value.to_boolean()); - } + } + if let Some(strict_single_value) = + settings_js.get(global_object, "strictSingleValueFields")? + { + if strict_single_value.is_boolean() { + this_ref + .strict_single_value_fields + .set(strict_single_value.to_boolean()); } - if let Some(padding_strategy) = settings_js.get(global_object, "paddingStrategy")? { - if padding_strategy.is_number() { - this_ref - .padding_strategy - .set(match padding_strategy.to_u32() { - 1 => PaddingStrategy::Aligned, - 2 => PaddingStrategy::Max, - _ => PaddingStrategy::None, - }); - } + } + if let Some(padding_strategy) = settings_js.get(global_object, "paddingStrategy")? { + if padding_strategy.is_number() { + this_ref + .engine + .padding_strategy + .set(match padding_strategy.to_u32() { + 1 => PaddingStrategy::Aligned, + 2 => PaddingStrategy::Max, + _ => PaddingStrategy::None, + }); } } } - let mut is_server = false; - if let Some(type_js) = options.get(global_object, "type")? { - is_server = type_js.is_number() && type_js.to_u32() == 0; - } - - this_ref.is_server.set(is_server); - js::context_set_cached(js_value, global_object, context_obj); + let is_server = options.session_type == 0; + this_ref.engine.is_server.set(is_server); + js::context_set_cached(js_value, global_object, options.context.to_js()); this_ref .strong_this @@ -7590,15 +4291,8 @@ 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, - ))); if is_server { - let _ = this_ref.set_settings(this_ref.local_settings.get()); + this_ref.set_settings(this_ref.engine.local_settings.get()); } else { // consider that we need to queue until the first flush this_ref.has_nonnative_backpressure.set(true); @@ -7608,24 +4302,21 @@ impl H2FrameParser { Ok(this_value) } - pub(crate) fn detach_from_js(&self, _: &mut JsScope<'_>) { - self.release_from_js(); + /// `detach(keepOutput)` + pub(crate) fn detach_from_js(&self, _: &mut JsScope<'_>, keep_output: bool) { + self.release_from_js(keep_output); } /// 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, - // so the loop body can keep using `this` (`&Self`) directly. - let mut it = StreamResumableIterator::init(this); - while let Some(stream) = it.next() { - // SAFETY: stream is *mut Stream from self.streams; valid until freed below / map - // cleared. `stream` points into a disjoint Box. - unsafe { (*stream).free_resources::(this) }; - } - this.detach(); + for stream in this.engine.streams.snapshot() { + this.drop_queued_data(&stream); + } + this.engine.streams.clear(); + this.detach(keep_output); if let Some(this_value) = this.strong_this.get().try_get() { // `global_this` is `GlobalRef` (JSC_BORROW) — Deref gives `&JSGlobalObject`. js::context_set_cached(this_value, &this.global_this, JSValue::ZERO); @@ -7635,21 +4326,20 @@ 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); } /// `process.exit()` never unwinds: the VM is destructed from inside the `exit()` call, so @@ -7682,47 +4372,32 @@ 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(); - // 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()); - for (_, item) in streams.iter() { - let stream = *item; - // SAFETY: stream is *mut Stream from self.streams; this is final teardown, freed exactly once via heap::take - unsafe { - (*stream).free_resources::(self); - drop(bun_core::heap::take(stream)); - } - } - drop(streams); + self.detach(false); } } impl H2FrameParser { + /// What the session's own buffers hold, used or not. + pub(crate) fn memory_cost(&self) -> usize { + self.write_buffer.get().capacity() + + self.inbound_tail.get().capacity() + + self.hdr_block.get().capacity() + + self.hdr_meta.get().capacity() * core::mem::size_of::() + + self.engine.header_block_capacity() + } + pub(crate) fn finalize(&self) { 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/bun/spawn/stdio.rs b/src/runtime/api/bun/spawn/stdio.rs index b27a514e0968..847d780ed3ff 100644 --- a/src/runtime/api/bun/spawn/stdio.rs +++ b/src/runtime/api/bun/spawn/stdio.rs @@ -373,7 +373,7 @@ impl Stdio { .throw_invalid_arguments(format_args!("Failed to create ReadableStream"))); }; - if stream.is_disturbed(cx.global()) { + if stream.is_disturbed(cx.global()) || stream.is_locked(cx.global()) { return Err(cx .global() .err( @@ -522,7 +522,7 @@ impl Stdio { ))); } - if stream.is_disturbed(cx.global()) { + if stream.is_disturbed(cx.global()) || stream.is_locked(cx.global()) { return Err(cx .global() .err( diff --git a/src/runtime/api/bun/subprocess.rs b/src/runtime/api/bun/subprocess.rs index 02039805094e..3288f1e234ed 100644 --- a/src/runtime/api/bun/subprocess.rs +++ b/src/runtime/api/bun/subprocess.rs @@ -1023,6 +1023,10 @@ impl Subprocess<'_> { // The exit is reported to the script that spawned the child. let _context = (*jsc_vm).enter_context(self.context); if !is_sync { + // A channel that ended before this exit was seen reports its end first. + if let Some(ipc_data) = self.ipc() { + ipc_data.run_pending(); + } if !this_jsvalue.is_empty() { if let Some(promise) = js::exited_promise_take_cached(this_jsvalue, global_this) { // SAFETY: event_loop points into the live VM and outlives this scope. diff --git a/src/runtime/api/html_rewriter.rs b/src/runtime/api/html_rewriter.rs index 32e47f6019bd..9ca5cd1dd7db 100644 --- a/src/runtime/api/html_rewriter.rs +++ b/src/runtime/api/html_rewriter.rs @@ -29,7 +29,7 @@ use crate::generated_bindgen::html_rewriter_internals::{ Bun__HTMLRewriter__onRejectInputStream, Bun__HTMLRewriter__onResolveInputStream, }; use crate::generated_bindgen::response::js_Response; -use crate::webcore::blob::SizeType as BlobSizeType; +use crate::webcore::blob::{BodyFacts, SizeType as BlobSizeType}; use crate::webcore::sink::{AnyControlledSink, ReadableSinkController}; use crate::webcore::streams::{ self, SourceHandle, Start, StreamError, StreamResult, Writable, WritablePending, @@ -1005,6 +1005,12 @@ impl RewriterPipe { pv.on_start_streaming = Some(RewriterPipe::on_start_streaming); pv.on_readable_stream_available = Some(RewriterPipe::on_readable_stream_available); pv.producer = SourceHandle::HTMLRewriter(this); + // The output is as decoded as the input. It isn't the string the input may have been. + pv.facts = original.get_body_value().facts() & BodyFacts::DECODED; + // An input without a header list still has its type with its body. + if original.get_init_headers().is_none() { + pv.set_content_type(original.get_body_value().content_type().clone()); + } webcore::body::Value::Locked(pv) }), BunString::EMPTY, @@ -1649,24 +1655,11 @@ impl RewriterPipe { let Some(response) = self.response.get().as_deref() else { return; }; - // For a waiting `.blob()`'s content type. - let headers = response.get_fetch_headers().map(NonNull::from); - let body_value = response.get_body_value(); let bytes = self.output_buffer.replace(Vec::new()); - let mut prev_value = core::mem::replace( - body_value, - webcore::body::Value::InternalBlob(webcore::InternalBlob { - bytes, - was_string: false, - }), - ); let context = self.global.bun_vm().context_of(self.script_context); - let _ = webcore::body::Value::resolve( - &mut prev_value, - body_value, - &self.global.js_thread(context), - headers, - ); + let _ = response + .get_body_value() + .fulfill(bytes, &self.global.js_thread(context)); } /// Feed the accumulated `pending_input` once unblocked, then maybe end, diff --git a/src/runtime/api/standalone_graph_jsc.rs b/src/runtime/api/standalone_graph_jsc.rs index b860256e4a0b..fa335ebaad83 100644 --- a/src/runtime/api/standalone_graph_jsc.rs +++ b/src/runtime/api/standalone_graph_jsc.rs @@ -78,7 +78,7 @@ fn template_blob(file: &File) -> NonNull Blob { // SAFETY: `template_blob` returns the pointer from `Blob::new`, never // freed for the process lifetime; only read here. - let blob = unsafe { template_blob(file).cast::().as_ref() }.dupe_with_content_type(true); + let blob = unsafe { template_blob(file).cast::().as_ref() }.dupe(); blob.global_this.set(global); blob } diff --git a/src/runtime/bake/dev_server/bundle.rs b/src/runtime/bake/dev_server/bundle.rs index 28cf1eb09bc4..0bdcffe58f70 100644 --- a/src/runtime/bake/dev_server/bundle.rs +++ b/src/runtime/bake/dev_server/bundle.rs @@ -296,7 +296,7 @@ impl DevServer { } self.next_bundle.route_queue.clear_retaining_capacity(); if let Some(server) = server.as_mut() { - server.on_static_request_complete(); + server.on_request_complete(); } // TODO: allow recovery from this state Ok(()) diff --git a/src/runtime/bake/dev_server/ensure_route.rs b/src/runtime/bake/dev_server/ensure_route.rs index 0df897bc93c5..64d69dbc4847 100644 --- a/src/runtime/bake/dev_server/ensure_route.rs +++ b/src/runtime/bake/dev_server/ensure_route.rs @@ -45,6 +45,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(()) } @@ -291,6 +292,7 @@ impl DevServer { }, deferred_data_ptr, ); + resp.suspend_timeout(); break 'brk Handler::BundledHtmlPage(ResponseAndMethod { response: resp, method, diff --git a/src/runtime/bake/dev_server/error_report_request.rs b/src/runtime/bake/dev_server/error_report_request.rs index ae6a2e1c0ad2..0c9279294ba0 100644 --- a/src/runtime/bake/dev_server/error_report_request.rs +++ b/src/runtime/bake/dev_server/error_report_request.rs @@ -90,7 +90,7 @@ impl ErrorReportRequest { .server .as_mut() .unwrap() - .on_static_request_complete(); + .on_request_complete(); drop(bun_core::heap::take(ctx)); } } @@ -386,7 +386,7 @@ impl ErrorReportRequest { StaticRoute::send_blob_then_deinit( r, - crate::webcore::blob::Any::from_array_list(out), + crate::webcore::blob::Any::from_owned_slice(out), InitFromBytesOptions { mime_type: Some(&bun_http_types::MimeType::OTHER), server: dev.server, diff --git a/src/runtime/bake/dev_server/failures.rs b/src/runtime/bake/dev_server/failures.rs index b9769e30f89b..65d468e2dcf1 100644 --- a/src/runtime/bake/dev_server/failures.rs +++ b/src/runtime/bake/dev_server/failures.rs @@ -84,7 +84,7 @@ impl DevServer { StaticRoute::send_blob_then_deinit( resp, - crate::webcore::blob::Any::from_array_list(buf), + crate::webcore::blob::Any::from_owned_slice(buf), crate::server::static_route::InitFromBytesOptions { mime_type: Some(&MimeType::HTML), server: self.server, diff --git a/src/runtime/bake/dev_server/finalize.rs b/src/runtime/bake/dev_server/finalize.rs index c6d1fffee5e3..dc956cd0e9e9 100644 --- a/src/runtime/bake/dev_server/finalize.rs +++ b/src/runtime/bake/dev_server/finalize.rs @@ -133,7 +133,7 @@ fn finalize_bundle_cleanup(dev: &mut DevServer, bv2: &mut BundleV2, had_sent_hmr // Unref the ref added in `start_async_bundle` if let Some(server) = dev.server.as_mut() { - server.on_static_request_complete(); + server.on_request_complete(); } } diff --git a/src/runtime/bake/dev_server/http.rs b/src/runtime/bake/dev_server/http.rs index 567164aaadeb..3a1ce721d44e 100644 --- a/src/runtime/bake/dev_server/http.rs +++ b/src/runtime/bake/dev_server/http.rs @@ -280,7 +280,7 @@ fn on_js_request(dev: &mut DevServer, req: &mut Request, resp: AnyResponse) { let json_bytes = bun_core::handle_oom(entry.render_json(source_id.kind(), bake::Side::Client)); let response = StaticRoute::init_from_any_blob( - crate::webcore::blob::Any::from_array_list(json_bytes), + crate::webcore::blob::Any::from_owned_slice(json_bytes), crate::server::static_route::InitFromBytesOptions { server: dev.server, mime_type: Some(&MimeType::JSON), @@ -435,13 +435,7 @@ impl UnrefSourceMapRequest { // live borrow of *ctx exists. let ctx = unsafe { bun_core::heap::take(ctx) }; // SAFETY: dev outlives the request - unsafe { - (*ctx.dev) - .server - .as_mut() - .unwrap() - .on_static_request_complete() - }; + unsafe { (*ctx.dev).server.as_mut().unwrap().on_request_complete() }; drop(ctx); } diff --git a/src/runtime/bake/dev_server/render.rs b/src/runtime/bake/dev_server/render.rs index 858e8f3a4726..c2b3cd5f87bd 100644 --- a/src/runtime/bake/dev_server/render.rs +++ b/src/runtime/bake/dev_server/render.rs @@ -448,6 +448,7 @@ impl DevServer { }, deferred.this_ptr(), ); + resp.suspend_timeout(); current_bundle.script_requests.push(deferred); return; } diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 51d9cc2f649a..8b44196a03cb 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -384,6 +384,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 "), // For bun's own use and tests: no help text. parse_param!("--debug-aot"), parse_param!("--aot-huge-heap"), @@ -563,6 +566,11 @@ pub(crate) const AUTO_REQUIREMENT_ROWS: &[RequirementRow] = { (Long(b"tls-min-v1.3"), Nothing), (Long(b"tls-max-v1.2"), Nothing), (Long(b"tls-max-v1.3"), Nothing), + ( + Long(b"network-family-autoselection-attempt-timeout"), + Nothing, + ), + (Long(b"tls-keylog"), Nothing), ( Long(b"redis-preconnect"), RuntimeRequirement::Feature(Feature::Valkey), @@ -1116,16 +1124,6 @@ fn replace_pid_placeholder(name: &'static [u8]) -> &'static [u8] { out.leak() } -use crate::generated_bindgen::node_tls_binding::CAStore; - -pub(crate) fn get_ca_store() -> CAStore { - match Global::ca_store() { - Global::CAStore::Bundled => CAStore::Bundled, - Global::CAStore::System => CAStore::System, - Global::CAStore::OpenSSL => CAStore::Openssl, - } -} - // ─── bunfig loading ────────────────────────────────────────────────────────── // their private helpers moved to `bun_bunfig::arguments` so `bun_install` can // call them without a tier-6 dependency. Re-export here so existing @@ -1670,6 +1668,10 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Resulthttps://bun.com/docs/cli/run { continue; } - bun_http::async_http::preconnect(url, false); + bun_http::async_http::preconnect(url, false, None); } }) } diff --git a/src/runtime/cli/test/parallel/Channel.rs b/src/runtime/cli/test/parallel/Channel.rs index 13ce96af1837..c0d7fc5eca9d 100644 --- a/src/runtime/cli/test/parallel/Channel.rs +++ b/src/runtime/cli/test/parallel/Channel.rs @@ -587,7 +587,6 @@ impl PosixHandlers { on_writable: Some(Self::raw_on_writable), on_close: Some(Self::raw_on_close), on_timeout: None, - on_long_timeout: None, on_end: Some(Self::raw_on_end), on_connect_error: None, on_connecting_error: None, diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs index 4ed951f9de0f..f53bab29d358 100644 --- a/src/runtime/dispatch.rs +++ b/src/runtime/dispatch.rs @@ -90,6 +90,7 @@ use crate::shell::interpreter::ShellTask; use crate::shell::io_writer::Poll as ShellBufferedWriterPoll; use crate::shell::states::r#async::Async as ShellAsync; +use crate::socket::DeferredWriteCompletion; use crate::webcore::fetch::fetch_tasklet::FetchTasklet; use crate::webcore::file_sink::FlushPendingTask as FlushPendingFileSinkTask; #[cfg(not(windows))] @@ -291,24 +292,21 @@ pub(crate) fn run_task( Ok(()) } )?, - task_tag::DuplexUpgradeContext => { - feature_arm!([Sockets], |context: crate::socket::DuplexUpgradeContext| { - // SAFETY: tag identifies pointee; the queue owns the live context + task_tag::UpgradedDuplex => { + feature_arm!([Sockets], |engine: UpgradedDuplex| { + // SAFETY: tag identifies pointee; the queue owns the live engine // until `run_event` (which may free it). - crate::socket::DuplexUpgradeContext::run_event(unsafe { - bun_ptr::ThisPtr::new(context) - }); + UpgradedDuplex::run_event(unsafe { bun_ptr::ThisPtr::new(engine) }); }) } + task_tag::FdPipe => feature_arm!([Sockets], |pipe: crate::socket::fd_pipe::FdPipe| { + // SAFETY: tag identifies the pointee; the queue owns it until here. + unsafe { crate::socket::fd_pipe::FdPipe::run_event(pipe) }; + }), #[cfg(windows)] - task_tag::WindowsNamedPipeContext => { - // Same shape as `DuplexUpgradeContext`: may free the context. + task_tag::WindowsNamedPipe => { // SAFETY: tag identifies the pointee; the queue owns it until here. - unsafe { - crate::socket::WindowsNamedPipeContext::run_event(cast_ptr!( - crate::socket::WindowsNamedPipeContext - )) - }; + unsafe { WindowsNamedPipe::run_event(cast_ptr!(WindowsNamedPipe)) }; } #[cfg(windows)] task_tag::GetAddrInfoLibuvComplete => { @@ -459,16 +457,6 @@ pub(crate) fn run_task( }; } // A server that speaks TLS was given TLS options, which is what retains `TlsSockets`. - task_tag::HTTPAppClose => { - feature_arm!([Serve], |app: crate::server::AppCloseTask| { - crate::server::AppCloseTask::::run(app); - }) - } - task_tag::HTTPSAppClose => { - feature_arm!([TlsSockets], |app: crate::server::AppCloseTask| { - crate::server::AppCloseTask::::run(app); - }) - } task_tag::HTTPServerDeinit => { feature_arm!([Serve], |server: crate::server::ServerDeinitTask< false, @@ -788,6 +776,18 @@ pub(crate) fn run_task( task_tag::StreamPending => feature_arm!([WebStreams], |pending: StreamPending| { StreamPending::run_from_js_thread(pending); }), + task_tag::TCPSocketWriteCompletion => { + feature_arm!([Sockets], |completion: DeferredWriteCompletion| { + // SAFETY: boxed in `complete_write_later`; the arm consumes it. + unsafe { bun_core::heap::take(completion) }.run(); + }) + } + task_tag::TLSSocketWriteCompletion => { + feature_arm!([Sockets], |completion: DeferredWriteCompletion| { + // SAFETY: boxed in `complete_write_later`; the arm consumes it. + unsafe { bun_core::heap::take(completion) }.run(); + }) + } _ => { // A value outside `task_tag::COUNT` is a producer bug, but it's @@ -883,7 +883,7 @@ fn run_task_cold(task: Task) { /// `release_task_unrun` track `bun_event_loop::task_tag::COUNT`. Bump when /// adding a variant — and give it an arm in both. const _: () = assert!( - task_tag::COUNT == 86, + task_tag::COUNT == 87, "dispatch::run_task / release_task_unrun arm count out of sync with bun_event_loop::task_tag", ); @@ -1088,6 +1088,12 @@ pub(crate) unsafe fn __bun_run_file_poll(poll: *mut FilePoll, size_or_offset: i6 } } poll_tag::TERMINAL_POLL => feature_poll_arm!([Terminal], TerminalPoll), + bun_io::PollTag::FdPipe => { + in_features!([Sockets], (pipe: *mut crate::socket::fd_pipe::FdPipe = owner.ptr.cast()) { + // SAFETY: tag matched, so `owner.ptr` is the live pipe that registered this poll. + unsafe { (*pipe).on_poll() } + }) + } // `OutputReader = BufferedReader` in install crate — separate tag for ownership. poll_tag::LIFECYCLE_SCRIPT_SUBPROCESS_OUTPUT_READER => { poll_arm!(bun_io::BufferedReader, |h| { @@ -1382,14 +1388,6 @@ pub(crate) unsafe fn __bun_fire_timer( |c, _now, _vm| (*c).timer_callback() ) } - EventLoopTimerTag::UpgradedDuplex => { - feature_timer_arm!( - [Sockets], - UpgradedDuplex, - event_loop_timer, - |c, _now, _vm| (*c).on_timeout() - ) - } EventLoopTimerTag::DnsSdConnection => { #[cfg(target_os = "macos")] { @@ -1531,16 +1529,6 @@ pub(crate) unsafe fn __bun_fire_timer( Ok(()) } ), - EventLoopTimerTag::QuicEndpoint => in_features!( - [NodeQuic], - ( - c: *mut crate::node::quic::QuicEndpoint = - owner!(crate::node::quic::QuicEndpoint, event_loop_timer), - ) -> JsResult<()> { - crate::node::quic::QuicEndpoint::on_timer_fire(c); - Ok(()) - } - ), EventLoopTimerTag::ViWait => in_features!( [Test], ( @@ -1680,8 +1668,9 @@ fn __bun_release_task_unrun(task: bun_event_loop::Task) { } task_tag::ShellYesTask => release_in!([Shell], ShellYesTask), task_tag::CppTask => release!(CppTask), - task_tag::DuplexUpgradeContext => { - release_in!([Sockets], crate::socket::DuplexUpgradeContext) + task_tag::UpgradedDuplex => release_in!([Sockets], UpgradedDuplex), + task_tag::FdPipe => { + release_in!([Sockets], crate::socket::fd_pipe::FdPipe) } task_tag::FetchTasklet => release_in!([Fetch], FetchTasklet), task_tag::FetchTaskletDeinit => { @@ -1748,8 +1737,6 @@ fn __bun_release_task_unrun(task: bun_event_loop::Task) { task_tag::GraphContextStopAndFree => { release!(bun_jsc::virtual_machine::GraphContextStopAndFree) } - task_tag::HTTPAppClose => release_in!([Serve], crate::server::AppCloseTask), - task_tag::HTTPSAppClose => release_in!([TlsSockets], crate::server::AppCloseTask), task_tag::HTTPServerDeinit => { release_in!([Serve], crate::server::ServerDeinitTask) } @@ -1820,6 +1807,12 @@ fn __bun_release_task_unrun(task: bun_event_loop::Task) { task_tag::AsyncCpTask => release_in!([NodeFs], crate::node::fs::AsyncCpTask), task_tag::ShellAsyncCpTask => release_in!([Shell], crate::node::fs::ShellAsyncCpTask), task_tag::StreamPending => release_in!([WebStreams], StreamPending), + task_tag::TCPSocketWriteCompletion => { + release_in!([Sockets], DeferredWriteCompletion) + } + task_tag::TLSSocketWriteCompletion => { + release_in!([Sockets], DeferredWriteCompletion) + } task_tag::ThreadSafeFunction => release_in!([NativeAddons], ThreadSafeFunction), task_tag::UvLoopDispatch => { #[cfg(unix)] @@ -1849,9 +1842,9 @@ fn __bun_release_task_unrun(task: bun_event_loop::Task) { #[cfg(not(windows))] unreachable!("windows-only tag"); } - task_tag::WindowsNamedPipeContext => { + task_tag::WindowsNamedPipe => { #[cfg(windows)] - release!(crate::socket::WindowsNamedPipeContext); + release!(WindowsNamedPipe); #[cfg(not(windows))] unreachable!("windows-only tag"); } diff --git a/src/runtime/dns_jsc/cares_jsc.rs b/src/runtime/dns_jsc/cares_jsc.rs index b75a43c110c7..5ebcfc1c8378 100644 --- a/src/runtime/dns_jsc/cares_jsc.rs +++ b/src/runtime/dns_jsc/cares_jsc.rs @@ -845,10 +845,24 @@ 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| { + let mut err = system_error_with_syscall_and_hostname(error, b"getaddrinfo", hostname); + err.errno = c_ares::Error::uv_eai(gai).unwrap_or(err.errno); + err + }) +} + /// 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) - .to_error_instance(global_this) + let mut err = + system_error_with_syscall_and_hostname(c_ares::Error::ENOTFOUND, b"getaddrinfo", hostname); + err.errno = c_ares::UV_EAI_NONAME; + err.to_error_instance(global_this) } pub(crate) fn canonicalize_ip<'a>( diff --git a/src/runtime/dns_jsc/dns.rs b/src/runtime/dns_jsc/dns.rs index 9c388f105788..8af63930a175 100644 --- a/src/runtime/dns_jsc/dns.rs +++ b/src/runtime/dns_jsc/dns.rs @@ -2338,11 +2338,12 @@ pub(crate) mod internal { fn process_results(info: *mut AddrInfo) -> 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() { @@ -2984,6 +2985,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()) } @@ -4552,6 +4579,7 @@ impl Resolver { // the socket is now closed. We must free the data associated with // socket. if let Some(value) = self.polls.with_mut(|p| p.remove(&fd)) { + bun_uws::udp::raw::bsd_disown_socket(fd); // SAFETY: `value` is the heap-allocated FilePoll for this fd. unsafe { (*value).deinit_with_vm(ctx) }; } @@ -4569,6 +4597,8 @@ impl Resolver { let poll_entry = polls.get_or_put(fd).expect("unreachable"); if !poll_entry.found_existing { + // c-ares opens its own sockets, so record here that this one is in use. + let _ = bun_uws::udp::raw::bsd_adopt_socket(fd, 0); *poll_entry.value_ptr = FilePoll::init(ctx, sys::Fd::from_native(fd), Default::default(), owner); } @@ -5525,4 +5555,14 @@ impl Resolver { }; bun::String::static_(<&'static str>::from(order)) } + + pub(crate) fn set_connect_result_order(order: &[u8]) { + // The order and the DNS cache are process-wide, so a Worker must not change them for the other threads. + if !VirtualMachine::get().is_main_thread { + return; + } + if let Some(order) = Order::from_string(order) { + internal::set_connect_order(order); + } + } } diff --git a/src/runtime/dns_jsc/dns_internals.bind.ts b/src/runtime/dns_jsc/dns_internals.bind.ts index c29006d840ed..2787e04c8d3e 100644 --- a/src/runtime/dns_jsc/dns_internals.bind.ts +++ b/src/runtime/dns_jsc/dns_internals.bind.ts @@ -4,6 +4,7 @@ import * as table from "../../codegen/builtins/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 } }); const method = { capability: "js", throws: true } as const; diff --git a/src/runtime/error.rs b/src/runtime/error.rs index 8a1ba3b03bad..18870a758a17 100644 --- a/src/runtime/error.rs +++ b/src/runtime/error.rs @@ -46,8 +46,6 @@ pub enum Error { InvalidValue, #[error("ConnectionFailed")] ConnectionFailed, - #[error("InvalidOptions")] - InvalidOptions, #[error("FailedToInitPipe")] FailedToInitPipe, #[error("FailedToBindPipe")] @@ -264,16 +262,6 @@ impl From for Error { } } -impl From for Error { - #[inline] - fn from(e: bun_uws::ssl_wrapper::InitError) -> Self { - match e { - bun_uws::ssl_wrapper::InitError::OutOfMemory => Self::Alloc(bun_alloc::AllocError), - bun_uws::ssl_wrapper::InitError::InvalidOptions => Self::InvalidOptions, - } - } -} - impl From for Error { fn from(e: bun_archive::Error) -> Self { match e { @@ -360,7 +348,6 @@ impl Error { Self::TooSmall => "TooSmall", Self::InvalidValue => "InvalidValue", Self::ConnectionFailed => "ConnectionFailed", - Self::InvalidOptions => "InvalidOptions", Self::FailedToInitPipe => "FailedToInitPipe", Self::FailedToBindPipe => "FailedToBindPipe", Self::HTTPForbidden => "HTTPForbidden", diff --git a/src/runtime/hw_exports.rs b/src/runtime/hw_exports.rs index 55d604661002..3e1056b0abd6 100644 --- a/src/runtime/hw_exports.rs +++ b/src/runtime/hw_exports.rs @@ -208,27 +208,14 @@ mod sql_hooks { // `in_heap` is consulted and reset. unsafe { (*heap.cast::()).remove(timer) }; } - unsafe fn ssl_ctx_cache(_vm: *mut VirtualMachine) -> *mut c_void { - let state = crate::jsc_hooks::runtime_state(); - debug_assert!(!state.is_null(), "RuntimeState not installed"); - // SAFETY: `state` is the boxed per-thread `RuntimeState`; the embedded - // `SSLContextCache` has stable address for the VM lifetime. - unsafe { core::ptr::addr_of_mut!((*state).ssl_ctx_cache).cast::() } - } - unsafe fn ssl_ctx_get_or_create( - cache: *mut c_void, - opts: &bun_uws::us_bun_socket_context_options_t, - err: &mut bun_uws::create_bun_socket_error_t, - ) -> Option { - // SAFETY: `cache` is `&runtime_state().ssl_ctx_cache`. - let cache = unsafe { &mut *cache.cast::() }; - cache.get_or_create_opts(opts, err) - } fn ssl_config_from_js(global: &JSGlobalObject, value: JSValue) -> *mut c_void { - use crate::socket::SSLConfigFromJs; - match crate::socket::SSLConfig::from_js(global.bun_vm_ref(), global, value, false) { - Ok(Some(cfg)) => bun_core::heap::into_raw(Box::new(cfg)).cast::(), - Ok(None) => core::ptr::null_mut(), + use crate::socket::ssl_config::ParsedTls; + let vm = global.bun_vm_ref(); + match ParsedTls::from_js(vm, global, value, false) { + // `sslmode` already asked for TLS, so `tls: {}` is `tls: true`. + Ok(parsed) => { + bun_core::heap::into_raw(Box::new(parsed.or_defaults(vm, false))).cast::() + } Err(bun_jsc::JsError::OutOfMemory) => { let _ = global.throw_out_of_memory(); core::ptr::null_mut() @@ -264,8 +251,6 @@ mod sql_hooks { timer_heap, timer_insert, timer_remove, - ssl_ctx_cache, - ssl_ctx_get_or_create, ssl_config_from_js, ssl_config_free, ssl_config_as_usockets_client, diff --git a/src/runtime/ipc.rs b/src/runtime/ipc.rs index e0b1b5cd49a3..4b07634946d6 100644 --- a/src/runtime/ipc.rs +++ b/src/runtime/ipc.rs @@ -762,19 +762,27 @@ impl CallbackList { Ok(()) } - fn call_next_tick(&mut self, global: &JSGlobalObject) -> JsResult<()> { + /// `sent` false: the peer never took the message, and each callback gets a `write EPIPE` error of its own. + fn call_next_tick(&mut self, global: &JSGlobalObject, sent: bool) -> JsResult<()> { + let result = || { + if sent { + JSValue::NULL + } else { + broken_pipe_error(global) + } + }; match self { CallbackList::AckNack => {} CallbackList::None => {} CallbackList::Callback(cb) => { - JSValue::call_next_tick_1(cb.value(), global, JSValue::NULL)?; + JSValue::call_next_tick_1(cb.value(), global, result())?; // Assignment drops the old `Callback(cb)` guard → unprotect. *self = CallbackList::None; } CallbackList::CallbackArray(arr) => { let mut iter = arr.value().array_iterator(global)?; while let Some(item) = iter.next()? { - JSValue::call_next_tick_1(item, global, JSValue::NULL)?; + JSValue::call_next_tick_1(item, global, result())?; } // Assignment drops the old `CallbackArray(arr)` guard → unprotect. *self = CallbackList::None; @@ -784,6 +792,21 @@ impl CallbackList { } } +fn broken_pipe_error(global: &JSGlobalObject) -> JSValue { + #[cfg(windows)] + let errno = uv::UV_EPIPE; + #[cfg(not(windows))] + let errno = -(bun_sys::E::EPIPE as c_int); + jsc::SystemError { + errno, + code: BunString::static_("EPIPE"), + message: BunString::static_("write EPIPE"), + syscall: BunString::static_("write"), + ..Default::default() + } + .to_error_instance(global) +} + pub(crate) struct SendHandle { // when a message has a handle, make sure it has a new SendHandle - so that if we retry sending it, // we only retry sending the message with the handle, not the original message. @@ -808,11 +831,11 @@ impl SendHandle { } } } - let _ = self.callbacks.call_next_tick(global); // TODO: properly propagate exception upwards + let _ = self.callbacks.call_next_tick(global, true); // TODO: properly propagate exception upwards // self drops here → data/callbacks/handle Drop. } - pub(crate) fn abort_unsent(self, global: &JSGlobalObject) { + pub(crate) fn abort_unsent(mut self, global: &JSGlobalObject, answer: bool) { if let Some(handle) = &self.handle { if handle.close_on_complete { let js = handle.js.value(); @@ -821,6 +844,11 @@ impl SendHandle { } } } + if answer { + if let Err(err) = self.callbacks.call_next_tick(global, false) { + let _ = bun_jsc::task::report_error_or_terminate(global, err); + } + } } } @@ -1185,32 +1213,42 @@ impl SendQueue { // SAFETY: caller contract — the queued task owns a ref on `this`. let sq = unsafe { &*this }; sq.deferred_scheduled.set(false); - if sq.pending_close.replace(false) { - log!("SendQueue#closeSocketTask"); - sq.close_socket(CloseReason::Normal, CloseFrom::User); - } - if sq.pending_after_close.replace(false) { - log!("SendQueue#_onAfterIPCClosed"); - if !sq.close_event_sent.replace(true) { - let global = sq.get_global_this(); - if let Some(item) = sq.waiting_for_ack.with_mut(|w| w.take()) { - item.complete(&global); - } - // on_write_complete already dequeued everything fully written; the rest was never delivered. - for item in sq.queue.with_mut(std::mem::take) { - item.abort_unsent(&global); - } - if let Some(owner) = sq.owner.get() { - owner.handle_ipc_close(); - } - } - } + sq.run_pending(); } // Release the task's ref; the SendQueue may be freed here. // SAFETY: `this` is live and owns the ref taken at schedule. unsafe { ::deref(this) }; } + /// Does now what the queued task would do. A pending flag means that task is still queued, and its ref keeps `self` alive through the callbacks. + pub(crate) fn run_pending(&self) { + if self.pending_close.replace(false) { + log!("SendQueue#closeSocketTask"); + self.close_socket(CloseReason::Normal, CloseFrom::User); + } + if self.pending_after_close.replace(false) { + log!("SendQueue#_onAfterIPCClosed"); + if !self.close_event_sent.replace(true) { + let global = self.get_global_this(); + // Like node, a message held back until an earlier handle is acked gets no answer when the channel closes. + let mut held_back = false; + if let Some(item) = self.waiting_for_ack.with_mut(|w| w.take()) { + item.complete(&global); + held_back = true; + } + // on_write_complete already dequeued everything fully written; the rest was never delivered. + for item in self.queue.with_mut(std::mem::take) { + let has_handle = item.handle.is_some(); + item.abort_unsent(&global, !held_back); + held_back |= has_handle; + } + if let Some(owner) = self.owner.get() { + owner.handle_ipc_close(); + } + } + } + } + /// `Taskable::release_unrun`: a scheduled deferred task that will never /// run still owns a ref; drop it (skipping the JS callbacks). /// diff --git a/src/runtime/ipc_host.bind.ts b/src/runtime/ipc_host.bind.ts index fbae78f1b4e2..ef76f25a363f 100644 --- a/src/runtime/ipc_host.bind.ts +++ b/src/runtime/ipc_host.bind.ts @@ -10,6 +10,8 @@ export const emitProcessErrorEvent = b.fn({ capability: "js", }); +export const emitProcessDisconnectEvent = b.fn({ export: "Bun__IPC__emitProcessDisconnectEvent", capability: "js" }); + /** Only `null` is this process. */ export const MessageTarget = b.union({ subprocess: Subprocess, diff --git a/src/runtime/ipc_host.rs b/src/runtime/ipc_host.rs index c0d8eb76110c..058b7a6c6970 100644 --- a/src/runtime/ipc_host.rs +++ b/src/runtime/ipc_host.rs @@ -54,6 +54,10 @@ pub(crate) fn emit_process_error_event<'a>(cx: &mut JsScope<'a>, error: JsLocal< Process__emitErrorEvent(cx.global(), error.to_js()); } +pub(crate) fn emit_process_disconnect_event(cx: &mut JsScope<'_>) { + Process__emitDisconnectEvent(cx.global()); +} + fn do_send_err( global_object: &JSGlobalObject, callback: JSValue, @@ -167,10 +171,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), @@ -441,7 +445,17 @@ impl IPCInstance { let event_loop = vm.event_loop_mut(); crate::jsc_hooks::ipc_child_singleton_deinit(); event_loop.enter(); - Process__emitDisconnectEvent(vm.global()); + // On the next tick, so that it comes after the callbacks of the messages that never went out. + let emit = bun_jsc::JSFunction::create( + vm.global(), + "", + crate::generated_bindgen::ipc_host::Bun__IPC__emitProcessDisconnectEvent, + 0, + Default::default(), + ); + if let Err(err) = JSValue::call_next_tick_1(emit, vm.global(), JSValue::UNDEFINED) { + let _ = bun_jsc::task::report_error_or_terminate(vm.global(), err); + } event_loop.exit(); // Group is embedded in RareData and shared with subprocess IPC; nothing // to free here. diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index d5167752bcc7..d431d859f7f7 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -45,6 +45,7 @@ use bun_resolver::fs as Fs; use bun_resolver::node_fallbacks; use bun_resolver::{GlobalCache, ResultUnion as ResolveResultUnion}; +use crate::api::SSLContextCache::SSLContextCache; use crate::cli::upgrade_command::FileSystemTmpdirExt as _; use crate::timer; use crate::webcore::blob::BlobExt as _; @@ -72,7 +73,7 @@ pub(crate) struct RuntimeState { pub(crate) sql_rare: bun_sql_jsc::jsc::RareData, /// `RareData.ssl_ctx_cache` — concrete digest-keyed weak `SSL_CTX*` cache. /// Same cycle-break story as `sql_rare`. - pub(crate) ssl_ctx_cache: crate::api::SSLContextCache::SSLContextCache, + pub(crate) ssl_ctx_cache: SSLContextCache, /// `RareData.global_dns_data` — per-VM resolver + c-ares channel. /// Lazy-init by [`crate::dns_jsc::global_resolver`]; freed when this box /// drops in [`deinit_runtime_state`]. @@ -97,6 +98,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>, @@ -199,6 +202,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> { @@ -248,14 +262,6 @@ pub(crate) unsafe fn runtime_state_of(vm: *mut VirtualMachine) -> *mut RuntimeSt pub(crate) fn default_client_ssl_ctx(vm: &VirtualMachine) -> *mut bun_uws::SslCtx { if vm.as_mut().rare_data().default_client_ssl_ctx.is_none() { let mut err = bun_uws::create_bun_socket_error_t::none; - let state = runtime_state(); - debug_assert!( - !state.is_null(), - "default_client_ssl_ctx before init_runtime_state" - ); - // SAFETY: per-thread `RuntimeState`; `ssl_ctx_cache` has a stable - // address for the VM's lifetime and is only touched from the JS thread. - let cache = unsafe { &mut (*state).ssl_ctx_cache }; // Mode-neutral CTX (VERIFY_NONE). `us_internal_ssl_attach` overrides // each client SSL to VERIFY_PEER + the shared bundled-root store, so // `new WebSocket("wss://…")` (which shares this CTX and defaults to @@ -263,12 +269,8 @@ 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() { - cache.get_or_create(&crate::socket::tls_true_defaults(vm, false), &mut err) - } else { - cache.get_or_create_opts(&Default::default(), &mut err) - }; - match ctx { + let config = vm.tls_config(Default::default(), false); + match SSLContextCache::get_or_create(&config, &mut err) { Some(ctx) => vm.as_mut().rare_data().default_client_ssl_ctx = Some(ctx), None => bun_core::Output::panic(format_args!( "default client SSL_CTX init failed: {}", @@ -302,17 +304,10 @@ fn ssl_ctx_cache_get_or_create( opts: &bun_uws::SocketContext::BunSocketContextOptions, err: &mut bun_uws::create_bun_socket_error_t, ) -> Option { - // The WebSocket client alone calls it. + // The WebSocket client and the SQL clients call it. bun_removable_features::removable_definition!(WebSocket, ssl_ctx_cache_get_or_create); - let state = runtime_state(); - debug_assert!( - !state.is_null(), - "ssl_ctx_cache_get_or_create before init_runtime_state" - ); - // SAFETY: per-thread `RuntimeState`; `ssl_ctx_cache` has a stable - // address for the VM's lifetime and is only touched from the JS thread. - let cache = unsafe { &mut (*state).ssl_ctx_cache }; - cache.get_or_create_opts(opts, err) + bun_removable_features::removable_definition!(Sql, ssl_ctx_cache_get_or_create); + SSLContextCache::get_or_create_opts(opts, err) } // ════════════════════════════════════════════════════════════════════════════ @@ -389,6 +384,7 @@ unsafe fn init_runtime_state( ) } }, + request_pools: crate::server::RequestPools::default(), wake_ctx: None, cron_jobs: Vec::new(), matcher_pass: Cell::new(ptr::null()), @@ -1119,8 +1115,6 @@ unsafe fn auto_tick(vm: *mut VirtualMachine) { || uv_idle_started || !unsafe { &*el }.immediate_tasks.is_empty() || unsafe { &*el }.has_pending_tasks(); - // Fold the QUIC deadline into the poll timeout. - let quic_next_tick_us = loop_.internal_loop_data.quic_next_tick_us(); let mut timespec = bun_core::Timespec { sec: 0, nsec: 0 }; if loop_.is_active() { // Before `get_timeout`: the first call arms the idle GC timer, and a @@ -1147,7 +1141,6 @@ unsafe fn auto_tick(vm: *mut VirtualMachine) { &mut (*state).timer, &mut timespec, has_pending_immediate, - quic_next_tick_us, vm.cast(), &mut now, ) @@ -1258,7 +1251,6 @@ unsafe fn auto_tick_active(vm: *mut VirtualMachine) { || uv_idle_started || !unsafe { &*el }.immediate_tasks.is_empty() || unsafe { &*el }.has_pending_tasks(); - let quic_next_tick_us = loop_.internal_loop_data.quic_next_tick_us(); let mut timespec = bun_core::Timespec { sec: 0, nsec: 0 }; if loop_.is_active() { // Before `get_timeout` — see the matching call in `auto_tick`. @@ -1275,7 +1267,6 @@ unsafe fn auto_tick_active(vm: *mut VirtualMachine) { &mut (*state).timer, &mut timespec, has_pending_immediate, - quic_next_tick_us, vm.cast(), &mut now, ) @@ -1444,8 +1435,7 @@ fn name_text_of_blob_url(blob_id: &[u8], vm: &VirtualMachine) -> Option> name } -/// `Response::get_blob_without_call_frame` / -/// `Request::get_blob_without_call_frame`. Downcasts +/// `blob()` of a `Response` or `Request`. Downcasts /// `value` to a `Response`/`Request` (whose data shapes + `BodyMixin` impl live /// in this crate, above `bun_jsc` / `bun_js_parser_jsc`) and returns its body /// Blob wrapped in a resolved Promise; `Ok(None)` to fall through to the @@ -1454,12 +1444,13 @@ fn body_mixin_get_blob( value: JSValue, global: &JSGlobalObject, ) -> bun_jsc::JsResult> { - use crate::webcore::body::BodyMixin as _; + use crate::webcore::body::{Action, BodyMixin as _}; + let cx = global.js_thread_of_caller(); if let Some(resp) = value.as_class_ref::() { - return Ok(Some(resp.get_blob_without_call_frame(global)?)); + return Ok(Some(resp.read_body(&cx, Action::GetBlob)?)); } if let Some(req) = value.as_class_ref::() { - return Ok(Some(req.get_blob_without_call_frame(global)?)); + return Ok(Some(req.read_body(&cx, Action::GetBlob)?)); } Ok(None) } diff --git a/src/runtime/node/node_cluster_binding.bind.ts b/src/runtime/node/node_cluster_binding.bind.ts index ee7a621631a7..3b184e6e6d2c 100644 --- a/src/runtime/node/node_cluster_binding.bind.ts +++ b/src/runtime/node/node_cluster_binding.bind.ts @@ -23,7 +23,7 @@ export const clusterRawBind = b.fn({ capability: "js", throws: true, }); -export const clusterValidateFd = b.fn({ args: { fd: b.f64, wantDgram: b.bool }, returns: b.i32 }); +export const clusterAdoptFd = b.fn({ args: { fd: b.f64, wantDgram: b.bool }, returns: b.i32 }); export const clusterCloseHandle = b.fn({ args: { fd: b.f64 } }); /** `process[kHandle]` of a `cluster.Worker`, which takes any object as its process. */ diff --git a/src/runtime/node/node_cluster_binding.rs b/src/runtime/node/node_cluster_binding.rs index 5aa17fe62bb2..b24e40671e3c 100644 --- a/src/runtime/node/node_cluster_binding.rs +++ b/src/runtime/node/node_cluster_binding.rs @@ -213,7 +213,7 @@ pub(crate) fn handle_internal_message_primary( return Ok(()); }; - if !ipc_data.internal_msg_queue.get().is_ready() { + if !message.is_object() || !ipc_data.internal_msg_queue.get().is_ready() { return Ok(()); } @@ -304,7 +304,7 @@ pub(crate) fn should_ignore_one_disconnect_event_listener(global: &JSGlobalObjec } pub(crate) fn cluster_raw_bind<'a>( - cx: &mut JsScope<'a>, + _cx: &mut JsScope<'a>, address_type: BindAddressType<'a>, address: BindAddress<'a>, port: f64, @@ -314,7 +314,6 @@ pub(crate) fn cluster_raw_bind<'a>( #[cfg(windows)] { - let _ = cx; match address_type { BindAddressType::Udp(_) => return Ok(BindResult::Errno(-bun_sys::UV_E::NOTSUP)), BindAddressType::Family(family) if family == -1.0 => { @@ -391,62 +390,28 @@ pub(crate) fn cluster_raw_bind<'a>( BindAddressType::Family(_) | BindAddressType::Ignored(_) => (false, 4), }; - fn last_neg_errno() -> BindResult { - BindResult::Errno(-bun_core::ffi::errno()) - } - - fn close_fd(fd: c_int) { - let _ = bun_sys::FdExt::close_allowing_standard_io(bun_sys::Fd::from_native(fd), None); - } - - fn set_cloexec_nonblock(fd: c_int) { - let fd = bun_sys::Fd::from_native(fd); - let _ = bun_sys::set_close_on_exec(fd); - let _ = bun_sys::set_nonblocking(fd); - } + use bun_uws::udp::raw::{bsd_close_socket, bsd_create_socket}; if atype == -1 { - let path_bytes = match address { - BindAddress::Text(path) => path, - BindAddress::Ignored(value) => { - return Err(cx.global().throw_invalid_argument_type_value( - "address", - "string", - value.to_js(), - )); - } + let BindAddress::Text(path_bytes) = address else { + return Ok(BindResult::Errno(-(libc::EINVAL))); }; - let mut sun: libc::sockaddr_un = bun_core::ffi::zeroed(); - sun.sun_family = libc::AF_UNIX as libc::sa_family_t; - if path_bytes.len() >= sun.sun_path.len() { - return Ok(BindResult::Errno(-(libc::ENAMETOOLONG))); - } - for (i, b) in path_bytes.iter().enumerate() { - sun.sun_path[i] = *b as _; - } - // SAFETY: socket/bind FFI; `sun` is a live `sockaddr_un` and `len` is at most its size. - unsafe { - let fd = libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0); - if fd < 0 { - return Ok(last_neg_errno()); - } - set_cloexec_nonblock(fd); - // Abstract names (leading NUL) are length-delimited: a padded length binds a different name. - let len = if path_bytes.first() == Some(&0) { - core::mem::offset_of!(libc::sockaddr_un, sun_path) + path_bytes.len() - } else { - core::mem::size_of::() - } as libc::socklen_t; - if libc::bind(fd, (&raw const sun).cast(), len) != 0 { - let e = last_neg_errno(); - close_fd(fd); - return Ok(e); - } - return Ok(BindResult::Handle(BoundHandle { - fd: fd as u64, - port: -1, - })); + let mut err: c_int = 0; + // SAFETY: `path_bytes` is readable for its length, and `err` is a live local. + let fd = unsafe { + bun_uws::socket_transfer::bsd_create_bound_socket_unix( + path_bytes.as_ptr().cast(), + path_bytes.len(), + &raw mut err, + ) + }; + if fd < 0 { + return Ok(BindResult::Errno(-err)); } + return Ok(BindResult::Handle(BoundHandle { + fd: fd as u64, + port: -1, + })); } let family: c_int = if atype == 6 { @@ -498,11 +463,11 @@ pub(crate) fn cluster_raw_bind<'a>( // locals of the length passed, and every caller's `ss_len` is the size of a // `sockaddr_in` or `sockaddr_in6`, which fits in `ss`. unsafe { - let fd = libc::socket(family, socktype, 0); + let mut err: c_int = 0; + let fd = bsd_create_socket(family, socktype, 0, &mut err); if fd < 0 { - return Err(-bun_core::ffi::errno()); + return Err(-err); } - set_cloexec_nonblock(fd); let one: c_int = 1; let one_ptr = (&raw const one).cast::(); @@ -555,7 +520,7 @@ pub(crate) fn cluster_raw_bind<'a>( if libc::bind(fd, core::ptr::from_ref(ss).cast(), ss_len) != 0 { let e = -bun_core::ffi::errno(); - close_fd(fd); + bsd_close_socket(fd); return Err(e); } Ok(fd) @@ -692,8 +657,8 @@ pub(crate) fn cluster_raw_bind<'a>( } } -/// Returns 0 if `fd` is an unconnected socket of the wanted type, or a negative errno. -pub(crate) fn cluster_validate_fd(fd: f64, want_dgram: bool) -> i32 { +/// Takes ownership of `fd`, an unconnected socket of the wanted type that nothing in this process owns. Returns 0 or a negative errno. +pub(crate) fn cluster_adopt_fd(fd: f64, want_dgram: bool) -> i32 { #[cfg(not(windows))] { let number = fd; @@ -710,24 +675,15 @@ pub(crate) fn cluster_validate_fd(fd: f64, want_dgram: bool) -> i32 { } else { libc::SOCK_STREAM }; - let mut ty: libc::c_int = 0; - let mut len = core::mem::size_of::() as libc::socklen_t; - // SAFETY: plain getsockopt on a caller-supplied fd; out-params are live locals, and `len` - // is the size of `ty`. - let rc = unsafe { - libc::getsockopt( - fd, - libc::SOL_SOCKET, - libc::SO_TYPE, - (&raw mut ty).cast(), - &raw mut len, - ) - }; - // node's createServerHandle: EINVAL for anything that cannot listen (e.g. a connected stdio socketpair), fd left untouched. - if rc != 0 || ty != wanted { - return -bun_sys::UV_E::INVAL; + if bun_uws::udp::raw::bsd_adopt_socket(fd, wanted) != 0 { + // node's createServerHandle: EINVAL for anything that cannot listen, fd left untouched. + return if bun_core::ffi::errno() == libc::EEXIST { + -bun_sys::UV_E::EXIST + } else { + -bun_sys::UV_E::INVAL + }; } - if ty == libc::SOCK_STREAM { + if !want_dgram { // SAFETY: getpeername only writes within `peer_len`, the size of `peer`. let connected = unsafe { let mut peer: libc::sockaddr_storage = bun_core::ffi::zeroed(); @@ -736,6 +692,7 @@ pub(crate) fn cluster_validate_fd(fd: f64, want_dgram: bool) -> i32 { libc::getpeername(fd, (&raw mut peer).cast(), &raw mut peer_len) == 0 }; if connected { + bun_uws::udp::raw::bsd_disown_socket(fd); return -bun_sys::UV_E::INVAL; } } @@ -749,19 +706,7 @@ pub(crate) fn cluster_validate_fd(fd: f64, want_dgram: bool) -> i32 { } pub(crate) fn cluster_close_handle(fd: f64) { - #[cfg(windows)] - { - if fd.is_finite() && fd >= 0.0 { - bun_uws::socket_transfer::bsd_close_socket( - fd as u64 as bun_uws::LIBUS_SOCKET_DESCRIPTOR, - ); - } - } - #[cfg(not(windows))] - { - let fd = fd as i32; - if fd >= 0 { - let _ = bun_sys::FdExt::close_allowing_standard_io(bun_sys::Fd::from_native(fd), None); - } + if fd.is_finite() && fd >= 0.0 { + bun_uws::socket_transfer::bsd_close_socket(fd as u64 as bun_uws::LIBUS_SOCKET_DESCRIPTOR); } } diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index 3f5f900a5b6a..a8348fe58e6b 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -7720,7 +7720,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 @@ -7791,7 +7795,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.bind.ts b/src/runtime/node/node_http_binding.bind.ts index c34a0b3294dd..dac80bf8c96b 100644 --- a/src/runtime/node/node_http_binding.bind.ts +++ b/src/runtime/node/node_http_binding.bind.ts @@ -1,7 +1,7 @@ import * as b from "../../codegen/bindgen-lib.ts"; import { webSocketDataCell } from "../../codegen/builtins/host/native-cells.ts"; -import { NodeHTTPResponse } from "../server/NodeHTTPResponse.bind.ts"; import { DebugHTTPSServer, DebugHTTPServer, HTTPSServer, HTTPServer, ServerWebSocket } from "../server/server.bind.ts"; +import { SocketHandle } from "../socket/socket_body.bind.ts"; export default b.module({ rustPath: "crate::node::node_http_binding" }); @@ -19,8 +19,34 @@ export const BunServer = b.union({ debugHttp: DebugHTTPServer, debugHttps: DebugHTTPSServer, }); -export const getBunServerAllClosedPromise = b.fn({ args: { server: BunServer }, returns: b.any, capability: "js" }); -export const getBunServerOpenCount = b.fn({ args: { server: BunServer }, returns: b.f64 }); +/** `handlers` is `node:net`'s handler table for accepted sockets. Every `req.socket._handle` of `server` calls it, and starts with `data`. `alpnProtocols` is in wire format, empty for none. */ +export const setBunServerSocketHandlers = b.fn({ + args: { + server: BunServer, + handlers: b.any, + alpnProtocols: b.bytes({ from: ["buffer"], held: "handle" }), + data: b.any, + }, + capability: "js", + throws: true, +}); + +/** `lenientHttpFlags`: bit 0 is llhttp's LENIENT_HEADERS, bit 1 its LENIENT_TRANSFER_ENCODING. */ +const appFlags = { lenientHttpFlags: b.u8, httpAllowHalfOpen: b.bool } as const; +/** What an `http.Server` can change while it listens. */ +export const setServerAppFlags = b.fn({ args: { server: BunServer, ...appFlags } }); +/** 0 means the option is not set. */ +export const setServerMaxHeadersCount = b.fn({ args: { server: BunServer, maxHeadersCount: b.u32 } }); +/** `onClientError(socketHandle, errorCode, rawPacket)`. */ +export const setServerCustomOptions = b.fn({ + args: { + server: BunServer, + ...appFlags, + maxHeaderSize: b.f64.loose, + onClientError: b.callable, + }, + capability: "js", +}); export const DeflateParams = b.dictionary({ serverMaxWindowBits: b.f64.loose.nullable.default(15), @@ -28,10 +54,17 @@ export const DeflateParams = b.dictionary({ serverNoContextTakeover: b.bool.loose.default(false), clientNoContextTakeover: b.bool.loose.default(false), }); -export const upgradeNodeHTTPResponseToWebSocket = b.fn({ +/** The HTTP side of `socket`'s connection while uWS owns it, and `undefined` for every other value. */ +export const httpConnectionOf = b.fn({ args: { socket: SocketHandle }, returns: b.any, capability: "heap" }); + +/** Whether the WebSocket that `upgradeSocketToWebSocket()` made of `socket` has closed, and the kernel had taken everything it queued. */ +export const closedWithAllDataSent = b.fn({ args: { socket: SocketHandle }, returns: b.bool }); + +/** `socket` is `_handle` of the socket given to `handleUpgrade()` of `ws`, after the connection has left HTTP. */ +export const upgradeSocketToWebSocket = b.fn({ args: { - /** `kInternalSocketData` of the socket given to `handleUpgrade()` of `ws`, which can be any `Duplex`. */ - response: NodeHTTPResponse.otherAsNull, + server: BunServer, + socket: SocketHandle, websocket: b.any, handlers: b.any, handshake: b.string.utf8, diff --git a/src/runtime/node/node_http_binding.rs b/src/runtime/node/node_http_binding.rs index 33200070b97f..b941fc23fe09 100644 --- a/src/runtime/node/node_http_binding.rs +++ b/src/runtime/node/node_http_binding.rs @@ -1,53 +1,109 @@ -//! `node:http` native binding — `getBunServerAllClosedPromise` / -//! `getBunServerOpenCount`, and the `ws` package's native server -//! transport: `upgradeNodeHTTPResponseToWebSocket` / `sendWebSocketFrame`. +//! `node:http`'s native binding: the server's options, and the `ws` package's server transport. -use bun_jsc::bindgen::{JsLocal, JsScope}; +use bun_jsc::bindgen::{Callable, JsBytes, JsHeap, JsLocal, JsScope}; 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}; -use crate::server::{NodeHTTPResponse, ServerWebSocket}; +use crate::generated_bindgen::socket_body::SocketHandle; +use crate::server::{AnyServer, ServerWebSocket}; +use crate::socket::node_http_socket::{self, WebSocketUpgrade}; -pub(crate) fn get_bun_server_all_closed_promise<'a>( +macro_rules! with_server { + ($server:expr, |$s:ident| $body:expr) => { + match $server { + BunServer::Http($s) => $body, + BunServer::Https($s) => $body, + BunServer::DebugHttp($s) => $body, + BunServer::DebugHttps($s) => $body, + } + }; +} + +pub(crate) fn set_server_app_flags( + server: BunServer<'_>, + lenient_http_flags: u8, + http_allow_half_open: bool, +) { + with_server!(server, |server| server + .set_node_http_flags(lenient_http_flags, http_allow_half_open)) +} + +pub(crate) fn set_server_max_headers_count(server: BunServer<'_>, max_headers_count: u32) { + with_server!(server, |server| server + .set_max_headers_count(max_headers_count)) +} + +pub(crate) fn set_server_custom_options<'a>( cx: &mut JsScope<'a>, server: BunServer<'a>, -) -> JSValue { - let global = cx.global(); - match server { - BunServer::Http(server) => server.get_all_closed_promise(global), - BunServer::Https(server) => server.get_all_closed_promise(global), - BunServer::DebugHttp(server) => server.get_all_closed_promise(global), - BunServer::DebugHttps(server) => server.get_all_closed_promise(global), - } + lenient_http_flags: u8, + http_allow_half_open: bool, + max_header_size: f64, + on_client_error: JsLocal<'a, Callable>, +) { + // Node's static_cast(double) is undefined for these: NaN or below 1 selects the default limit (0), 2^64 or more selects none. + let max_header_size = if max_header_size >= 1.0 { + max_header_size as u64 + } else { + 0 + }; + with_server!(server, |server| { + server.set_node_http_flags(lenient_http_flags, http_allow_half_open); + server.set_max_http_header_size(max_header_size); + server.set_on_client_error(cx.global(), on_client_error.to_js()); + }) } -/// 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 { - macro_rules! open_count { +pub(crate) fn set_bun_server_socket_handlers<'a>( + cx: &mut JsScope<'a>, + server: BunServer<'a>, + handlers: JsLocal<'a>, + alpn_protocols: JsBytes<'a>, + data: JsLocal<'a>, +) -> JsResult<()> { + let alpn_protocols = alpn_protocols.get(&cx.heap); + let alpn_protocols = (!alpn_protocols.is_empty()).then(|| Box::from(alpn_protocols)); + let (global, handlers, data) = (cx.global(), handlers.to_js(), data.to_js()); + macro_rules! apply { ($server:expr) => { - f64::from($server.pending_requests.get()) - + f64::from($server.active_sockets_count()) - + f64::from($server.active_connection_count.get()) + $server.set_node_socket_handlers(global, handlers, alpn_protocols, data) }; } match server { - BunServer::Http(server) => open_count!(server), - BunServer::Https(server) => open_count!(server), - BunServer::DebugHttp(server) => open_count!(server), - BunServer::DebugHttps(server) => open_count!(server), + BunServer::Http(server) => apply!(server), + BunServer::Https(server) => apply!(server), + BunServer::DebugHttp(server) => apply!(server), + BunServer::DebugHttps(server) => apply!(server), } } -/// `(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`. -/// Returns `undefined` without writing anything if `response` isn't a native response or can no -/// longer be upgraded. -pub(crate) fn upgrade_node_http_response_to_web_socket<'a>( +pub(crate) fn http_connection_of<'a>(heap: &JsHeap<'a>, socket: SocketHandle<'a>) -> JsLocal<'a> { + let connection = match socket { + SocketHandle::Tcp(socket) => node_http_socket::http_connection_of(socket), + SocketHandle::Tls(socket) => node_http_socket::http_connection_of(socket), + SocketHandle::Ignored(_) => None, + }; + connection.map_or(JsLocal::UNDEFINED, |connection| { + JsLocal::new(heap, connection) + }) +} + +pub(crate) fn closed_with_all_data_sent(socket: SocketHandle<'_>) -> bool { + let flags = match socket { + SocketHandle::Tcp(socket) => socket.flags.get(), + SocketHandle::Tls(socket) => socket.flags.get(), + SocketHandle::Ignored(_) => return false, + }; + flags.contains(crate::socket::SocketFlags::CLOSED_WITH_ALL_DATA_SENT) +} + +/// Makes `socket`'s connection a WebSocket of `server` and writes `handshake` as the response head. Returns the `ServerWebSocket`, or `undefined` with nothing written if `socket` isn't an open connection of `server` that has left HTTP. +pub(crate) fn upgrade_socket_to_web_socket<'a>( cx: &mut JsScope<'a>, - response: Option<&NodeHTTPResponse>, + server: BunServer<'a>, + socket: SocketHandle<'a>, websocket: JsLocal<'a>, handlers: JsLocal<'a>, handshake: &[u8], @@ -58,34 +114,47 @@ pub(crate) fn upgrade_node_http_response_to_web_socket<'a>( const AUTO_PONG: i32 = 1 << 0; const SKIP_UTF8_VALIDATION: i32 = 1 << 1; - let Some(response) = response else { - return Ok(JSValue::UNDEFINED); - }; let global = cx.global(); - if !handshake.ends_with(b"\r\n\r\n") { return Err(global .throw_invalid_arguments(format_args!("handshake must be a complete response head"))); } - let deflate_params = deflate.map(|deflate| PerMessageDeflateParams { - server_max_window_bits: deflate.server_max_window_bits as i32, - client_max_window_bits: deflate.client_max_window_bits as i32, - server_no_context_takeover: deflate.server_no_context_takeover, - client_no_context_takeover: deflate.client_no_context_takeover, - }); - - Ok(response - .upgrade_for_ws_package( - global, - websocket.to_js(), - handlers.to_js(), - handshake, - max_payload, - flags & AUTO_PONG != 0, - flags & SKIP_UTF8_VALIDATION != 0, - deflate_params.as_ref(), - )? - .unwrap_or(JSValue::UNDEFINED)) + let upgrade = WebSocketUpgrade { + websocket: websocket.to_js(), + handlers: handlers.to_js(), + handshake, + max_payload_length: max_payload, + auto_pong: flags & AUTO_PONG != 0, + skip_utf8_validation: flags & SKIP_UTF8_VALIDATION != 0, + deflate_params: deflate.map(|deflate| PerMessageDeflateParams { + server_max_window_bits: deflate.server_max_window_bits as i32, + client_max_window_bits: deflate.client_max_window_bits as i32, + server_no_context_takeover: deflate.server_no_context_takeover, + client_no_context_takeover: deflate.client_no_context_takeover, + }), + }; + macro_rules! adopt { + ($server:expr, $socket:expr) => { + $socket.become_web_socket( + global, + AnyServer::from(core::ptr::from_ref($server)), + $server + .node_socket_config + .get() + .as_ref() + .map(|config| &config.handlers), + &upgrade, + ) + }; + } + Ok(match (server, socket) { + (BunServer::Http(server), SocketHandle::Tcp(socket)) => adopt!(server, socket), + (BunServer::DebugHttp(server), SocketHandle::Tcp(socket)) => adopt!(server, socket), + (BunServer::Https(server), SocketHandle::Tls(socket)) => adopt!(server, socket), + (BunServer::DebugHttps(server), SocketHandle::Tls(socket)) => adopt!(server, socket), + _ => Ok(None), + }? + .unwrap_or(JSValue::UNDEFINED)) } /// `(handle, data, opcode, fin, compress)`: sends one frame on a `ServerWebSocket`. `opcode` is diff --git a/src/runtime/node/node_net_binding.bind.ts b/src/runtime/node/node_net_binding.bind.ts index 962a33d7af46..85fac4a85c5b 100644 --- a/src/runtime/node/node_net_binding.bind.ts +++ b/src/runtime/node/node_net_binding.bind.ts @@ -5,20 +5,6 @@ import { SocketHandle } from "../socket/socket_body.bind.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.returning(newSocket.spec), diff --git a/src/runtime/node/node_net_binding.rs b/src/runtime/node/node_net_binding.rs index d1b34655e479..0bc346d6bb57 100644 --- a/src/runtime/node/node_net_binding.rs +++ b/src/runtime/node/node_net_binding.rs @@ -1,79 +1,14 @@ -// -// - -use core::cell::Cell; -use core::sync::atomic::{AtomicBool, Ordering}; - -use bun_io::KeepAlive; use bun_jsc::bindgen::{JsLocal, JsScope}; -use bun_jsc::{self as jsc, JSGlobalObject, JSValue, JsCell, JsResult}; -use bun_uws as uws; +use bun_jsc::{self as jsc, JSGlobalObject, JSValue, JsResult}; use crate::generated_bindgen::socket_body::SocketHandle; -use crate::socket::{Listener, NativeCallbacks, NewSocket, SocketFlags}; - -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 -} +use crate::socket::{Listener, NewSocket, SocketFlags}; pub(crate) fn new_detached_socket(cx: &mut JsScope<'_>, is_ssl: bool) -> JSValue { - // Only `socket`, `ref_count`, `protos`, `handlers` are - // specified; the rest take their struct defaults. fn make(global: &JSGlobalObject) -> JSValue { - let socket = NewSocket::::new(NewSocket:: { - socket: Cell::new(uws::NewSocketHandler::::DETACHED), - ref_count: bun_ptr::RefCount::init(), - protos: JsCell::new(None), - handlers: JsCell::new(None), - local_binding: JsCell::new(None), - // — defaults — - owned_ssl_ctx: JsCell::new(None), - // node:net/node:tls own server-identity (`checkServerIdentity`) - // policy in JS, so a hostname mismatch is never enforced natively. - flags: Cell::new(SocketFlags::default() | SocketFlags::DEFERS_SERVER_IDENTITY), - this_value: JsCell::new(jsc::JsRef::empty()), - poll_ref: JsCell::new(KeepAlive::init()), - ref_pollref_on_connect: Cell::new(true), - connection: JsCell::new(None), - server_name: JsCell::new(None), - buffered_data_for_node_net: Default::default(), - bytes_written: Cell::new(0), - native_callback: JsCell::new(NativeCallbacks::None), - twin: JsCell::new(None), - verify_error: JsCell::new(None), - latest_session: core::cell::Cell::new(None), - }); + let socket = NewSocket::::new(None); + // node:net and node:tls check the server's identity in JS (`checkServerIdentity`). + socket.flags.set(SocketFlags::DEFERS_SERVER_IDENTITY); // Weak while idle: `_handle` owns it, and `this_value_for_connect` pins each attempt. let value = socket.to_js(global); socket.this_value.set(jsc::JsRef::init_weak(value)); diff --git a/src/runtime/node/node_quic_binding.rs b/src/runtime/node/node_quic_binding.rs index 0a5d3a06a3c3..9b05d5a5a3a8 100644 --- a/src/runtime/node/node_quic_binding.rs +++ b/src/runtime/node/node_quic_binding.rs @@ -10,6 +10,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", @@ -24,12 +25,14 @@ const QUIC_CALLBACK_NAMES: &[&str] = &[ "SessionPathValidation", "SessionTicket", "SessionVersionNegotiation", + "StreamAvailable", "StreamBlocked", "StreamClose", "StreamCreated", "StreamDrain", "StreamHeaders", "StreamReset", + "StreamStopSending", "StreamTrailers", ]; @@ -267,6 +270,7 @@ pub(crate) fn binding(cx: &mut JsScope<'_>) -> JsResult { "WANTS_HEADERS" => 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/node_tls_binding.bind.ts b/src/runtime/node/node_tls_binding.bind.ts index b40d59900428..a259214b7b96 100644 --- a/src/runtime/node/node_tls_binding.bind.ts +++ b/src/runtime/node/node_tls_binding.bind.ts @@ -1,7 +1,12 @@ import * as b from "../../codegen/bindgen-lib.ts"; -export default b.module({ rustPath: "crate::cli::Arguments" }); +export default b.module({ rustPath: "bun_jsc::rare_data" }); -export const CAStore = b.enumeration(["bundled", "system", "openssl"]); - -export const getCAStore = b.fn({ returns: CAStore }); +/** The list given to `tls.setDefaultCACertificates()`, as PEM. `null` until it's called. */ +export const getDefaultCA = b.fn({ returns: b.array(b.string.utf8).nullable }); +export const setDefaultCA = b.fn({ args: { certs: b.array(b.string.utf8.owned) } }); +/** The `tls.getCACertificates()` types that make up `"default"` until then. */ +export const getDefaultCACertificateTypes = b.fn({ + returns: b.array(b.string.utf8), + rustPath: "bun_core::Global::default_ca_certificate_types", +}); diff --git a/src/runtime/node/quic/endpoint.rs b/src/runtime/node/quic/endpoint.rs index f41f37137a9e..e3dc60e6686e 100644 --- a/src/runtime/node/quic/endpoint.rs +++ b/src/runtime/node/quic/endpoint.rs @@ -4,6 +4,7 @@ use core::cell::Cell; use core::ffi::{c_char, c_int, c_uint, c_void}; use core::ptr::{null, null_mut}; +use std::rc::Rc; use bun_collections::smallvec::SmallVec; use bun_io::KeepAlive; @@ -13,12 +14,12 @@ use bun_jsc::{ }; use bun_lsquic_sys as lsquic; use bun_uws as uws; +use bun_uws::quic::PassPoint; -use crate::jsc_hooks::timer_all_mut as timer_all; -use crate::timer::{EventLoopTimer, EventLoopTimerState, EventLoopTimerTag}; +use crate::socket::udp_socket::get_us_error; 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; @@ -84,11 +80,9 @@ const LONG_HEADER_DCID_LEN_OFFSET: usize = 5; const LONG_HEADER_MIN_LEN: usize = 7; /// Matches Node's 1200-byte default. const DEFAULT_DATAGRAM_FRAME_SIZE: u64 = 1200; -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; @@ -110,8 +104,11 @@ pub(crate) const CLOSECONTEXT_LISTEN_FAILURE: u8 = 5; const PREFERRED_ADDRESS_USE: u64 = 1; /// Node's `DEFAULT_MAX_IDLE_TIMEOUT` (node/src/quic/transportparams.h), in the /// seconds unit `transportParams.maxIdleTimeout` uses. -const DEFAULT_MAX_IDLE_TIMEOUT_SECS: u64 = 10; +const DEFAULT_MAX_IDLE_TIMEOUT_SECS: c_uint = 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 +137,15 @@ pub(super) fn stored_addr_from_sockaddr(ptr: *const c_void) -> StoredAddr { StoredAddr::from_raw(ptr.cast(), len) } -fn conn_peer_addr(conn: 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.raw(), - 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,17 +153,36 @@ 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, } } } -/// `#[repr(C)]` so `vtable_ptr` is at offset 0 — the C shim reads it via -/// `*(us_nq_vtable**)peer_ctx`. Without it Rust may reorder fields. -#[repr(C)] +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(()) + } +} + pub(crate) struct QuicEndpoint { - /// MUST stay the first field — `ea_get_ssl_ctx`'s `peer_ctx` is the - /// QuicEndpoint pointer, and the C shim's thunk reads - /// `*(us_nq_vtable**)peer_ctx` to recover the vtable. vtable_ptr: *const lsquic::NqVtable, /// The vtable storage itself; `vtable_ptr` and `ea_stream_if_ctx` point /// into this box, so it must outlive both engines. @@ -198,26 +194,20 @@ pub(crate) struct QuicEndpoint { closing: Cell, closed: Cell, - socket: Cell>, + /// Null until the endpoint binds or closes. Owns both engines and `udp`. + driver: Cell<*mut uws::quic::Driver>, + /// Null unless bound. The `peer_ctx` of this endpoint's connections. + udp: Cell<*mut uws::quic::Udp>, bind_config: JsCell, local_addr: Cell, poll_ref: JsCell, this_value: JsCell, - /// Both can coexist on one endpoint — Node allows that. - server_engine: Cell<*mut lsquic::lsquic_engine>, - client_engine: Cell<*mut lsquic::lsquic_engine>, + /// At most one server engine, and one client engine for each distinct settings struct. + engines: JsCell>>, server_tls: JsCell>, - client_tls: JsCell>, sni_contexts: JsCell, TlsContext)>>, server_alpn_wire: JsCell>, - send_scratch: JsCell>, - /// NUL-terminated ALPN string for the client engine's `ea_alpn` (must - /// outlive the engine; lsquic stores the pointer). - client_alpn: JsCell>, - server_alpn: JsCell>, - server_is_http: Cell, - client_is_http: Cell, pub(super) server_verify_client: Cell, server_session_options: JsCell>, disable_stateless_reset: Cell, @@ -228,169 +218,131 @@ pub(crate) struct QuicEndpoint { /// never mutated afterwards. origin_blob: JsCell>, - processing: Cell, - followup_due: Cell, + /// When `process` has to run again whatever lsquic wants, on the `now_ns` clock. `u64::MAX` is never. + next_pass_at_ns: Cell, + follow_up: Cell, sessions: JsCell>, - 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>, + /// Keeps `block_list` alive. + _block_list_js: Option, block_list_allow: Cell, - pub(crate) event_loop_timer: JsCell, pending_endpoint_close: Cell, - /// Intrusive node on the loop's node:quic driver list; linked only while - /// this endpoint holds a socket. `us_nq_loop_flush_if_pending` runs the - /// process pass once per loop turn when `pending` is set. - nq_driver: JsCell, - nq_registered: Cell, /// Set while the pass runs from the microtask drain: session Closed events /// are requeued for the next loop point instead of dispatching mid-chain. pub(super) defer_closes: Cell, /// Native-entry depth, node's Session::SendPendingDataScope. Non-zero means /// 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. + /// not re-enter lsquic mid-callback. send_scope_depth: Cell, global: Cell<*const JSGlobalObject>, } -/// Mirrors `struct us_nq_driver_s` in node_quic_shim.c. Owned by the endpoint -/// it belongs to; the C side only ever links it into the loop's list. -#[repr(C)] -pub(crate) struct UsNqDriver { - next: *mut UsNqDriver, - owner: *mut c_void, - pending: c_int, +/// A handler's `schedule_process` asks for a pass after the loop's next poll. It applies a close the handler asked for, once a peer on this loop has read the handler's own flight. +#[derive(Clone, Copy, PartialEq, Eq)] +enum FollowUp { + None, + Requested, + AfterNextPoll, } -impl Default for UsNqDriver { - fn default() -> Self { - Self { - next: null_mut(), - owner: null_mut(), - pending: 0, +impl QuicEndpoint { + /// Creates the driver on first use, so an endpoint that's never used costs the loop nothing. + fn driver(&self) -> &uws::quic::Driver { + if self.driver.get().is_null() { + // SAFETY: `free_driver` runs before this endpoint is freed. + self.driver.set(unsafe { + uws::quic::Driver::create( + uws::Loop::get(), + driver_pass, + core::ptr::from_ref(self).cast_mut().cast(), + ) + }); } + uws::quic::Driver::opaque_ref(self.driver.get()) } -} -unsafe extern "C" { - fn us_nq_loop_register(loop_: *mut c_void, d: *mut UsNqDriver, owner: *mut c_void); - fn us_nq_loop_unregister(loop_: *mut c_void, d: *mut UsNqDriver); -} - -impl QuicEndpoint { - /// Links this endpoint into the loop's driver list. Idempotent. - fn link_loop_driver(&self) { - if self.nq_registered.replace(true) { - return; - } - // SAFETY: the node lives in `self` and is only reachable through the - // list, which `unlink_loop_driver` clears before the endpoint dies. - unsafe { - us_nq_loop_register( - uws::Loop::get().cast(), - self.nq_driver.as_ptr(), - core::ptr::from_ref(self).cast_mut().cast(), - ); + fn free_driver(&self) { + let driver = self.driver.replace(null_mut()); + if !driver.is_null() { + // SAFETY: `driver` came from `Driver::create`, and the field no longer has it. + unsafe { uws::quic::Driver::free(driver) }; } } - /// Unlinks from the loop's driver list. Idempotent; must run before the - /// endpoint can be freed so the walk never reaches dead memory. - fn unlink_loop_driver(&self) { - if !self.nq_registered.replace(false) { - return; + /// Returns the µs until the next pass is due, or -1. + fn next_pass_us(&self) -> c_int { + if self.closed.get() { + return if self.pending_endpoint_close.get() { + 0 + } else { + -1 + }; + } + let lsquic_us = self.driver().next_tick_us(); + let own_us = match self.next_pass_at_ns.get() { + u64::MAX => return lsquic_us, + at => (at.saturating_sub(now_ns()) / 1000).min(c_int::MAX as u64) as c_int, + }; + if lsquic_us < 0 { + own_us + } else { + own_us.min(lsquic_us) } - // SAFETY: registered by `link_loop_driver` on this same loop. - unsafe { us_nq_loop_unregister(uws::Loop::get().cast(), self.nq_driver.as_ptr()) }; } - /// Marks that lsquic has work queued, so the next driver pass runs. - fn mark_driver_pending(&self) { - self.nq_driver.with_mut(|d| d.pending = 1); + /// Asks for a `process` at `at_ns` on the `now_ns` clock, if none is due sooner. + pub(super) fn request_pass_at(&self, at_ns: u64) { + self.next_pass_at_ns + .set(self.next_pass_at_ns.get().min(at_ns)); } - /// Runs a driver pass, or hands `pending` back when one cannot run now. - /// `defer_closes` distinguishes the microtask-drain pass, which must not - /// let a session end mid-chain, from the loop_pre/loop_post pass. - fn run_driver_pass(&self, defer_closes: bool) { - if self.closed.get() { - return; - } - // A pass is already on the stack (the walker cleared `pending` before - // calling): give the flag back so that pass's tail flush sees it, - // otherwise the write that set it waits out the backstop timer. + fn run_driver_pass(&self, point: PassPoint) -> c_int { + // A session must not end in the middle of a microtask chain. + let draining = point == PassPoint::AfterMicrotasks; let global_ptr = self.global.get(); + // A pass that's already on the stack flushes at its end. if self.send_scope_depth.get() != 0 || global_ptr.is_null() { - self.mark_driver_pending(); - return; + return 0; } - // SAFETY: `global_ptr` is the realm that created this endpoint and - // outlives it; null was ruled out above. + // SAFETY: `global_ptr` is the realm that created this endpoint and outlives it. let global = unsafe { &*global_ptr }; - self.defer_closes.set(defer_closes); - self.process(global); - self.defer_closes.set(false); - } - - fn server_ssl_ctx(&self) -> *mut lsquic::SSL_CTX { - self.server_tls - .get() - .as_ref() - .or_else(|| self.client_tls.get().as_ref()) - .map(|t| t.raw().cast()) - .unwrap_or(null_mut()) + let follow_up_is_due = match (point, self.follow_up.get()) { + (PassPoint::BeforePoll, FollowUp::Requested) => { + self.follow_up.set(FollowUp::AfterNextPoll); + false + } + (PassPoint::AfterPoll, FollowUp::AfterNextPoll) => { + self.follow_up.set(FollowUp::None); + true + } + _ => false, + }; + if self.closed.get() { + if !draining && self.pending_endpoint_close.replace(false) { + self.deliver_endpoint_close(global, CLOSECONTEXT_CLOSE, 0); + } + } else if follow_up_is_due || self.driver().is_pending() || self.next_pass_us() == 0 { + self.defer_closes.set(draining); + self.process(global); + self.defer_closes.set(false); + } + if self.follow_up.get() != FollowUp::None && !self.closed.get() { + return 0; + } + self.next_pass_us() } } -/// The microtask-drain pass: full processing, but session close events hold -/// until the next loop point so a running microtask chain never observes a -/// session ending mid-chain (node's loop never interleaves that way). -/// -/// `owner` is the pointer `link_loop_driver` installed. -#[unsafe(no_mangle)] -pub(crate) extern "C" fn Bun__nodeQuic__drainEndpoint(owner: &QuicEndpoint) { - owner.run_driver_pass(true); -} - -/// One process pass per loop turn (loop_pre/loop_post): the writes a JS turn -/// queued leave as one engine pass and one sendmmsg batch. -/// -/// `owner` is the pointer `link_loop_driver` installed. -#[unsafe(no_mangle)] -pub(crate) extern "C" fn Bun__nodeQuic__processEndpoint(owner: &QuicEndpoint) { - owner.run_driver_pass(false); +unsafe extern "C" fn driver_pass(driver: *mut uws::quic::Driver, point: PassPoint) -> c_int { + let owner = uws::quic::Driver::opaque_ref(driver).owner(); + // SAFETY: the owner is the endpoint, which frees the driver before it's freed itself. + unsafe { &*owner.cast::() }.run_driver_pass(point) } -bun_event_loop::impl_timer_owner!(QuicEndpoint; from_timer_ptr => event_loop_timer); - -extern "C" fn on_drain(socket: *mut uws::udp::Socket) { - let user = uws::udp::Socket::opaque_mut(socket).user(); - if user.is_null() { - return; - } - // SAFETY: `user` is the QuicEndpoint, live until close. - let this = unsafe { &*user.cast::() }; - if this.closed.get() { - return; - } - for engine in [this.server_engine.get(), this.client_engine.get()] { - if !engine.is_null() { - // SAFETY: engines are live while the endpoint is not closed. - unsafe { lsquic::lsquic_engine_send_unsent_packets(engine) }; - } - } - this.schedule_process(); -} -extern "C" fn on_close(_socket: *mut uws::udp::Socket) {} extern "C" fn on_recv_error(_socket: *mut uws::udp::Socket, _errno: c_int, _is_errqueue: c_int) {} thread_local! { @@ -402,21 +354,6 @@ thread_local! { const ENDPOINT_REGISTRY_INLINE_CAP: usize = 4; -fn registry_find_by_addr(addr: &StoredAddr, not: *const QuicEndpoint) -> Option<*mut QuicEndpoint> { - let want = addr.decode().map(|(f, p, ip)| (f, p, ip.to_vec())); - ENDPOINT_REGISTRY.with_borrow(|v| { - v.iter().copied().find(|&e| { - if core::ptr::eq(e, not) { - return false; - } - // SAFETY: registered endpoints are unregistered in finish_close - // before they can be freed. - let theirs = unsafe { (*e).local_addr.get() }; - theirs.decode().map(|(f, p, ip)| (f, p, ip.to_vec())) == want - }) - }) -} - /// Header byte 0 bit 7 (RFC 8999 sec 5.1): 1 = long header, 0 = short. const HEADER_FORM_LONG: u8 = 0x80; /// Minimum stateless reset size (RFC 9000 sec 10.3: header byte + 4+ random @@ -433,18 +370,32 @@ const VERNEG_PROBE_CID_LEN: usize = 8; const VERNEG_PROBE_LEN: usize = 1200; const SHORT_HEADER_DCID_LEN: usize = 8; +/// Returns a packet's destination connection ID. A short header doesn't carry its length, which is lsquic's default for every engine here. +fn destination_cid(payload: &[u8]) -> Option<&[u8]> { + if payload[0] & HEADER_FORM_LONG == 0 { + return payload.get(1..1 + SHORT_HEADER_DCID_LEN); + } + let len = *payload.get(LONG_HEADER_DCID_LEN_OFFSET)? as usize; + let start = LONG_HEADER_DCID_LEN_OFFSET + 1; + payload + .get(start..start + len) + .filter(|cid| !cid.is_empty() && cid.len() <= MAX_CID_LEN) +} + extern "C" fn on_data( socket: *mut uws::udp::Socket, buf: *mut uws::udp::PacketBuffer, packets: c_int, ) { - let user = uws::udp::Socket::opaque_mut(socket).user(); - if user.is_null() { - return; - } - // SAFETY: `user` was set to the QuicEndpoint at bind time - // and stays live until close; all mutated fields are Cell-based. - let this = unsafe { &*user.cast::() }; + let udp = uws::udp::Socket::opaque_mut(socket) + .user() + .cast::(); + // SAFETY: the owner of the socket's `Udp` is the endpoint, which closes the socket before it's freed. + let this = unsafe { + &*uws::quic::Udp::opaque_ref(udp) + .owner() + .cast::() + }; let global_ptr = this.global.get(); if global_ptr.is_null() { return; @@ -465,8 +416,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 { @@ -484,112 +434,50 @@ extern "C" fn on_data( if this.peer_blocked(peer_sa) { continue; } - // Which of our engines already hashes this DCID, if either. Feeding the - // other one a packet it cannot match makes it answer with a stateless - // reset, so remember the owner for the feed below. - let mut owner_engine = None; - if payload[0] & HEADER_FORM_LONG == 0 && payload.len() > 1 + SHORT_HEADER_DCID_LEN { - let dcid = &payload[1..1 + SHORT_HEADER_DCID_LEN]; - owner_engine = [this.server_engine.get(), this.client_engine.get()] - .into_iter() - .filter(|e| !e.is_null()) - .find(|&e| { - // SAFETY: engines are live while the endpoint is. - let in_use = - unsafe { lsquic::lsquic_engine_cid_in_use(e, dcid.as_ptr(), dcid.len()) }; - in_use != 0 - }); - if owner_engine.is_none() { - // Keep which engine hashed the DCID, as the local path above - // does: feeding the sibling engine too makes it treat the - // packet as an unknown-CID arrival. - let owner = ENDPOINT_REGISTRY.with_borrow(|v| { - v.iter().copied().find_map(|other| { - if core::ptr::eq(other, this) { - return None; - } - [ - // SAFETY: registered endpoints outlive their - // registry entry. - unsafe { (*other).server_engine.get() }, - // SAFETY: as above. - unsafe { (*other).client_engine.get() }, - ] - .into_iter() - .filter(|e| !e.is_null()) - .find(|&e| { - // SAFETY: as above. - let in_use = unsafe { - lsquic::lsquic_engine_cid_in_use(e, dcid.as_ptr(), dcid.len()) - }; - in_use != 0 - }) - .map(|engine| (other, engine)) - }) - }); - if let Some((owner, engine)) = owner { - // SAFETY: as above; the packet is fed with OUR local - // address (the migration target). - let other = unsafe { &*owner }; - if other.peer_blocked(peer_sa) { + let peer = core::ptr::from_ref(peer).cast::(); + let dcid = destination_cid(payload); + // An engine answers a packet it can't match with a stateless reset, so only the owner gets the packet. + let owner_engine = dcid.and_then(|cid| this.engine_with_cid(cid)); + if let (None, Some(cid), true) = (owner_engine, dcid, payload[0] & HEADER_FORM_LONG == 0) { + let sibling = ENDPOINT_REGISTRY.with_borrow(|v| { + v.iter() + .copied() + .filter(|&other| !core::ptr::eq(other, this)) + // SAFETY: registered endpoints outlive their registry entry. + .find_map(|other| Some((other, unsafe { &*other }.engine_with_cid(cid)?))) + }); + if let Some((other, engine)) = sibling { + // SAFETY: as above. + let other = unsafe { &*other }; + if !other.peer_blocked(peer_sa) { + // `local` is this endpoint's address, the one the connection migrates to. + other.feed(Some(engine), payload, &local, peer); + other.process(global); + } + continue; + } + // May be a stateless reset (RFC 9000 sec 10.3). + if payload.len() >= STATELESS_RESET_MIN_LEN { + let others: SmallVec<[*mut QuicEndpoint; ENDPOINT_REGISTRY_INLINE_CAP]> = + ENDPOINT_REGISTRY.with_borrow(|v| { + v.iter() + .copied() + .filter(|&other| !core::ptr::eq(other, this)) + .collect() + }); + for other_ptr in others { + // A listed pointer is live: `teardown` unregisters before it drops the reference that keeps the wrapper alive. + if !ENDPOINT_REGISTRY.with_borrow(|v| v.contains(&other_ptr)) { continue; } - // SAFETY: as in the direct feed below. - unsafe { - lsquic::lsquic_engine_packet_in( - engine, - payload.as_ptr(), - payload.len(), - local.as_ptr().cast(), - core::ptr::from_ref(peer).cast(), - owner.cast(), - 0, - ); + // SAFETY: registered as of the check above, so its + // backing storage is live. + let other = unsafe { &*other_ptr }; + if other.peer_blocked(peer_sa) { + continue; } + other.feed(None, payload, &local, peer); other.process(global); - continue; - } - // May be a stateless reset (RFC 9000 sec 10.3). - if payload.len() >= STATELESS_RESET_MIN_LEN { - let others: SmallVec<[*mut QuicEndpoint; ENDPOINT_REGISTRY_INLINE_CAP]> = - ENDPOINT_REGISTRY.with_borrow(|v| { - v.iter() - .copied() - .filter(|&other| !core::ptr::eq(other, this)) - .collect() - }); - for other_ptr in others { - // Registration is the liveness guarantee: `teardown` - // unregisters before dropping the self-reference that - // keeps the wrapper alive, so a listed pointer is live. - if !ENDPOINT_REGISTRY.with_borrow(|v| v.contains(&other_ptr)) { - continue; - } - // SAFETY: registered as of the check above, so its - // backing storage is live. - let other = unsafe { &*other_ptr }; - if other.peer_blocked(peer_sa) { - continue; - } - for engine in [other.server_engine.get(), other.client_engine.get()] { - if engine.is_null() { - continue; - } - // SAFETY: as in the direct feed below. - unsafe { - lsquic::lsquic_engine_packet_in( - engine, - payload.as_ptr(), - payload.len(), - local.as_ptr().cast(), - core::ptr::from_ref(peer).cast(), - other_ptr.cast(), - 0, - ); - } - } - other.process(global); - } } } } @@ -601,36 +489,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 { - // Already matched above: the other engine would only miss it. - Some(e) => [e, null_mut()], - None => [this.server_engine.get(), this.client_engine.get()], - }; - for engine in engines { - if engine.is_null() { - continue; - } - // SAFETY: `engine` is live while the endpoint is; payload/local/ - // peer are valid for this callback. - unsafe { - lsquic::lsquic_engine_packet_in( - engine, - payload.as_ptr(), - payload.len(), - local.as_ptr().cast(), - core::ptr::from_ref(peer).cast(), - user.cast(), - 0, - ); - } - } + this.feed(owner_engine, payload, &local, peer); } // Keep the scope through the tail pass: a graceful close a handler queued // during this dispatch must not join the same flight as the data the @@ -639,184 +498,6 @@ extern "C" fn on_data( this.send_scope_depth.set(this.send_scope_depth.get() - 1); } -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, - n: c_uint, - ) -> c_int = 0; { - let Some(socket) = this.socket.get() else { - return 0; - }; - let my_addr = this.local_addr.get(); - let stride = lsquic::us_nq_spec_stride(); - let mut sent = 0; - for i in 0..n as usize { - // SAFETY: lsquic guarantees `specs[0..n]` is valid for this callback; - // each spec is `stride` bytes apart. - let spec = unsafe { - specs - .cast::() - .add(i * stride) - .cast::() - }; - let mut iovlen: usize = 0; - // SAFETY: as above. - let iov = unsafe { lsquic::us_nq_spec_iov(spec, core::ptr::from_mut(&mut iovlen)) }; - let mut total = 0usize; - let mut payload = core::ptr::null::(); - if iovlen == 1 { - // SAFETY: as above. - let v = unsafe { &*iov }; - if !v.iov_base.is_null() { - payload = v.iov_base.cast::(); - total = v.iov_len; - } - } else if iovlen > 1 { - this.send_scratch.with_mut(|buf| { - buf.clear(); - for j in 0..iovlen { - // SAFETY: as above. - let v = unsafe { &*iov.add(j) }; - if v.iov_base.is_null() || v.iov_len == 0 { - continue; - } - // SAFETY: lsquic guarantees `iov_base[..iov_len]` is valid. - let src = - unsafe { core::slice::from_raw_parts(v.iov_base.cast::(), v.iov_len) }; - buf.extend_from_slice(src); - } - total = buf.len(); - }); - // Read after the last `extend_from_slice`, so a realloc cannot - // leave it dangling. Nothing below touches `send_scratch`. - payload = this.send_scratch.get().as_ptr(); - } - // SAFETY: `dest` points at lsquic-owned sockaddr storage valid for - // this callback. - let dest = StoredAddr::from_raw( - unsafe { lsquic::us_nq_spec_dest(spec) }.cast(), - SOCKADDR_IN6_LEN, - ); - if total == 0 || payload.is_null() || !dest.is_set() { - sent += 1; - continue; - } - // SAFETY: `local_sa` points at lsquic-owned storage valid for this - // callback. - let spec_local = StoredAddr::from_raw( - unsafe { lsquic::us_nq_spec_local(spec) }.cast(), - SOCKADDR_IN6_LEN, - ); - let out_socket = if spec_local.is_set() && spec_local.decode() != my_addr.decode() { - registry_find_by_addr(&spec_local, this) - // SAFETY: registered endpoints outlive their registry entry. - .and_then(|other| unsafe { (*other).socket.get() }) - .unwrap_or(socket) - } else { - socket - }; - let rv = uws::udp::Socket::opaque_mut(out_socket).send( - &[payload], - &[total], - &[dest.as_ptr().cast()], - ); - if rv < 1 { - // SAFETY: `errno_ptr()` is this thread's errno slot. - 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 { - *e = libc::EAGAIN; - } - } - break; - } - this.add_stat(IDX_STATS_PACKETS_SENT, 1); - this.add_stat(IDX_STATS_BYTES_SENT, total as u64); - sent += 1; - } - sent as c_int - } - - fn get_ssl_ctx(this: &QuicEndpoint, _local: *const c_void) -> *mut lsquic::SSL_CTX = null_mut(); { - this.server_ssl_ctx() - } - - fn get_client_ssl_ctx(this: &QuicEndpoint, _local: *const c_void) -> *mut lsquic::SSL_CTX = null_mut(); { - this.client_tls - .get() - .as_ref() - .or_else(|| this.server_tls.get().as_ref()) - .map(|t| t.raw().cast()) - .unwrap_or(null_mut()) - } -} - /// Node's SNI resolution order: exact hostname, then a `*.suffix` wildcard, /// then the `*` default. Comparison is ASCII-case-insensitive (RFC 6066). fn match_sni<'a>(entries: &'a [(Vec, TlsContext)], host: &[u8]) -> Option<&'a TlsContext> { @@ -853,25 +534,17 @@ lsquic_callback! { return ctx.raw().cast(); } } - this.server_ssl_ctx() - } -} - -/// Node's documented default ALPN is `'h3'`. -fn alpn_cstr_is_http(alpn_cstr: &[u8]) -> bool { - match alpn_cstr.strip_suffix(b"\0") { - None | Some(b"") => true, - Some(a) => a == b"h3" || a.starts_with(b"h3-"), + this.server_tls + .get() + .as_ref() + .map_or(null_mut(), |tls| tls.raw().cast()) } } -/// Whether every protocol in a wire-format ALPN list agrees on HTTP/3-ness. -/// The engine's framing is fixed from the first entry, but `alpn_select_cb` -/// offers the whole list, so a mixed one can negotiate the framing we did not -/// build for. -fn alpn_list_is_uniform(alpn: &[u8]) -> bool { +/// Returns whether the protocols in a wire-format ALPN list are HTTP/3, or `None` if only some are. Node's default is `h3`. +fn alpn_is_http(alpn: &[u8]) -> Option { let mut i = 0usize; - let mut want: Option = None; + let mut all: Option = None; while i < alpn.len() { let n = alpn[i] as usize; i += 1; @@ -880,12 +553,12 @@ fn alpn_list_is_uniform(alpn: &[u8]) -> bool { } let p = &alpn[i..i + n]; let is_http = p == b"h3" || p.starts_with(b"h3-"); - if *want.get_or_insert(is_http) != is_http { - return false; + if *all.get_or_insert(is_http) != is_http { + return None; } i += n; } - true + Some(all.unwrap_or(true)) } pub(super) fn read_u64_option( @@ -903,49 +576,63 @@ pub(super) fn read_u64_option( } } -fn snapshot_datagram_frame_size(s: &lsquic::Settings) -> u64 { - if s.get_datagrams() == 0 { - return 0; - } - match s.get_max_datagram_frame_size() { - 0 => DEFAULT_DATAGRAM_FRAME_SIZE, - v => v, +/// lsquic's settings belong to an engine and Node's options to a session, so sessions share an engine only if their settings are equal. +pub(super) struct Engine { + /// Owned by the endpoint's driver. + raw: *mut lsquic::lsquic_engine, + pub(super) is_server: bool, + /// lsquic frames this engine's streams as HTTP/3. + pub(super) is_http: bool, + settings: lsquic::Settings, +} + +impl Engine { + /// The transport parameters this engine's connections send. + pub(super) fn local_transport_params(&self) -> lsquic::NqTransportParams { + let s = &self.settings; + lsquic::NqTransportParams { + initial_max_stream_data_bidi_local: s.get_init_max_stream_data_bidi_local(), + initial_max_stream_data_bidi_remote: s.get_init_max_stream_data_bidi_remote(), + initial_max_stream_data_uni: s.get_init_max_stream_data_uni(), + initial_max_data: s.get_init_max_data(), + initial_max_streams_bidi: s.get_init_max_streams_bidi(), + initial_max_streams_uni: s.get_init_max_streams_uni(), + max_idle_timeout: match s.get_idle_timeout_ms() { + 0 => s.get_idle_timeout().saturating_mul(1000), + ms => ms, + }, + max_udp_payload_size: s.get_max_udp_payload_size_rx(), + // lsquic hardcodes these (RFC 9000 defaults / lsquic.h LSQUIC_DF_*). + ack_delay_exponent: 3, + max_ack_delay: 25, + active_connection_id_limit: 8, + max_datagram_frame_size: match (s.get_datagrams(), s.get_max_datagram_frame_size()) { + (0, _) => 0, + (_, 0) => DEFAULT_DATAGRAM_FRAME_SIZE, + (_, size) => size, + }, + disable_active_migration: (s.get_allow_migration() == 0) as c_int, + ..lsquic::NqTransportParams::default() + } } } -/// lsquic settings are engine-wide; matches Node's per-endpoint listen options. fn apply_transport_params( global: &JSGlobalObject, s: &mut lsquic::Settings, options: JSValue, - local_tp: &mut lsquic::NqTransportParams, ) -> JsResult<()> { - // 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); + // lsquic's default is 30 seconds. + s.idle_timeout(DEFAULT_MAX_IDLE_TIMEOUT_SECS); + // 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), - ms => ms, - }; - local_tp.initial_max_data = s.get_init_max_data(); - local_tp.initial_max_stream_data_bidi_local = s.get_init_max_stream_data_bidi_local(); - local_tp.initial_max_stream_data_bidi_remote = s.get_init_max_stream_data_bidi_remote(); - local_tp.initial_max_stream_data_uni = s.get_init_max_stream_data_uni(); - local_tp.initial_max_streams_bidi = s.get_init_max_streams_bidi(); - local_tp.initial_max_streams_uni = s.get_init_max_streams_uni(); - local_tp.max_udp_payload_size = s.get_max_udp_payload_size_rx(); - local_tp.ack_delay_exponent = 3; - local_tp.max_ack_delay = 25; - local_tp.active_connection_id_limit = 8; - local_tp.max_datagram_frame_size = snapshot_datagram_frame_size(s); - local_tp.disable_active_migration = (s.get_allow_migration() == 0) as c_int; return Ok(()); } if let Some(ms) = read_u64_option(global, options, "handshakeTimeout")? { s.handshake_to((ms.saturating_mul(1000)).min(c_uint::MAX as u64) as _); } - if let Some(ms) = read_u64_option(global, options, "keepAlive")? { + if let Some(ms) = read_u64_option(global, options, "keepAlive")?.filter(|&ms| ms > 0) { // Node's keepAlive is exact (millisecond-granular). s.ping_period(1); s.ping_period_us(ms.saturating_mul(1000)); @@ -959,11 +646,8 @@ fn apply_transport_params( // u16 port. IPs are wire order; ports are HOST order. let mut pref = [0u8; 24]; let mut have_pref = false; - for (key, is_v4) in [ - ("preferredAddressIpv4", true), - ("preferredAddressIpv6", false), - ] { - let Some(addr_js) = tp.get(global, key)?.filter(|v| v.is_object()) else { + for (key, at) in [("preferredAddressIpv4", 0), ("preferredAddressIpv6", 6)] { + let Some(addr_js) = tp.get(global, key)? else { continue; }; let Some(addr) = addr_js.as_class_ref::() else { @@ -973,15 +657,9 @@ fn apply_transport_params( let Some((_family, port, ip)) = stored.decode() else { continue; }; - if is_v4 && ip.len() == 4 { - pref[0..4].copy_from_slice(ip); - pref[4..6].copy_from_slice(&port.to_ne_bytes()); - have_pref = true; - } else if !is_v4 && ip.len() == 16 { - pref[6..22].copy_from_slice(ip); - pref[22..24].copy_from_slice(&port.to_ne_bytes()); - have_pref = true; - } + pref[at..at + ip.len()].copy_from_slice(ip); + pref[at + ip.len()..at + ip.len() + 2].copy_from_slice(&port.to_ne_bytes()); + have_pref = true; } if have_pref { s.preferred_address(&pref); @@ -1031,38 +709,13 @@ fn apply_transport_params( } } } - *local_tp = lsquic::NqTransportParams { - initial_max_stream_data_bidi_local: s.get_init_max_stream_data_bidi_local(), - initial_max_stream_data_bidi_remote: s.get_init_max_stream_data_bidi_remote(), - initial_max_stream_data_uni: s.get_init_max_stream_data_uni(), - initial_max_data: s.get_init_max_data(), - initial_max_streams_bidi: s.get_init_max_streams_bidi(), - initial_max_streams_uni: s.get_init_max_streams_uni(), - max_idle_timeout: match s.get_idle_timeout_ms() { - 0 => s.get_idle_timeout().saturating_mul(1000), - ms => ms, - }, - max_udp_payload_size: s.get_max_udp_payload_size_rx(), - // lsquic hardcodes these (RFC 9000 defaults / lsquic.h LSQUIC_DF_*). - ack_delay_exponent: 3, - max_ack_delay: 25, - active_connection_id_limit: 8, - max_datagram_frame_size: snapshot_datagram_frame_size(s), - disable_active_migration: (s.get_allow_migration() == 0) as c_int, - ..lsquic::NqTransportParams::default() - }; - if let Some(cc) = options.get(global, "cc")?.filter(|v| v.is_string()) { + if let Some(cc) = options.get(global, "cc")? { 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(()) } @@ -1088,19 +741,6 @@ impl QuicEndpoint { structure: JsStructure<'a, Self>, options: JsLocal<'a>, ) -> JsResult> { - 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 mut bind_config = BindConfig::default(); @@ -1136,13 +776,26 @@ impl QuicEndpoint { } let mut host_nul = host.into_bytes(); host_nul.push(0); - bind_config = BindConfig { - host: host_nul, - port, - }; + bind_config.host = host_nul; + bind_config.port = port; } } } + if options.get_boolean_loose(global, "ipv6Only")? == Some(true) { + bind_config.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. + bind_config.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)) + }; + bind_config.receive_buffer_size = read_i32("udpReceiveBufferSize")?; + bind_config.send_buffer_size = read_i32("udpSendBufferSize")?; + bind_config.ttl = read_i32("udpTTL")?; if let Some(bl_js) = options.get(global, "blockList")?.filter(|v| v.is_object()) { if let Some(bl) = crate::generated_bindgen::block_list::js_BlockList::from_js(bl_js) { @@ -1150,10 +803,7 @@ impl QuicEndpoint { block_list_js = Some(bun_jsc::Strong::create(bl_js, global)); } } - if let Some(policy) = options - .get(global, "blockListPolicy")? - .filter(|v| v.is_string()) - { + if let Some(policy) = options.get(global, "blockListPolicy")? { let policy = bun_core::String::from_js(policy, global)?.to_owned_slice(); block_list_allow = policy == b"allow"; } @@ -1163,17 +813,11 @@ impl QuicEndpoint { { disable_stateless_reset = v.to_boolean(); } - if let Some(v) = options - .get(global, "statelessResetBurst")? - .filter(|v| v.is_number()) - { - stateless_reset_burst = v.as_number().max(0.0).min(u32::MAX as f64) as u32; + if let Some(v) = options.get(global, "statelessResetBurst")? { + stateless_reset_burst = v.as_number() as u32; } - if let Some(v) = options - .get(global, "statelessResetRate")? - .filter(|v| v.is_number()) - { - stateless_reset_rate = v.as_number().max(0.0); + if let Some(v) = options.get(global, "statelessResetRate")? { + stateless_reset_rate = v.as_number(); } } @@ -1184,47 +828,31 @@ impl QuicEndpoint { stats: null_mut(), closing: Cell::new(false), closed: Cell::new(false), - socket: Cell::new(None), + driver: Cell::new(null_mut()), + udp: Cell::new(null_mut()), bind_config: JsCell::new(bind_config), local_addr: Cell::new(StoredAddr::default()), poll_ref: JsCell::new(KeepAlive::init()), this_value: JsCell::new(JsRef::empty()), - server_engine: Cell::new(null_mut()), - client_engine: Cell::new(null_mut()), + engines: JsCell::new(Vec::new()), server_tls: JsCell::new(None), sni_contexts: JsCell::new(Vec::new()), server_alpn_wire: JsCell::new(Vec::new()), - send_scratch: JsCell::new(Vec::new()), - client_tls: JsCell::new(None), - client_alpn: JsCell::new(Vec::new()), - server_alpn: JsCell::new(Vec::new()), - server_is_http: Cell::new(false), server_verify_client: Cell::new(false), server_session_options: JsCell::new(None), disable_stateless_reset: Cell::new(disable_stateless_reset), origin_blob: JsCell::new(Vec::new()), stateless_reset_burst: Cell::new(stateless_reset_burst), stateless_reset_rate: Cell::new(stateless_reset_rate), - client_is_http: Cell::new(false), - processing: Cell::new(false), - followup_due: Cell::new(false), + next_pass_at_ns: Cell::new(u64::MAX), + follow_up: Cell::new(FollowUp::None), sessions: JsCell::new(Vec::new()), - 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(block_list), - early_keylog: JsCell::new(Vec::new()), - block_list_js: JsCell::new(block_list_js), + _block_list_js: block_list_js, block_list_allow: Cell::new(block_list_allow), - event_loop_timer: JsCell::new(EventLoopTimer::init_paused( - EventLoopTimerTag::QuicEndpoint, - )), pending_endpoint_close: Cell::new(false), - nq_driver: JsCell::new(UsNqDriver::default()), - nq_registered: Cell::new(false), defer_closes: Cell::new(false), send_scope_depth: Cell::new(0), global: Cell::new(core::ptr::from_ref(cx.global())), @@ -1255,11 +883,9 @@ impl QuicEndpoint { on_early_data_failed: session::on_early_data_failed, on_path_switch: session::on_path_switch, on_origin: session::on_origin, - get_ssl_ctx, - 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 nothing else has the wrapper. unsafe { @@ -1311,6 +937,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. @@ -1328,7 +984,7 @@ impl QuicEndpoint { let Some(bl) = self.block_list.get() else { return false; }; - // SAFETY: the Strong in `block_list_js` keeps the wrapper (and + // SAFETY: the Strong in `_block_list_js` keeps the wrapper (and // native object) alive for the endpoint's lifetime. let blocked = unsafe { (*bl).check_sockaddr(peer) } != self.block_list_allow.get(); if blocked { @@ -1339,50 +995,47 @@ impl QuicEndpoint { /// Returns `Ok(false)` when the bind fails: Node does not throw here. fn ensure_bound(&self, global: &JSGlobalObject, this_value: JSValue) -> JsResult { - if self.socket.get().is_some() { + if !self.udp.get().is_null() { return Ok(true); } let mut err: c_int = 0; let cfg = self.bind_config.get(); - let socket = uws::udp::Socket::create( - uws::Loop::get(), - on_data, - on_drain, - on_close, - on_recv_error, - cfg.host.as_ptr().cast(), - cfg.port, - 0, - Some(&mut err), - core::ptr::from_ref(self).cast_mut().cast::(), - ); - if !socket.is_null() { - // Linked only while we hold a socket, so an idle endpoint costs - // the loop nothing. - self.link_loop_driver(); - } - if socket.is_null() { + // SAFETY: `cfg.host` is NUL-terminated. + let udp = unsafe { + self.driver().create_udp( + cfg.host.as_ptr().cast(), + cfg.port, + cfg.flags, + &mut err, + on_data, + on_recv_error, + ) + }; + let bound = if udp.is_null() { + Err(err) + } else { + self.udp.set(udp); + cfg.apply_socket_options(uws::udp::Socket::opaque_mut( + uws::quic::Udp::opaque_ref(udp).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]; - let mut len: i32 = ip.len() as i32; - sock.bound_ip(ip.as_mut_ptr(), &mut len); - let addr = match len as usize { - IPV4_ADDR_LEN => { - crate::socket::SocketAddress::init_ipv4([ip[0], ip[1], ip[2], ip[3]], port as u16) - } - IPV6_ADDR_LEN => crate::socket::SocketAddress::init_ipv6(ip, port as u16, 0, 0), - _ => crate::socket::SocketAddress::init_ipv4([127, 0, 0, 1], port as u16), + let udp = uws::quic::Udp::opaque_ref(udp); + // SAFETY: `stats` is a `[u64; ENDPOINT_STATS_FIELDS.len()]` that the wrapper keeps alive for as long as `self`. + unsafe { + udp.count_sent( + self.stats.add(IDX_STATS_PACKETS_SENT), + self.stats.add(IDX_STATS_BYTES_SENT), + ) }; - self.local_addr.set(StoredAddr::from_socket_address(&addr)); + self.local_addr.set(stored_addr_from_sockaddr(udp.local())); self.with_state(|s| { s.bound = 1; s.receiving = 1; @@ -1400,7 +1053,7 @@ impl QuicEndpoint { } fn update_keepalive(&self) { - if self.closed.get() || self.socket.get().is_none() { + if self.closed.get() || self.udp.get().is_null() { return; } let listening = self.with_state(|s| s.listening) != 0; @@ -1414,12 +1067,7 @@ impl QuicEndpoint { if self.closed.get() { return; } - if self.processing.replace(true) { - return; - } - // A depth-0 pass runs after the previous flight left the socket: safe - // point for graceful closes stashed during a dispatch. - if self.send_scope_depth.get() == 0 { + if self.send_scope_depth.get() == 0 && self.follow_up.get() == FollowUp::None { for session in self.sessions.get().clone() { if let Some(session) = self.live_session(session) { session.flush_pending_graceful(); @@ -1427,7 +1075,7 @@ impl QuicEndpoint { } } self.send_scope_depth.set(self.send_scope_depth.get() + 1); - self.followup_due.set(false); + self.next_pass_at_ns.set(u64::MAX); // Not from the mid-turn drain pass: a deferred abort is dropped when // a later stream writes first, and node decides that over the whole // turn -- flushing mid-chain puts a RESET on the wire node never sends. @@ -1439,21 +1087,14 @@ impl QuicEndpoint { } } bun_boringssl_sys::ERR_clear_error(); - for engine in [self.server_engine.get(), self.client_engine.get()] { - if !engine.is_null() { - // SAFETY: engine is live while the endpoint is. - unsafe { lsquic::lsquic_engine_process_conns(engine) }; - } - } - self.processing.set(false); - let server_engine = self.server_engine.get(); - if !server_engine.is_null() { + self.driver().process(); + if let Some(server_engine) = self.server_engine() { let (mut sent, mut limited) = (0u64, 0u64); // SAFETY: engine is live while the endpoint is; out-params are // stack slots. unsafe { lsquic::lsquic_engine_sreset_stats( - server_engine, + server_engine.raw, core::ptr::from_mut(&mut sent), core::ptr::from_mut(&mut limited), ) @@ -1468,9 +1109,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 @@ -1484,16 +1123,12 @@ impl QuicEndpoint { continue; }; let handle = session.handle(); - if let Some(callback) = callbacks::get(global, "onSessionNew") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - callback, - global, - self.this_value.get().get(), - &[handle], - ); - } + callbacks::call( + global, + "onSessionNew", + self.this_value.get().get(), + &[handle], + ); } let sessions: Vec<*mut QuicSession> = self.sessions.get().clone(); for session in sessions { @@ -1503,8 +1138,7 @@ impl QuicEndpoint { session.process_events(global); session.maybe_finish_deferred_close(); } - self.sweep_provisional(); - self.rearm_timer(); + self.sweep_verneg_probes(); self.update_keepalive(); if self.closing.get() && self.sessions.get().is_empty() @@ -1517,24 +1151,63 @@ impl QuicEndpoint { // Writes made by the dispatch above could not flush inline (the scope // was held). Send them at the pass's outer edge, where node's // SendPendingDataScope flushes, not a loop turn later. - if self - .nq_driver - .with_mut(|d| core::mem::replace(&mut d.pending, 0)) - != 0 - { + if !self.closed.get() && self.driver().is_pending() { self.drive_engines_once(); } } fn engine_conn_count(&self) -> u32 { - let mut n = 0u32; - for engine in [self.server_engine.get(), self.client_engine.get()] { - if !engine.is_null() { - // SAFETY: engines live until finish_close. - n += unsafe { lsquic::lsquic_engine_conn_count(engine) }; + self.engines + .get() + .iter() + // SAFETY: engines live until finish_close. + .map(|engine| unsafe { lsquic::lsquic_engine_conn_count(engine.raw) }) + .sum() + } + + fn server_engine(&self) -> Option> { + self.engines.get().iter().find(|e| e.is_server).cloned() + } + + /// Returns the engine that has a connection with this ID. + fn engine_with_cid(&self, cid: &[u8]) -> Option<*mut lsquic::lsquic_engine> { + self.engines.get().iter().map(|e| e.raw).find(|&engine| { + // SAFETY: engines live until finish_close. + unsafe { lsquic::lsquic_engine_cid_in_use(engine, cid.as_ptr(), cid.len()) != 0 } + }) + } + + /// Hands a datagram that arrived at `local` to `engine`, or to every engine if no engine has its connection ID. + fn feed( + &self, + engine: Option<*mut lsquic::lsquic_engine>, + payload: &[u8], + local: &StoredAddr, + peer: *const c_void, + ) { + let packet_in = |raw: *mut lsquic::lsquic_engine| { + // SAFETY: `raw` lives until finish_close, and payload/local/peer are valid for this call. + unsafe { + lsquic::lsquic_engine_packet_in( + raw, + payload.as_ptr(), + payload.len(), + local.as_ptr().cast(), + peer.cast(), + self.udp.get().cast(), + 0, + ); } + }; + if let Some(raw) = engine { + return packet_in(raw); + } + // By index: `on_new_mini_conn` reads the listen options, and a getter there can add an engine. + let mut i = 0; + while let Some(raw) = self.engines.get().get(i).map(|e| e.raw) { + packet_in(raw); + i += 1; } - n } /// Whether a native dispatch (on_data, process) is on the stack. @@ -1546,185 +1219,19 @@ impl QuicEndpoint { if self.closed.get() { return; } - self.followup_due.set(true); // Flush at the outermost native exit (node's SendPendingDataScope): // a response must leave before a later handler's close, or lsquic's // control-stream priority coalesces GOAWAY ahead of the data. if self.send_scope_depth.get() == 0 { self.drive_engines_once(); + } else if self.follow_up.get() == FollowUp::None { + self.follow_up.set(FollowUp::Requested); } - // The loop driver dispatches what the flush queued at the next loop - // point; the timer below is only the backstop for lsquic's - // time-driven state (RTO, ACK delay, idle) and the deferred close. - self.mark_driver_pending(); - let next = bun_core::Timespec::ms_from_now(bun_core::TimespecMockMode::ForceRealTime, 1); - timer_all().update( - core::ptr::addr_of!(self.event_loop_timer) - .cast::() - .cast_mut(), - &next, - ); - } - - fn rearm_timer(&self) { - if self.closed.get() { - return; - } - let mut earliest_us: Option = None; - for engine in [self.server_engine.get(), self.client_engine.get()] { - if engine.is_null() { - continue; - } - let mut diff: c_int = 0; - // SAFETY: engine is live. - if unsafe { - lsquic::lsquic_engine_earliest_adv_tick(engine, core::ptr::from_mut(&mut diff)) - } != 0 - { - earliest_us = Some(earliest_us.map_or(diff, |e| e.min(diff))); - } - } - 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. - const SWEEP_POLL_MS: u64 = 250; - if !self.pending_verneg.get().is_empty() || !self.provisional.get().is_empty() { - ms = Some(ms.map_or(SWEEP_POLL_MS, |m| m.min(SWEEP_POLL_MS))); - } - if self.followup_due.get() { - ms = Some(ms.map_or(1, |m| m.min(1))); - } - if let Some(ms) = ms { - let next = bun_core::Timespec::ms_from_now( - bun_core::TimespecMockMode::ForceRealTime, - ms as i64, - ); - timer_all().update( - core::ptr::addr_of!(self.event_loop_timer) - .cast::() - .cast_mut(), - &next, - ); - } - } - - pub(crate) fn on_timer_fire(this: *mut Self) { - // SAFETY: the timer heap only holds timers of live endpoints. - let this_ref = unsafe { &*this }; - this_ref - .event_loop_timer - .with_mut(|t| t.state = EventLoopTimerState::FIRED); - let global_ptr = this_ref.global.get(); - if global_ptr.is_null() { - return; - } - // SAFETY: as in `on_data`. - let global = unsafe { &*global_ptr }; - if this_ref.pending_endpoint_close.replace(false) { - this_ref.deliver_endpoint_close(global, CLOSECONTEXT_CLOSE, 0); - return; - } - 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(()); - } - 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(()); - } - 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, - self, - endpoint_handle, - None, - true, - self.application(true), - )?; - let applied = self.apply_server_session_options(global, session); - let session = core::ptr::from_ref(session).cast_mut(); - 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 + // The next pass dispatches what the flush queued. + self.driver().set_pending(); } - /// 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; @@ -1738,28 +1245,17 @@ 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 next_pass_at = u64::MAX; 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 { + next_pass_at = next_pass_at.min(*created_ns + VERNEG_PROBE_TIMEOUT_NS); return true; } expired.push(*session); @@ -1768,154 +1264,97 @@ impl QuicEndpoint { }); for session in expired { if self.expire_session(session) { - n_expired += 1; + // `process()` has already drained the event queues. + next_pass_at = 0; } } - if n_expired > 0 { - // This runs after `process()` already drained the event queues, so - // 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)); + self.request_pass_at(next_pass_at); + } + + /// 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() } - /// lsquic promotes a server conn after its handshake and never calls `on_hsk_done` for it. - pub(super) fn on_new_conn(&self, handshaken: lsquic::Conn) -> *mut QuicSession { + /// 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 }; - let endpoint_handle = self.this_value.get().get(); - let peer = conn_peer_addr(handshaken); - 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(handshaken); - 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() { - handshaken.abort_error(false, QUIC_TRANSPORT_CONNECTION_REFUSED, c""); - 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) { - handshaken.abort_error(false, QUIC_TRANSPORT_CONNECTION_REFUSED, c""); + 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. - handshaken.abort_silent(); - return null_mut(); + return Self::refuse_mini_conn(conn); } match QuicSession::create( global, self.vtable_ptr, self, - endpoint_handle, - Some(handshaken), - true, - self.application(true), + self.this_value.get().get(), + self.server_engine(), ) { Ok((session, _handle)) => { if let Err(err) = self.apply_server_session_options(global, session) { crate::dispatch::fold(Err(err)); } + session.bind_mini_conn(conn); let session_ptr = core::ptr::from_ref(session).cast_mut(); self.sessions.with_mut(|v| v.push(session_ptr)); - self.pending_new_sessions.with_mut(|v| v.push(session_ptr)); - self.add_stat(IDX_STATS_SERVER_SESSIONS, 1); - session.note_handshake_ok(handshaken); session_ptr } Err(e) => { - // Abort like the sibling null-return branches, or the conn - // lingers with no session behind it. crate::dispatch::fold(Err(e)); - handshaken.abort_silent(); - null_mut() + Self::refuse_mini_conn(conn) } } } - pub(super) fn configured_alpn(&self, is_server: bool) -> Option> { - let alpn = if is_server { - self.server_alpn.get() - } else { - self.client_alpn.get() - }; - let bytes = alpn.strip_suffix(b"\0").unwrap_or(alpn); - if bytes.is_empty() { - None - } else { - Some(bytes.to_vec()) - } - } - - pub(super) fn is_http(&self, is_server: bool) -> bool { - if is_server { - self.server_is_http.get() - } else { - self.client_is_http.get() - } + /// 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); } - fn application(&self, is_server: bool) -> session::Application { - if self.is_http(is_server) { - session::Application::Http3 - } else { - session::Application::Default + /// 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, new_conn: lsquic::Conn) -> *mut QuicSession { + // SAFETY: `new_conn` is the live conn lsquic just created. + let ctx = unsafe { lsquic::lsquic_conn_get_ctx(new_conn.raw()) }; + // 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(new_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 drive_engines_once(&self) { - if self.processing.replace(true) { + if self.closed.get() { return; } self.send_scope_depth.set(self.send_scope_depth.get() + 1); bun_boringssl_sys::ERR_clear_error(); - for engine in [self.server_engine.get(), self.client_engine.get()] { - if !engine.is_null() { - // SAFETY: engine is live while the endpoint is. - unsafe { lsquic::lsquic_engine_process_conns(engine) }; - // SAFETY: as above. - if unsafe { lsquic::lsquic_engine_has_unsent_packets(engine) } != 0 { - // SAFETY: as above. - unsafe { lsquic::lsquic_engine_send_unsent_packets(engine) }; - } - } - } + self.driver().process(); self.send_scope_depth.set(self.send_scope_depth.get() - 1); - self.processing.set(false); } pub(super) fn unregister_session(&self, session: *mut QuicSession) { @@ -1924,31 +1363,18 @@ 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( + /// Returns the engine for sessions with these options. Creates it unless an earlier session's engine has the same settings. + fn engine_for( &self, is_server: bool, config: &TlsConfig, options: JSValue, global: &JSGlobalObject, - ) -> JsResult<*mut lsquic::lsquic_engine> { - let tls = TlsContext::new(config).map_err(|e| global.throw(format_args!("tls: {}", e)))?; - // Node accepts a list, so own ALPN on the SSL_CTX and pass NULL here. - let alpn_cstr = TlsContext::alpn_cstr(config); - let is_http = alpn_cstr_is_http(&alpn_cstr); - if is_server && !alpn_list_is_uniform(&config.alpn) { + ) -> JsResult> { + // An engine frames every stream the same way, but `alpn_select_cb` picks from the whole list. + let Some(is_http) = alpn_is_http(&config.alpn) else { return Err(global .err( jsc::ErrorCode::INVALID_ARG_VALUE, @@ -1957,20 +1383,11 @@ impl QuicEndpoint { ), ) .throw()); - } - if is_server { - self.server_tls.set(Some(tls)); - self.server_alpn.set(alpn_cstr); - self.server_is_http.set(is_http); - self.server_verify_client.set(config.verify_client); - } else { - self.client_tls.set(Some(tls)); - self.client_alpn.set(alpn_cstr); - self.client_is_http.set(is_http); - } + }; 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); @@ -1987,23 +1404,19 @@ impl QuicEndpoint { if !origin_blob.is_empty() { settings.origin_blob(origin_blob); } + } else { + // Without `keepAlive`, a Node client sends no PING. lsquic's default sends one every half idle timeout. + settings.ping_period(0); } - let mut local_tp = lsquic::NqTransportParams::default(); - apply_transport_params(global, &mut settings, options, &mut local_tp)?; + apply_transport_params(global, &mut settings, options)?; if let Some(app) = options .get(global, "application")? .filter(|v| v.is_object()) { - if let Some(v) = app - .get(global, "enableConnectProtocol")? - .filter(|v| v.is_boolean()) - { + if let Some(v) = app.get(global, "enableConnectProtocol")? { settings.h3_connect_protocol(v.to_boolean() as c_int); } - if let Some(v) = app - .get(global, "enableDatagrams")? - .filter(|v| v.is_boolean()) - { + if let Some(v) = app.get(global, "enableDatagrams")? { // RFC 9297: HTTP/3 datagram support is advertised separately // from the transport-level max_datagram_frame_size. settings.h3_datagram(v.to_boolean() as c_int); @@ -2015,25 +1428,33 @@ impl QuicEndpoint { settings.max_h3_header_bytes(v.min(u32::MAX as u64) as u32); } } - if is_server { - self.server_local_tp.set(local_tp); - } else { - self.client_local_tp.set(local_tp); + let same = |e: &&Rc| { + e.is_server == is_server && e.is_http == is_http && e.settings == settings + }; + if let Some(engine) = self.engines.get().iter().find(same) { + return Ok(Rc::clone(engine)); } - // SAFETY: `vtable` outlives both engines (held in this struct); + // SAFETY: `vtable` outlives the engines (held in this struct); // `settings` is copied by lsquic before this returns. - let engine = unsafe { + let raw = unsafe { lsquic::us_nq_engine_new( + self.driver().as_mut_ptr().cast(), is_server as c_int, is_http as c_int, self.vtable_ptr.cast_mut(), settings.as_ptr(), - core::ptr::null(), ) }; - if engine.is_null() { + if raw.is_null() { return Err(global.throw(format_args!("failed to create QUIC engine"))); } + let engine = Rc::new(Engine { + raw, + is_server, + is_http, + settings, + }); + self.engines.with_mut(|v| v.push(Rc::clone(&engine))); Ok(engine) } @@ -2041,29 +1462,17 @@ impl QuicEndpoint { if self.closed.replace(true) { return false; } - // Unlink first: the driver walk must never reach a freed endpoint. - self.unlink_loop_driver(); - if self.event_loop_timer.get().state == EventLoopTimerState::ACTIVE { - timer_all().remove(self.event_loop_timer.as_ptr()); - } - for engine in [ - self.server_engine.replace(null_mut()), - self.client_engine.replace(null_mut()), - ] { - if !engine.is_null() { - // SAFETY: engine was created by this endpoint. - unsafe { lsquic::lsquic_engine_destroy(engine) }; - } - } - if let Some(socket) = self.socket.take() { - uws::udp::Socket::opaque_mut(socket).close(); + self.engines.with_mut(Vec::clear); + self.udp.set(null_mut()); + if !self.driver.get().is_null() { + self.driver().close(); } let me = core::ptr::from_ref(self).cast_mut(); ENDPOINT_REGISTRY.with_borrow_mut(|v| v.retain(|&e| e != me)); true } - /// `closed` is set FIRST: it gates `schedule_process`/`rearm_timer`, so a + /// `closed` is set FIRST: it gates `schedule_process`, so a /// callback running below cannot re-arm a tick onto engines this function /// is about to free. fn teardown(&self) -> bool { @@ -2071,7 +1480,6 @@ impl QuicEndpoint { return false; } self.server_tls.set(None); - self.client_tls.set(None); self.sni_contexts.with_mut(Vec::clear); self.server_session_options.set(None); self.with_state(|s| { @@ -2088,16 +1496,10 @@ impl QuicEndpoint { if !self.teardown() { return; } - // Defer onEndpointClose to the next turn (Node closes asynchronously). + // Node closes asynchronously, so the driver's next pass calls onEndpointClose. self.poll_ref.with_mut(|p| p.ref_(bun_io::js_vm_ctx())); self.pending_endpoint_close.set(true); - let next = bun_core::Timespec::ms_from_now(bun_core::TimespecMockMode::ForceRealTime, 1); - timer_all().update( - core::ptr::addr_of!(self.event_loop_timer) - .cast::() - .cast_mut(), - &next, - ); + self.driver(); } fn apply_server_session_options( @@ -2116,49 +1518,20 @@ 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") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - callback, - global, - self.this_value.get().get(), - &[ - JSValue::js_number(context as f64), - JSValue::js_number(status as f64), - ], - ); - } + callbacks::call( + global, + "onEndpointClose", + self.this_value.get().get(), + &[ + JSValue::js_number(context as f64), + JSValue::js_number(status as f64), + ], + ); self.this_value.with_mut(|r| r.downgrade()); self.vtable.set(None); + self.free_driver(); } pub(crate) fn listen( @@ -2179,7 +1552,7 @@ impl QuicEndpoint { if !self.ensure_bound(global, this_value.to_js())? { return Ok(()); } - if self.server_engine.get().is_null() { + if self.server_engine().is_none() { let options = options.to_js(); if options.is_object() { self.server_session_options @@ -2220,8 +1593,12 @@ impl QuicEndpoint { self.sni_contexts.with_mut(|m| *m = built); } } - let engine = self.build_engine(true, &config, options, global)?; - self.server_engine.set(engine); + let server_tls = TlsContext::new(global, &config)?; + // SAFETY: `teardown` destroys the engines before it drops `server_tls`. + unsafe { self.driver().set_ssl_ctx(server_tls.raw()) }; + self.server_tls.set(Some(server_tls)); + self.server_verify_client.set(config.verify_client); + self.engine_for(true, &config, options, global)?; } // SAFETY: state buffer is live. self.with_state(|s| s.listening = 1); @@ -2233,7 +1610,7 @@ impl QuicEndpoint { &self, this_value: JsWrapper<'_, Self>, cx: &mut JsScope<'_>, - address: JsLocal<'_>, + address: &crate::socket::SocketAddress, options: JsLocal<'_>, session_ticket: JsLocal<'_>, ) -> JsResult { @@ -2249,18 +1626,9 @@ impl QuicEndpoint { if !self.ensure_bound(global, this_value.to_js())? { return Ok(JSValue::UNDEFINED); } - let address = address.to_js(); let options = options.to_js(); let session_ticket = session_ticket.to_js(); - let Some(addr) = address.as_class_ref::() else { - return Err(global - .err( - jsc::ErrorCode::INVALID_ARG_TYPE, - format_args!("The \"address\" argument must be an instance of SocketAddress"), - ) - .throw()); - }; - let remote = StoredAddr::from_socket_address(addr); + let remote = StoredAddr::from_socket_address(address); if let Some(version) = read_u64_option(global, options, "version")? .map(|v| v as u32) .filter(|v| *v != QUIC_VERSION_1 && *v != QUIC_VERSION_2) @@ -2277,59 +1645,12 @@ impl QuicEndpoint { } let tls = options.get(global, "tls")?.unwrap_or(JSValue::UNDEFINED); let config = TlsConfig::from_js(global, tls, false)?; - if self.client_engine.get().is_null() { - let engine = self.build_engine(false, &config, options, global)?; - self.client_engine.set(engine); - } else { - if alpn_cstr_is_http(&TlsContext::alpn_cstr(&config)) != self.client_is_http.get() { - let (was, want) = if self.client_is_http.get() { - ("an HTTP/3", "raw") - } else { - ("a raw", "HTTP/3") - }; - return Err(global - .err( - jsc::ErrorCode::INVALID_STATE, - format_args!( - "This endpoint's client engine was created for {was} ALPN; use a separate QuicEndpoint for a {want} connection" - ), - ) - .throw()); - } - // Node's TLS options are per-session, but the lsquic engine is - // per-endpoint. Each conn's SSL holds a reference to its SSL_CTX, - // so replacing ours is safe for earlier sessions. - match TlsContext::new(&config) { - Ok(fresh) => self.client_tls.set(Some(fresh)), - Err(e) => return Err(global.throw(format_args!("{e}"))), - } - } - // Node's DEFAULT_MAX_IDLE_TIMEOUT is 10 seconds when unspecified. - let idle_ms = options - .get(global, "transportParams")? - .filter(|v| v.is_object()) - .map(|tp| read_u64_option(global, tp, "maxIdleTimeout")) - .transpose()? - .flatten() - .map(|secs| secs.saturating_mul(MS_PER_SEC)) - .unwrap_or(DEFAULT_MAX_IDLE_TIMEOUT_SECS * MS_PER_SEC); - // SAFETY: the client engine exists after the branch above. - unsafe { - lsquic::lsquic_engine_set_idle_timeout_ms( - self.client_engine.get(), - idle_ms.min(c_uint::MAX as u64) as c_uint, - ) - }; - // Keep what localTransportParams() reports in step with what this - // connect() just put on the wire; a reused endpoint would otherwise - // still echo the first session's value. - self.client_local_tp - .with_mut(|tp| tp.max_idle_timeout = idle_ms); + // Only needed for the `connect` call below: the conn's SSL takes its own reference to the SSL_CTX. + let tls = TlsContext::new(global, &config)?; + let engine = self.engine_for(false, &config, options, global)?; // Read before the session exists: `QuicSession::create` self-roots, and // the conn that follows holds it as its ctx, so a throw after either // point has to unwind state that a plain `?` here avoids creating. - let keepalive_us = - read_u64_option(global, options, "keepAlive")?.map_or(0, |ms| ms.saturating_mul(1000)); let use_preferred = read_u64_option(global, options, "preferredAddressPolicy")? == Some(PREFERRED_ADDRESS_USE); let (session, handle) = QuicSession::create( @@ -2337,54 +1658,35 @@ impl QuicEndpoint { self.vtable_ptr, self, this_value.to_js(), - None, - false, - self.application(false), + Some(Rc::clone(&engine)), )?; let session_ptr = core::ptr::from_ref(session).cast_mut(); - // `TlsConfig::from_js` defaults servername to "localhost\0" (Node parity). - let sni = config.servername.as_ref(); - let local = self.local_addr.get(); - let resume_blob: Option> = if config.enable_early_data { + let resume_blob: Vec = if config.enable_early_data { session_ticket .as_array_buffer(global) - .map(|buf| buf.byte_slice().to_vec()) + .map_or(Vec::new(), |buf| buf.byte_slice().to_vec()) } else { - None - }; - let (resume_ptr, resume_len) = match resume_blob.as_deref() { - Some(b) if !b.is_empty() => (b.as_ptr(), b.len()), - _ => (null(), 0), + Vec::new() }; // engine_connect fires on_new_conn synchronously; hold the scope so a // schedule_process from inside it cannot re-enter the engine. self.send_scope_depth.set(self.send_scope_depth.get() + 1); - // SAFETY: engine is live; local/remote/sni/resume are valid for this - // call (lsquic copies the resume blob); `session` is the - // conn-ctx, at its final address in its wrapper. + // `options.token` isn't replayed: lsquic breaks the token and zero-rtt tests with it. That costs the Retry RTT it would have saved. + // SAFETY: the engine is live and remote/tls/sni are valid for this call. `session` is + // the conn-ctx, at its final address in its wrapper. lsquic returns a live conn or null. + // The session's copy is cleared in `on_conn_closed`. The local copy is used after + // `apply_options`, which reads own data properties of an object that quic.ts builds, so no + // script runs there. let conn = unsafe { - let conn = lsquic::lsquic_engine_connect( - self.client_engine.get(), - lsquic::N_LSQVER, - local.as_ptr().cast(), + let conn = uws::quic::Udp::opaque_ref(self.udp.get()).connect( + engine.raw.cast(), remote.as_ptr().cast(), - core::ptr::from_ref(self).cast_mut().cast(), + tls.raw(), session_ptr.cast(), - sni.map_or(null(), |s| s.as_ptr().cast()), - 0, - resume_ptr, - resume_len, - // `options.token` is validated in JS but deliberately not - // replayed: handing it to lsquic breaks the token and zero-rtt - // tests, at the cost of the Retry RTT it would have saved. - null(), - 0, + config.servername.as_ptr().cast(), + &resume_blob, ); - // SAFETY: lsquic returns a live conn or null. The session's copy is cleared as in - // `on_new_conn`. The local copy is used after `apply_options`, which reads - // `datagramDropPolicy`, `qlog`, `verifyPeer` and `application`: quic.ts passes an - // object it builds, with these as own data properties, so no script runs there. - lsquic::Conn::from_raw(conn) + lsquic::Conn::from_raw(conn.cast()) }; self.send_scope_depth.set(self.send_scope_depth.get() - 1); let Some(conn) = conn else { @@ -2394,17 +1696,15 @@ impl QuicEndpoint { // SAFETY: `conn` is live. unsafe { lsquic::lsquic_conn_set_ctx(conn.raw(), session_ptr.cast()) }; session.conn.set(Some(conn)); - session.cache_sockaddrs(conn); + session.cache_sockaddrs(conn.sockaddrs()); // `conn` is set above, so teardown clears its ctx and no late callback reaches the session. if let Err(e) = session.apply_options(global, options) { session.teardown(global); return Err(e); } - if resume_len != 0 { + if !resume_blob.is_empty() { session.apply_peer_datagram_budget(); } - // keepAlive is per-session in Node. - conn.set_ping_period_us(keepalive_us); if use_preferred { conn.use_preferred_address(true); } @@ -2422,15 +1722,8 @@ impl QuicEndpoint { version: u32, min_version: u32, ) -> JsResult { - let (session, handle) = QuicSession::create( - global, - self.vtable_ptr, - self, - this_value, - None, - false, - session::Application::None, - )?; + let (session, handle) = + QuicSession::create(global, self.vtable_ptr, self, this_value, None)?; let mut dcid = [0u8; VERNEG_PROBE_CID_LEN]; let mut scid = [0u8; VERNEG_PROBE_CID_LEN]; bun_boringssl_sys::rand_bytes(&mut dcid); @@ -2458,7 +1751,8 @@ impl QuicEndpoint { self.schedule_process(); self.sessions.with_mut(|v| v.push(session)); self.add_stat(IDX_STATS_CLIENT_SESSIONS, 1); - if let Some(socket) = self.socket.get() { + let socket = uws::quic::Udp::opaque_ref(self.udp.get()).socket(); + if !socket.is_null() { uws::udp::Socket::opaque_mut(socket).send( &[probe.as_ptr()], &[probe.len()], @@ -2537,6 +1831,7 @@ impl QuicEndpoint { pub(crate) fn release_socket(&self, _: &mut JsScope<'_>) { self.pending_endpoint_close.set(false); self.teardown(); + self.free_driver(); self.poll_ref.with_mut(|p| p.disable()); } @@ -2568,14 +1863,7 @@ impl QuicEndpoint { if config.alpn.is_empty() { config.alpn = alpn.to_vec(); } - match TlsContext::new(&config) { - Ok(ctx) => out.push((host, ctx)), - Err(e) => { - return Err(global - .err(jsc::ErrorCode::INVALID_ARG_VALUE, format_args!("tls: {e}")) - .throw()); - } - } + out.push((host, TlsContext::new(global, &config)?)); } Ok(out) } @@ -2584,7 +1872,7 @@ impl QuicEndpoint { &self, cx: &mut JsScope<'_>, entries: JsLocal<'_>, - replace: JsLocal<'_>, + replace: bool, ) -> JsResult<()> { let global = cx.global(); if self.closed.get() { @@ -2595,23 +1883,13 @@ impl QuicEndpoint { ) .throw()); } - let entries = entries.to_js(); - let replace = replace.to_js(); - if !entries.is_object() { - return Err(global - .err( - jsc::ErrorCode::INVALID_ARG_TYPE, - format_args!("The \"entries\" argument must be of type object"), - ) - .throw()); - } let mut alpn = self.server_alpn_wire.get().clone(); if alpn.is_empty() { alpn = b"\x02h3".to_vec(); } - let built = Self::build_sni_contexts(global, entries, &alpn)?; + let built = Self::build_sni_contexts(global, entries.to_js(), &alpn)?; self.sni_contexts.with_mut(|map| { - if replace.to_boolean() { + if replace { map.clear(); } for (host, ctx) in built { @@ -2634,13 +1912,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; } }); } @@ -2648,12 +1926,8 @@ impl QuicEndpoint { impl Drop for QuicEndpoint { fn drop(&mut self) { - // Remove the timer before the backing storage drops, or a later heap - // operation dereferences the freed node. - if self.event_loop_timer.get().state == EventLoopTimerState::ACTIVE { - timer_all().remove(self.event_loop_timer.as_ptr()); - } self.detach_for_finalize(); self.release_native(); + self.free_driver(); } } diff --git a/src/runtime/node/quic/ffi.rs b/src/runtime/node/quic/ffi.rs index 4b3c732c6a5a..25ab6a5ef9df 100644 --- a/src/runtime/node/quic/ffi.rs +++ b/src/runtime/node/quic/ffi.rs @@ -3,13 +3,14 @@ use core::ffi::c_void; /// SAFETY CONTRACT (the single audited deref for every `node:quic` callback): /// /// * Every context pointer the shim hands back — the engine's `owner` -/// (`ea_stream_if_ctx` / `peer_ctx`), a conn-ctx, or a stream-ctx — is a +/// (`ea_stream_if_ctx`), a conn-ctx, or a stream-ctx — is a /// pointer we ourselves installed: a `QuicEndpoint`, `QuicSession`, or /// `QuicStream` stored inline in its JS wrapper, whose first field is the /// `*const NqVtable` that `packages/bun-usockets/src/node_quic_shim.c` reads /// 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/mod.rs b/src/runtime/node/quic/mod.rs index 5acf082141b9..c91852ba14da 100644 --- a/src/runtime/node/quic/mod.rs +++ b/src/runtime/node/quic/mod.rs @@ -29,16 +29,20 @@ pub(crate) mod callbacks { .set(global, holder); } - pub(crate) fn get(global: &JSGlobalObject, name: &str) -> Option { - let holder = global - .bun_vm() - .as_mut() - .rare_data() - .node_quic_callbacks - .get()?; - match holder.get(global, name) { - Ok(Some(value)) if value.is_callable() => Some(value), - _ => None, + /// Calls the callback that `setCallbacks()` got under `name`, and reports what it throws as uncaught. + pub(crate) fn call(global: &JSGlobalObject, name: &str, this: JSValue, args: &[JSValue]) { + let vm = global.bun_vm().as_mut(); + let Some(holder) = vm.rare_data().node_quic_callbacks.get() else { + return; + }; + if let Ok(Some(callback)) = holder.get(global, name) { + vm.event_loop_ref().run_callback( + bun_event_loop::ContextId::NONE, + callback, + global, + this, + args, + ); } } } diff --git a/src/runtime/node/quic/quic.bind.ts b/src/runtime/node/quic/quic.bind.ts index 97ed047edf10..c4b17e6623e8 100644 --- a/src/runtime/node/quic/quic.bind.ts +++ b/src/runtime/node/quic/quic.bind.ts @@ -1,4 +1,5 @@ import * as b from "../../../codegen/bindgen-lib.ts"; +import { SocketAddress } from "../../socket/SocketAddress.bind.ts"; const js = { capability: "js", throws: true } as const; const objectIfOpen = { returns: b.any.returning("object", "undefined"), ...js } as const; @@ -14,11 +15,15 @@ export const QuicEndpoint = b.class({ proto: { address, closeGracefully: { capability: "js", passThis: true }, - connect: { ...objectIfOpen, args: { address: b.any, options: b.any, sessionTicket: b.any }, passThis: true }, + connect: { + ...objectIfOpen, + args: { address: SocketAddress, options: b.any, sessionTicket: b.any }, + passThis: true, + }, listen: { ...js, args: { options: b.any }, passThis: true }, markBusy: { args: { busy: b.bool.loose }, ...writesState }, releaseSocket: { capability: "js" }, - setSNIContexts: { ...js, args: { entries: b.any, replace: b.any } }, + setSNIContexts: { ...js, args: { entries: b.any, replace: b.bool } }, }, }); @@ -50,9 +55,9 @@ export const QuicStream = b.class({ endWrite: { capability: "js" }, getPriority: { returns: b.u32 }, getReader: { returns: b.self, passThis: true }, - initStreamingSource: writesState, + initStreamingSource: { capability: "heap", throws: true, ...writesState }, pull: { ...js, args: { callback: b.any } }, - resetStream: { args: { code: b.any }, capability: "js" }, + resetStream: { args: { code: b.any }, returns: b.bool, capability: "js" }, sendHeaders: { ...js, args: { kind: b.any, headers: b.any, flags: b.any }, returns: b.any.returning("boolean") }, setPriority: { ...js, args: { priority: b.any } }, setWakeup: { args: { callback: b.any }, capability: "js" }, diff --git a/src/runtime/node/quic/session.rs b/src/runtime/node/quic/session.rs index e15f5c9e7282..c2433e8d964e 100644 --- a/src/runtime/node/quic/session.rs +++ b/src/runtime/node/quic/session.rs @@ -2,8 +2,9 @@ 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 std::rc::Rc; use bun_jsc::bindgen::{JsLocal, JsPayload as _, JsScope, JsWrapper}; use bun_jsc::bun_string_jsc; @@ -14,7 +15,7 @@ use bun_jsc::{ use bun_lsquic_sys as lsquic; use super::callbacks; -use super::endpoint::{MS_PER_SEC, QuicEndpoint, alloc_exposed_array_buffer}; +use super::endpoint::{Engine, MS_PER_SEC, QuicEndpoint, alloc_exposed_array_buffer}; use super::ffi::lsquic_callback; use super::now_ns; use super::tls; @@ -53,16 +54,9 @@ const STREAM_ID_UNI_BIT: u64 = 0x2; const H3_NO_ERROR: u64 = 0x100; pub(super) const H3_INTERNAL_ERROR: u64 = 0x102; -/// Mirrors node's `Session::Application::Type` (node/src/quic/application.h). -#[derive(Clone, Copy)] -#[repr(u8)] -pub(super) enum Application { - /// Used by version-negotiation probes, which have no engine and never handshake. - None = 0, - /// Raw QUIC (node's `DefaultApplication`). - Default = 1, - Http3 = 2, -} +/// Node's `Session::Application::Type` (node/src/quic/application.h). 0 means no application. +const APPLICATION_DEFAULT: u8 = 1; +const APPLICATION_HTTP3: u8 = 2; /// Mirrors node's `HeadersSupportState`. 0 means no application. const HEADERS_SUPPORTED: u8 = 1; @@ -73,8 +67,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 @@ -151,9 +143,6 @@ impl StoredAddr { pub(super) fn as_ptr(&self) -> *const u8 { self.bytes.as_ptr() } - pub(super) fn is_set(&self) -> bool { - self.len > 0 - } pub(super) fn decode(&self) -> Option<(u16, u16, &[u8])> { use crate::socket::socket_address::inet; if self.len == 0 { @@ -282,6 +271,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, @@ -333,13 +330,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). @@ -366,10 +361,13 @@ pub(crate) struct QuicSession { /// `*(us_nq_vtable**)conn_ctx`. vtable: *const lsquic::NqVtable, pub(super) conn: Cell>, + /// 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>, - is_server: Cell, + /// `None` for a version negotiation probe, which never handshakes. + engine: Option>, local_addr: Cell, pub(super) remote_addr: Cell, /// Borrowed views into JSC-owned ArrayBuffers (see endpoint.rs `state`). @@ -393,11 +391,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>, @@ -405,6 +402,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, @@ -418,13 +417,18 @@ pub(crate) struct QuicSession { } impl QuicSession { - fn new(global: &JSGlobalObject, vtable: *const lsquic::NqVtable) -> Self { + fn new( + global: &JSGlobalObject, + vtable: *const lsquic::NqVtable, + engine: Option>, + ) -> Self { Self { vtable, conn: Cell::new(None), + mini_conn: Cell::new(None), endpoint: Cell::new(null_mut()), endpoint_js: JsCell::new(None), - is_server: Cell::new(false), + engine, local_addr: Cell::new(StoredAddr::default()), remote_addr: Cell::new(StoredAddr::default()), state: Cell::new(null_mut()), @@ -442,7 +446,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), @@ -454,6 +457,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), @@ -472,11 +476,9 @@ impl QuicSession { vtable: *const lsquic::NqVtable, endpoint: &'a QuicEndpoint, endpoint_handle: JSValue, - conn: Option, - is_server: bool, - application: Application, + engine: Option>, ) -> JsResult<(&'a QuicSession, JSValue)> { - let this_value = Self::new(global, vtable).into_js_wrapper(global); + let this_value = Self::new(global, vtable, engine).into_js_wrapper(global); let handle = this_value.to_js(); let raw = this_value.payload_ptr().as_ptr(); @@ -509,11 +511,14 @@ impl QuicSession { s.no_error_code = 0; s.internal_error_code = 1; s.stream_open_allowed = 1; - s.application_type = application as u8; - match application { - Application::None => {} - Application::Default => s.headers_supported = HEADERS_UNSUPPORTED, - Application::Http3 => { + match this.engine.as_deref() { + None => {} + Some(Engine { is_http: false, .. }) => { + s.application_type = APPLICATION_DEFAULT; + s.headers_supported = HEADERS_UNSUPPORTED; + } + Some(Engine { is_http: true, .. }) => { + s.application_type = APPLICATION_HTTP3; s.headers_supported = HEADERS_SUPPORTED; s.priority_supported = 1; s.no_error_code = H3_NO_ERROR; @@ -526,33 +531,46 @@ 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(core::ptr::from_ref(endpoint).cast_mut()); this.endpoint_js .set(Some(Strong::create(endpoint_handle, global))); - this.is_server.set(is_server); 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 let Some(conn) = conn { - this.cache_sockaddrs(conn); - } Ok((this, 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_none() && 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: lsquic::Conn) { + debug_assert!(self.conn.get().is_none() && self.mini_conn.get().is_some()); + self.mini_conn.set(None); self.conn.set(Some(conn)); - // SAFETY: `conn` is the live conn lsquic just promoted; `self` is - // the session inside its wrapper (conn-ctx contract). - unsafe { - lsquic::lsquic_conn_set_ctx(conn.raw(), core::ptr::from_ref(self).cast_mut().cast()) - }; - if let Some(ep) = self.endpoint_ref() { - for line in ep.take_early_keylog(conn.ssl()) { - self.push_event(SessionEvent::Keylog(line)); - } - } - self.cache_sockaddrs(conn); + self.cache_sockaddrs(conn.sockaddrs()); if self.close_when_bound.get() { // Carry the user's {type, code, reason} through rather than // sending a bare close. @@ -562,18 +580,9 @@ impl QuicSession { } } - pub(super) fn cache_sockaddrs(&self, conn: 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.raw(), - 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 @@ -621,17 +630,14 @@ impl QuicSession { if !options.is_object() { return Ok(()); } - if let Some(v) = options - .get(global, "datagramDropPolicy")? - .filter(|v| v.is_string()) - { + if let Some(v) = options.get(global, "datagramDropPolicy")? { self.datagram_drop_newest .set(bun_core::String::from_js(v, global)?.to_owned_slice() == b"drop-newest"); } if let Some(v) = options.get(global, "qlog")? { self.qlog_enabled.set(v.to_boolean()); } - if let Some(v) = options.get(global, "verifyPeer")?.filter(|v| v.is_string()) { + if let Some(v) = options.get(global, "verifyPeer")? { self.reject_unverified_peer .set(bun_core::String::from_js(v, global)?.to_owned_slice() != b"manual"); } @@ -645,7 +651,11 @@ impl QuicSession { Ok(()) } pub(super) fn is_server(&self) -> bool { - self.is_server.get() + self.engine.as_ref().is_some_and(|engine| engine.is_server) + } + /// Returns true if lsquic frames this session's streams as HTTP/3. + pub(super) fn is_http(&self) -> bool { + self.engine.as_ref().is_some_and(|engine| engine.is_http) } pub(super) fn push_event(&self, event: SessionEvent) { self.events.with_mut(|e| e.push(event)); @@ -731,20 +741,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( @@ -762,6 +768,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) { @@ -770,16 +787,7 @@ impl QuicSession { (true, false) => IDX_STATS_SESSION_UNI_IN_STREAM_COUNT, (true, true) => IDX_STATS_SESSION_UNI_OUT_STREAM_COUNT, }; - let stats = self.stats.get(); - if !stats.is_null() { - // SAFETY: stats is a live `[u64; N]` view; ArrayBuffer storage - // only guarantees byte alignment. - unsafe { - stats - .add(idx) - .write_unaligned(stats.add(idx).read_unaligned().wrapping_add(1)) - }; - } + self.add_stat(idx, 1); } pub(super) fn on_remote_stream(&self, raw: lsquic::Stream) -> *mut super::stream::QuicStream { @@ -800,6 +808,10 @@ impl QuicSession { if let Some(code) = qs.pre_reset_code() { self.push_event(SessionEvent::StreamReset { stream: key, code }); } + if let Some(code) = 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 }); + } core::ptr::from_ref(qs).cast_mut() } Err(e) => { @@ -879,19 +891,12 @@ impl QuicSession { continue; } let buf = ArrayBuffer::create_buffer(global, &blob).or_report(); - if let Some(cb) = callbacks::get(global, "onSessionTicket") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - self.handle(), - &[buf], - ); - } + callbacks::call(global, "onSessionTicket", self.handle(), &[buf]); } - if !self.pending_tickets.get().is_empty() { - self.schedule_process(); + if let (Some(&(at, _)), Some(endpoint)) = + (self.pending_tickets.get().front(), self.endpoint_ref()) + { + endpoint.request_pass_at(at); } } @@ -950,7 +955,7 @@ impl QuicSession { match event { SessionEvent::HandshakeDone { ok } => { if ok { - if self.is_server.get() || self.peer_verification_refused() { + if self.is_server() || self.peer_verification_refused() { // Node's server reports at handshake COMPLETION // (session.cc: server completion == confirmation // per RFC 9001 §4.1.2). @@ -1021,13 +1026,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(()); @@ -1045,16 +1044,15 @@ impl QuicSession { } else { 0.0 }); - if let Some(cb) = callbacks::get(global, "onStreamCreated") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - self.handle(), - &[handle, direction], - ); - } + callbacks::call( + global, + "onStreamCreated", + self.handle(), + &[handle, direction], + ); + } else if stream.was_pending() { + let handle = stream.handle(); + callbacks::call(global, "onStreamAvailable", handle, &[]); } } SessionEvent::NewToken(token) => { @@ -1066,29 +1064,16 @@ impl QuicSession { return Ok(()); } let buf = ArrayBuffer::create_buffer(global, &token)?; - if let Some(cb) = callbacks::get(global, "onSessionNewToken") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - self.handle(), - &[buf], - ); - } + callbacks::call( + global, + "onSessionNewToken", + self.handle(), + &[buf, self.remote_addr.get().to_js_socket_address(global)], + ); } SessionEvent::Keylog(line) => { let s = bun_string_jsc::create_utf8_for_js(global, &line)?; - if let Some(cb) = callbacks::get(global, "onSessionKeyLog") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - self.handle(), - &[s], - ); - } + callbacks::call(global, "onSessionKeyLog", self.handle(), &[s]); } SessionEvent::SessionResume(blob) => { if !self.has_listener(LISTENER_FLAG_SESSION_TICKET) { @@ -1101,16 +1086,7 @@ impl QuicSession { return Ok(()); } let buf = ArrayBuffer::create_buffer(global, &blob)?; - if let Some(cb) = callbacks::get(global, "onSessionTicket") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - self.handle(), - &[buf], - ); - } + callbacks::call(global, "onSessionTicket", self.handle(), &[buf]); } SessionEvent::StreamReset { stream, code } => { let Some(stream) = self @@ -1122,48 +1098,21 @@ impl QuicSession { let handle = stream.handle(); let err = make_application_error(global, code)?; { - if let Some(cb) = callbacks::get(global, "onStreamReset") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - handle, - &[err], - ); - } + callbacks::call(global, "onStreamReset", handle, &[err]); } } SessionEvent::GoawayReceived => { // lsquic doesn't surface the GOAWAY stream-id; Node // reports -1n when the id is unavailable. let last_stream_id = JSValue::from_int64_no_truncate(global, -1)?; - if let Some(cb) = callbacks::get(global, "onSessionGoaway") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - self.handle(), - &[last_stream_id], - ); - } + callbacks::call(global, "onSessionGoaway", self.handle(), &[last_stream_id]); } SessionEvent::StreamWantsTrailers { stream } => { let Some(stream) = self.live_stream(stream) else { return Ok(()); }; let handle = stream.handle(); - if let Some(cb) = callbacks::get(global, "onStreamTrailers") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - handle, - &[], - ); - } + callbacks::call(global, "onStreamTrailers", handle, &[]); } SessionEvent::StreamHeaders { stream, @@ -1182,16 +1131,12 @@ impl QuicSession { }); let js_arr = js_arr?; { - if let Some(cb) = callbacks::get(global, "onStreamHeaders") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - handle, - &[js_arr, JSValue::js_number(kind as f64)], - ); - } + callbacks::call( + global, + "onStreamHeaders", + handle, + &[js_arr, JSValue::js_number(kind as f64)], + ); } } SessionEvent::StreamDrain { stream } => { @@ -1199,32 +1144,14 @@ impl QuicSession { return Ok(()); }; let handle = stream.handle(); - if let Some(cb) = callbacks::get(global, "onStreamDrain") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - handle, - &[], - ); - } + callbacks::call(global, "onStreamDrain", handle, &[]); } SessionEvent::StreamBlocked { stream } => { let Some(stream) = self.live_stream(stream).filter(|s| s.wants_block()) else { return Ok(()); }; let handle = stream.handle(); - if let Some(cb) = callbacks::get(global, "onStreamBlocked") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - handle, - &[], - ); - } + callbacks::call(global, "onStreamBlocked", handle, &[]); } SessionEvent::StreamWake { stream } => { let Some(stream) = self @@ -1250,38 +1177,38 @@ impl QuicSession { return Ok(()); } let buf = ArrayBuffer::create_buffer(global, &payload)?; - if let Some(cb) = callbacks::get(global, "onSessionDatagram") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - self.handle(), - &[buf, JSValue::js_boolean(early)], - ); - } + callbacks::call( + global, + "onSessionDatagram", + self.handle(), + &[buf, JSValue::js_boolean(early)], + ); } - 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 @@ -1301,47 +1228,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(()); @@ -1357,20 +1243,16 @@ impl QuicSession { JSValue::create_array_from_iter(global, [min, requested].into_iter(), |v| { Ok(JSValue::js_number(v as f64)) })?; - if let Some(cb) = callbacks::get(global, "onSessionVersionNegotiation") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - self.handle(), - &[ - JSValue::js_number(requested as f64), - requested_arr, - supported_arr, - ], - ); - } + callbacks::call( + global, + "onSessionVersionNegotiation", + self.handle(), + &[ + JSValue::js_number(requested as f64), + requested_arr, + supported_arr, + ], + ); } SessionEvent::Origin(payload) => { if !self.has_listener(LISTENER_FLAG_ORIGIN) { @@ -1394,16 +1276,7 @@ impl QuicSession { JSValue::create_array_from_iter(global, ranges.into_iter(), |(o, n)| { bun_string_jsc::create_utf8_for_js(global, &payload[o..o + n]) })?; - if let Some(cb) = callbacks::get(global, "onSessionOrigin") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - self.handle(), - &[array], - ); - } + callbacks::call(global, "onSessionOrigin", self.handle(), &[array]); } SessionEvent::PathValidation { validated, @@ -1428,7 +1301,7 @@ impl QuicSession { // Node passes each fact only from the side that owns it: // the server knows the previous path, the client knows it // migrated to the preferred address. - let (old_local_js, old_remote_js, preferred_js) = if self.is_server.get() { + let (old_local_js, old_remote_js, preferred_js) = if self.is_server() { ( old_local.to_js_socket_address(global), old_remote.to_js_socket_address(global), @@ -1441,29 +1314,31 @@ impl QuicSession { JSValue::js_boolean(preferred), ) }; - if let Some(cb) = callbacks::get(global, "onSessionPathValidation") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - self.handle(), - &[ - result_js, - new_local.to_js_socket_address(global), - new_remote.to_js_socket_address(global), - old_local_js, - old_remote_js, - preferred_js, - ], - ); - } + callbacks::call( + global, + "onSessionPathValidation", + self.handle(), + &[ + result_js, + new_local.to_js_socket_address(global), + new_remote.to_js_socket_address(global), + old_local_js, + old_remote_js, + preferred_js, + ], + ); } SessionEvent::StreamStopSending { stream, code } => { let Some(stream) = self.live_stream(stream) else { 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)?; + callbacks::call(global, "onStreamStopSending", handle, &[err]); } SessionEvent::StreamClosed { stream: key } => { let Some(stream) = self.live_stream(key) else { @@ -1480,6 +1355,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_none() && 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. @@ -1488,16 +1367,7 @@ impl QuicSession { return Ok(()); }; let handle = stream.handle(); - if let Some(cb) = callbacks::get(global, "onStreamClose") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - handle, - &[JSValue::UNDEFINED], - ); - } + callbacks::call(global, "onStreamClose", handle, &[JSValue::UNDEFINED]); // onStreamClose is user JS too: re-acquire before the // `release_close_root` below, as above. let Some(stream) = self.live_stream(key) else { @@ -1518,7 +1388,7 @@ impl QuicSession { return; } let cert_ok = { - if let Some(endpoint) = self.endpoint_ref().filter(|_| ok && self.is_server.get()) { + if let Some(endpoint) = self.endpoint_ref().filter(|_| ok && self.is_server()) { let verify_client = endpoint.server_verify_client.get(); !verify_client || self @@ -1582,7 +1452,7 @@ impl QuicSession { // no client certificate reports X509_V_ERR_UNSPECIFIED. let pair = match snap_validation { Some(pair) => Some(pair), - None if self.is_server.get() && !have_peer_cert => { + None if self.is_server() && !have_peer_cert => { Some(tls::validation_error_strings(tls::X509_V_ERR_UNSPECIFIED)) } None => None, @@ -1598,25 +1468,21 @@ impl QuicSession { ), None => (JSValue::UNDEFINED, JSValue::UNDEFINED), }; - if let Some(callback) = callbacks::get(global, "onSessionHandshake") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - callback, - global, - self.handle(), - &[ - sni, - alpn, - cipher, - cipher_version, - verify_reason, - verify_code, - JSValue::js_boolean(early_data.0), - JSValue::js_boolean(early_data.1), - ], - ); - } + callbacks::call( + global, + "onSessionHandshake", + self.handle(), + &[ + sni, + alpn, + cipher, + cipher_version, + verify_reason, + verify_code, + JSValue::js_boolean(early_data.0), + JSValue::js_boolean(early_data.1), + ], + ); if self.qlog_enabled.get() { let t = now_ns() / 1_000_000; @@ -1628,17 +1494,8 @@ impl QuicSession { // Node destroys the early streams and fires `onearlyrejected` — on // the CLIENT only. - if early_data.0 && !early_data.1 && !self.is_server.get() { - if let Some(callback) = callbacks::get(global, "onSessionEarlyDataRejected") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - callback, - global, - self.handle(), - &[], - ); - } + if early_data.0 && !early_data.1 && !self.is_server() { + callbacks::call(global, "onSessionEarlyDataRejected", self.handle(), &[]); } // Matching Node: the server session exists and then closes with a @@ -1663,7 +1520,7 @@ impl QuicSession { } fn peer_verification_refused(&self) -> bool { - !self.is_server.get() + !self.is_server() && self.reject_unverified_peer.get() && self .hsk_snapshot @@ -1676,10 +1533,7 @@ impl QuicSession { let sni = conn.sni().map(|s| s.to_bytes().to_vec()); let cipher = conn.cipher().map(|s| s.to_bytes().to_vec()); let ssl = conn.ssl().cast(); - let alpn = tls::negotiated_alpn(ssl).or_else(|| { - self.endpoint_ref() - .and_then(|ep| ep.configured_alpn(self.is_server.get())) - }); + let alpn = tls::negotiated_alpn(ssl); let validation = tls::validation_error(ssl); let peer_cert_der = tls::peer_certificate_der(ssl); let local_cert_der = tls::local_certificate_der(ssl); @@ -1714,16 +1568,12 @@ impl QuicSession { // so bailing here would silently end the whole qlog stream, not just // drop this record. let data_js = bun_string_jsc::create_utf8_for_js(global, data.as_bytes()).or_report(); - if let Some(cb) = callbacks::get(global, "onSessionQlog") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - self.handle(), - &[data_js, JSValue::js_boolean(fin)], - ); - } + callbacks::call( + global, + "onSessionQlog", + self.handle(), + &[data_js, JSValue::js_boolean(fin)], + ); } fn report_close(&self, global: &JSGlobalObject) { @@ -1748,27 +1598,13 @@ 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 => match self.conn.get() { - None => 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), - }, - Some(conn) => { - // Map the lsquic conn status to Node's QuicError shape. - let mut buf = [0 as c_char; CONN_STATUS_ERRBUF_LEN]; - // SAFETY: `conn` is live until lsquic frees it after - // `on_conn_closed` returns. - let status = unsafe { - lsquic::lsquic_conn_status(conn.raw(), 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 @@ -1784,21 +1620,17 @@ impl QuicSession { // SAFETY: endpoint is alive (endpoint_js Strong still held). unsafe { (*endpoint).unregister_session(core::ptr::from_ref(self).cast_mut()) }; } - if let Some(callback) = callbacks::get(global, "onSessionClose") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - callback, - global, - self.handle(), - &[ - JSValue::js_number(error_type as f64), - code_js, - reason_js, - JSValue::UNDEFINED, - ], - ); - } + callbacks::call( + global, + "onSessionClose", + self.handle(), + &[ + JSValue::js_number(error_type as f64), + code_js, + reason_js, + JSValue::UNDEFINED, + ], + ); } pub(super) fn teardown(&self, _global: &JSGlobalObject) { @@ -1829,6 +1661,10 @@ impl QuicSession { // late callbacks no-op. unsafe { lsquic::lsquic_conn_set_ctx(conn.raw(), 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() { @@ -1868,7 +1704,6 @@ impl QuicSession { code = super::endpoint::read_u64_option(global, options, "code")?.unwrap_or(0); reason = options .get(global, "reason")? - .filter(|v| v.is_string()) .map(|v| bun_core::String::from_js(v, global).map(|s| s.to_owned_slice())) .transpose()? .unwrap_or_default(); @@ -1881,11 +1716,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() { @@ -1905,7 +1736,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_none() { @@ -1979,7 +1810,7 @@ impl QuicSession { let (app, code, reason) = self.parse_close_options(global, options.to_js())?; self.with_state(|s| s.graceful_close = 1); if self.conn.get().is_none() { - if self.is_server.get() && !self.close_reported.get() { + if self.is_server() && !self.close_reported.get() { self.pending_graceful .with_mut(|p| *p = Some((app, code, reason))); self.close_when_bound.set(true); @@ -2065,7 +1896,7 @@ impl QuicSession { if let Some(buf) = body.as_array_buffer(global) { 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 in attach_source: a later setOutbound() throws instead of adding a second body. @@ -2078,6 +1909,9 @@ impl QuicSession { conn.make_stream(); } } + if let Some(stream) = self.live_stream(key) { + stream.mark_pending_if_unbound(); + } self.schedule_process(); Ok(handle) } @@ -2096,10 +1930,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). @@ -2121,7 +1952,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); @@ -2129,7 +1960,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_none() { return JSValue::from_uint64_no_truncate(global, 0); } @@ -2144,24 +1975,29 @@ 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(); - 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], - ); - } + 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(), + }; + callbacks::call( + global, + "onSessionDatagramStatus", + self.handle(), + &[id_js, status_js], + ); Ok(()) } @@ -2227,7 +2063,8 @@ impl QuicSession { /// The lsquic conn while it is attached. Set between binding /// (create/connect) and `on_conn_closed` clearing it, so it is live for /// the duration of any JS-thread call that observes `Some`. - fn conn(&self) -> Option { + pub(super) fn conn(&self) -> Option { + // SAFETY: see doc comment. self.conn.get() } fn conn_ssl(&self) -> Option<*mut bun_boringssl_sys::SSL> { @@ -2334,15 +2171,8 @@ fn make_application_error(global: &JSGlobalObject, code: u64) -> JsResult) -> JsResult { let global = cx.global(); - let Some(ep) = self.endpoint_ref() else { - return Ok(JSValue::UNDEFINED); - }; - let tp = if self.is_server.get() { - ep.server_local_tp.get() - } else { - ep.client_local_tp.get() - }; - Self::transport_params_to_js(global, tp) + let params = self.engine.as_ref().map(|e| e.local_transport_params()); + Self::transport_params_to_js(global, ¶ms.unwrap_or_default()) } pub(crate) fn remote_transport_params(&self, cx: &mut JsScope<'_>) -> JsResult { let global = cx.global(); @@ -2368,6 +2198,14 @@ impl QuicSession { session.conn.set(None); } } + + /// 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); + } + } } impl Drop for QuicSession { @@ -2376,10 +2214,24 @@ impl Drop for QuicSession { // SAFETY: `on_conn_closed` and `teardown` clear `conn`, so one that is set is live. unsafe { lsquic::lsquic_conn_set_ctx(conn.raw(), 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, @@ -2392,6 +2244,38 @@ lsquic_callback! { endpoint.on_new_conn(conn).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); } @@ -2429,20 +2313,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), @@ -2454,18 +2341,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); - if let Some(conn) = session.conn.get() { - 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.raw(), 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. @@ -2538,7 +2415,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, + }); if let Some(c) = session.conn() { c.want_datagram_write(!session.datagram_queue.get().is_empty()); } @@ -2552,22 +2433,39 @@ 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() { - if let Some(c) = session.conn() { + session.add_stat(IDX_STATS_SESSION_DATAGRAMS_SENT, 1); + if let Some(c) = session.conn() { + 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 ed9daa00c518..f44dd94b3e95 100644 --- a/src/runtime/node/quic/stream.rs +++ b/src/runtime/node/quic/stream.rs @@ -3,9 +3,10 @@ use core::ffi::{c_int, c_void}; use core::ptr::null_mut; use std::collections::VecDeque; -use bun_jsc::bindgen::{JsLocal, JsPayload as _, JsScope, JsWrapper}; +use bun_jsc::bindgen::{self, JsHeap, JsLocal, JsPayload as _, JsScope, JsWrapper}; use bun_jsc::{ - ArrayBuffer, JSGlobalObject, JSType, JSValue, JsCell, JsRef, JsResult, StringJsc, Strong, + ArrayBuffer, ErrorCode, JSGlobalObject, JSType, JSValue, JsCell, JsRef, JsResult, StringJsc, + Strong, }; use bun_lsquic_sys as lsquic; @@ -36,6 +37,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 +70,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 +105,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, } @@ -126,15 +143,24 @@ pub(crate) struct QuicStream { inbound: JsCell, wakeup: JsCell>, peer_stop_sending_code: Cell>, + /// The codes of resetStream() and stopSending() calls made before the stream opened. bind_raw() applies them. + reset_at_open: Cell>, + stop_sending_at_open: 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, } @@ -170,14 +196,20 @@ impl QuicStream { inbound: JsCell::new(Inbound::default()), wakeup: JsCell::new(None), peer_stop_sending_code: Cell::new(None), + reset_at_open: Cell::new(None), + stop_sending_at_open: 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 this_value = stream.into_js_wrapper(global); @@ -246,59 +278,34 @@ 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 { let _ = s.set_http_prio(urgency, incremental); } - // lsquic_stream_send_headers only buffers the block; the flush in - // 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) { - self.wrote_to_lsquic.set(true); - if s.send_headers(&bytes, count, eos) == 0 { - if eos { - self.with_state(|st| { - st.fin_sent = 1; - st.write_ended = 1; - }); - s.shutdown(1); - } else { - want_write = true; - } - } 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; - }); - break; + let want_write = self.send_pending_headers(s); + // Not on HTTP/3, where either frame would get ahead of the stream's header blocks. + if !self.session_ref().is_some_and(QuicSession::is_http) { + if let Some(code) = self.stop_sending_at_open.take() { + s.stop_sending(code); + } + if let Some(code) = self.reset_at_open.take() { + s.reset(code); } } 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); } } @@ -316,6 +323,64 @@ impl QuicStream { 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 + } + + /// lsquic can only queue a HEADERS frame behind sent data if the stream has flow-control credit for the frame's own header. + fn must_wait_for_credit(&self, s: lsquic::Stream, frameless: bool) -> bool { + !frameless && self.headers_sent.get() && s.write_avail() == 0 + } + + /// Sends the queued header blocks in order. Returns true if the stream needs a write event, to flush a block or to get credit for the next one. + fn send_pending_headers(&self, s: lsquic::Stream) -> bool { + let mut want_write = false; + while let Some(&(_, _, _, frameless)) = self.pending_headers.get().first() { + if self.must_wait_for_credit(s, frameless) { + return true; + } + let (bytes, count, eos, _) = self.pending_headers.with_mut(|q| q.remove(0)); + self.wrote_to_lsquic.set(true); + if self.put_header_block(s, &bytes, count, eos, frameless) { + if eos { + self.with_state(|st| { + st.fin_sent = 1; + st.write_ended = 1; + }); + s.shutdown(1); + } else { + // lsquic only buffers the block, and the flush in drain_outbound needs a write event. + want_write = true; + } + } else { + // sendHeaders() returned true when it queued this block, so `closed` reports the failure. + self.fail_message(s); + } + } + want_write + } + fn state_mut(&self) -> *mut StreamState { self.state.get() } @@ -363,6 +428,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_none() { + 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()); } @@ -387,7 +463,10 @@ impl QuicStream { } pub(super) fn has_undelivered_outbound(&self) -> bool { let out = self.outbound.get(); - if !out.data.is_empty() || out.end == PendingEnd::Fin { + if !out.data.is_empty() + || out.end == PendingEnd::Fin + || !self.pending_headers.get().is_empty() + { return true; } self.ls().is_some_and(|s| s.has_unacked_data()) @@ -401,6 +480,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; @@ -408,6 +497,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; @@ -428,6 +518,29 @@ impl QuicStream { } } + /// `on_stream_read` drains lsquic, which returns the peer's credit. Holding the window ties the credit to what the application has read. + 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) } @@ -436,10 +549,7 @@ impl QuicStream { /// early-data phase. pub(super) fn cancel_early_rejected(&self, code: u64) { self.mark_reset(code); - self.outbound.with_mut(|o| { - o.data.clear(); - o.end = PendingEnd::None; - }); + self.discard_outbound(); self.with_state(|st| st.write_ended = 1); if let Some(s) = self.ls() { // Node destroys it silently. @@ -457,6 +567,26 @@ impl QuicStream { s.reset_code = code; } }); + self.discard_outbound(); + // 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); + } + } + } + + /// Ends both directions of a stream whose HTTP/3 message is malformed (RFC 9114 §4.1.2). + fn reset_malformed(&self) { + const H3_MESSAGE_ERROR: u64 = 0x10e; + if let Some(s) = self.ls() { + // reset() ends the read side only if the peer has ended it, so STOP_SENDING stops the rest of the message. + s.reset(H3_MESSAGE_ERROR); + s.stop_sending(H3_MESSAGE_ERROR); + } + self.mark_reset(H3_MESSAGE_ERROR); } fn mark_reset(&self, code: u64) { @@ -465,6 +595,7 @@ impl QuicStream { } self.inbound.with_mut(|inbound| { inbound.chunks.clear(); + inbound.unread = 0; inbound.errored = true; inbound.ended = true; }); @@ -480,6 +611,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) } @@ -495,6 +630,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; @@ -508,30 +646,58 @@ impl QuicStream { } } + /// Drops everything that's still waiting to be sent. Every path that ends the write side calls this. + fn discard_outbound(&self) { + self.outbound.with_mut(|o| { + o.free(); + o.end = PendingEnd::None; + }); + // As in Node, a stream that isn't open yet still sends its queued header blocks when it opens. + if !self.is_pending() { + self.pending_headers.set(Vec::new()); + } + } + + /// 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.discard_outbound(); + } + 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; + } + // lsquic only buffers a block that it takes. The next write event flushes it and sends what waits behind it. + if self.send_pending_headers(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; } @@ -542,14 +708,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 }); } @@ -570,9 +731,37 @@ impl QuicStream { } } + fn send_fin(&self, s: lsquic::Stream) { + s.shutdown(1); + self.wrote_to_lsquic.set(true); + self.discard_outbound(); + 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(); @@ -616,11 +805,10 @@ 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); + self.pending_headers.set(Vec::new()); + // 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(); @@ -675,6 +863,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) @@ -689,6 +878,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, @@ -706,14 +896,15 @@ impl QuicStream { pub(crate) fn attach_source(&self, cx: &mut JsScope<'_>, source: JsLocal<'_>) -> JsResult<()> { let global = cx.global(); - 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(()); } let source = source.to_js(); - let bytes = if source.is_undefined_or_null() { - Vec::new() + let body = if source.is_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)" @@ -721,7 +912,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); @@ -729,15 +922,22 @@ impl QuicStream { Ok(()) } - pub(crate) fn init_streaming_source(&self) { + pub(crate) fn init_streaming_source<'a>(&self, heap: &JsHeap<'a>) -> bindgen::Result<'a, ()> { if self.destroyed.get() { - return; + return Ok(()); + } + if self.is_receive_only() { + return Err(heap.error_with_code( + ErrorCode::INVALID_STATE, + format_args!("Stream is not writable"), + )); } self.outbound.with_mut(|o| o.started = true); self.with_state(|s| { s.has_outbound = 1; s.write_desired_size = s.high_water_mark; }); + Ok(()) } pub(crate) fn write(&self, cx: &mut JsScope<'_>, batch: JsLocal<'_>) -> JsResult { @@ -749,8 +949,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)?; @@ -822,7 +1021,9 @@ impl QuicStream { } }); self.drain_outbound(); - if self.outbound.get().data.is_empty() { + if self.outbound.get().data.is_empty() + && self.pending_headers.get().is_empty() + { s.close(); } else { s.reset(code); @@ -842,9 +1043,13 @@ impl QuicStream { Ok(()) } - pub(crate) fn reset_stream(&self, _: &mut JsScope<'_>, code: JsLocal<'_>) { + /// Returns false for a receive-only stream, which has nothing to reset. + pub(crate) fn reset_stream(&self, _: &mut JsScope<'_>, code: JsLocal<'_>) -> bool { if self.destroyed.get() { - return; + return true; + } + if self.is_receive_only() { + return false; } let code = error_code_arg(code.to_js()); self.with_state(|s| { @@ -852,14 +1057,13 @@ impl QuicStream { s.reset = 1; s.reset_code = code; }); - self.outbound.with_mut(|o| { - o.data.clear(); - o.end = PendingEnd::None; - }); - if let Some(s) = self.ls() { - s.reset(code); + self.discard_outbound(); + match self.ls() { + Some(s) => s.reset(code), + None => self.reset_at_open.set(Some(code)), } self.kick_write(); + true } pub(crate) fn stop_sending(&self, _: &mut JsScope<'_>, code: JsLocal<'_>) { @@ -867,8 +1071,9 @@ impl QuicStream { return; } let code = error_code_arg(code.to_js()); - if let Some(s) = self.ls() { - s.stop_sending(code); + match self.ls() { + Some(s) => s.stop_sending(code), + None => self.stop_sending_at_open.set(Some(code)), } self.inbound.with_mut(|i| i.ended = true); self.with_state(|s| { @@ -912,10 +1117,7 @@ impl QuicStream { s.reset = 1; s.reset_code = code; }); - self.outbound.with_mut(|o| { - o.data.clear(); - o.end = PendingEnd::None; - }); + self.discard_outbound(); } if let Some(s) = self.ls() { if self.wrote_to_lsquic.get() { @@ -977,17 +1179,32 @@ 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.coerce_to_i32(global)? as u32 == QUIC_STREAM_HEADERS_KIND_TRAILING; - let eos = is_trailing + let kind = kind.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; - let Some(s) = self.ls() else { + // 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 open = self.ls(); + let ready = open.filter(|&s| { + self.pending_headers.get().is_empty() && !self.must_wait_for_credit(s, frameless) + }); + let Some(s) = ready else { + // Nothing can follow the end of the write side, whether it's sent or still queued. + let ended = open.is_some() && self.with_state(|s| s.write_ended != 0); + let queued = self.pending_headers.get(); + if ended || queued.last().is_some_and(|&(_, _, eos, _)| eos) { + return Ok(JSValue::js_boolean(false)); + } 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); + if open.is_some() { + self.note_write_blocked(); + self.kick_write(); + } 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 { @@ -1004,6 +1221,9 @@ impl QuicStream { } Ok(JSValue::js_boolean(true)) } else { + if self.outbound.get().has_output() { + self.kick_write(); + } Ok(JSValue::js_boolean(false)) } } @@ -1077,10 +1297,6 @@ pub(super) unsafe extern "C" fn on_stream_read(ctx: *mut c_void, s: *mut lsquic: let claim_header_sets = || -> bool { while let Some(hset) = stream.take_header_set() { let pairs = hset.pairs(); - /// RFC 9114 §8.1 H3_MESSAGE_ERROR — malformed message (a request - /// carrying :status). Matches lsquic's `HEC_MESSAGE_ERROR` - /// (lsquic_hq.h:82); 0x105 is H3_FRAME_UNEXPECTED, a different code. - const H3_MESSAGE_ERROR: u64 = 0x10e; let has_status = pairs .as_chunks::<2>() .0 @@ -1092,14 +1308,7 @@ pub(super) unsafe extern "C" fn on_stream_read(ctx: *mut c_void, s: *mut lsquic: // the request unanswered until the idle timeout. let peer_is_client = qs.session_ref().is_some_and(|s| s.is_server()); if peer_is_client && has_status.is_some() { - if let Some(s) = qs.ls() { - // reset() only ends the read side when the peer already - // FIN'd/RST'd, so STOP_SENDING is what stops a malformed - // request streaming a body into a stream nothing will answer. - s.reset(H3_MESSAGE_ERROR); - s.stop_sending(H3_MESSAGE_ERROR); - } - qs.mark_reset(H3_MESSAGE_ERROR); + qs.reset_malformed(); return false; } // 1xx interim responses are HINTS (RFC 9114 §4.1). @@ -1121,9 +1330,6 @@ pub(super) unsafe extern "C" fn on_stream_read(ctx: *mut c_void, s: *mut lsquic: } true }; - if !claim_header_sets() { - return; - } if stream.received_early_data() { qs.with_state(|s| s.received_early_data = 1); } @@ -1132,6 +1338,10 @@ pub(super) unsafe extern "C" fn on_stream_read(ctx: *mut c_void, s: *mut lsquic: let buf = unsafe { stack_buf.as_bytes_mut() }; let mut got_any = false; loop { + // Until a header block is claimed, a read fails and sets no errno. + if !claim_header_sets() { + break; + } let n = stream.read(buf); match n { n if n > 0 => { @@ -1169,12 +1379,21 @@ pub(super) unsafe extern "C" fn on_stream_read(ctx: *mut c_void, s: *mut lsquic: }); } got_any = true; + } else if bun_core::ffi::errno() == libc::EBADMSG + && qs + .session_ref() + .and_then(|session| session.conn()) + .is_some_and(|conn| conn.status().0 != lsquic::LSCONN_ST_ERROR) + { + // No frame error, which closes the connection: there's no header block where one belongs (RFC 9114 §4.1). lsquic calls on_read on every tick for this too. + qs.reset_malformed(); } break; } } } if got_any { + qs.update_window_hold(); if let Some(session) = qs.session_ref() { session.push_event(SessionEvent::StreamWake { stream: qs.key }); } @@ -1187,6 +1406,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(None); if let Some(session) = qs.session_ref() { diff --git a/src/runtime/node/quic/tls.rs b/src/runtime/node/quic/tls.rs index 60dced897c0f..8673edd372eb 100644 --- a/src/runtime/node/quic/tls.rs +++ b/src/runtime/node/quic/tls.rs @@ -1,16 +1,22 @@ -use core::ffi::c_int; +use core::ffi::{CStr, c_char, c_int}; +use core::net::IpAddr; use core::ptr::null_mut; use bun_boringssl_sys as ssl; use bun_jsc::{JSGlobalObject, JSValue, JsResult, StringJsc}; +use bun_uws as uws; pub(super) struct TlsConfig { pub is_server: bool, pub alpn: Vec, - pub servername: Option>, + /// Ends with a NUL. + pub servername: Vec, pub certs_pem: Vec>, pub keys_pem: Vec>, - pub ca_pem: Vec>, + /// `None` is the default CA store. + pub ca_pem: Option>>, + /// `ca_pem` is the list of `tls.setDefaultCACertificates()`, which a server trusts and doesn't announce. + pub ca_is_default: bool, pub crl_pem: Vec>, pub verify_peer_strict: bool, pub verify_hostname: bool, @@ -37,21 +43,42 @@ fn value_to_bytes(global: &JSGlobalObject, value: JSValue) -> JsResult JsResult>> { let mut out = Vec::new(); if value.is_array() { let len = value.get_length(global)? as u32; for i in 0..len { - if let Some(bytes) = value_to_bytes(global, value.get_index(global, i)?)? { - out.push(bytes); - } + out.extend(value_to_bytes(global, value.get_index(global, i)?)?); } - } else if let Some(bytes) = value_to_bytes(global, value)? { - out.push(bytes); + } else { + out.extend(value_to_bytes(global, value)?); } + out.iter_mut().for_each(|pem| pem.push(0)); Ok(out) } +/// Returns each CRL of a PEM as its own entry, because `us_ssl_ctx_build_raw` reads one CRL per entry. +fn split_crls(mut pem: &[u8]) -> Vec> { + const BEGIN: &[u8] = b"-----BEGIN X509 CRL-----"; + let mut crls = Vec::new(); + let mut after_begin = bun_core::strings::index_of(pem, BEGIN).unwrap_or(0) + 1; + while let Some(next) = bun_core::strings::index_of(&pem[after_begin..], BEGIN) { + let (crl, rest) = pem.split_at(after_begin + next); + crls.push([crl, b"\0"].concat()); + pem = rest; + after_begin = 1; + } + crls.push(pem.to_vec()); + crls +} + +/// The C strings of `collect_pem`'s result, which has to outlive them. +fn pem_list(pems: &[Vec]) -> Vec<*const c_char> { + debug_assert!(pems.iter().all(|pem| pem.last() == Some(&0))); + pems.iter().map(|pem| pem.as_ptr().cast()).collect() +} + /// The TLS 1.3 cipher suites BoringSSL implements (RFC 8446 appendix B.4 /// names). const TLS13_AES_128_GCM_SHA256: &[u8] = b"TLS_AES_128_GCM_SHA256"; @@ -89,13 +116,27 @@ impl TlsConfig { tls: JSValue, is_server: bool, ) -> JsResult { + let mut given = bun_http::ssl_config::Builder::default(); + if tls.is_object() + && let Some(v) = tls.get(global, "ca")? + { + let ca_pem = collect_pem(global, v)?; + if !ca_pem.is_empty() { + given.ca = Some(ca_pem.iter().map(|pem| bun_core::dupe_z(pem)).collect()); + } + } + let trust = global.bun_vm().tls_config(given, is_server); let mut config = TlsConfig { is_server, alpn: Vec::new(), - servername: None, + // As in Node: https://github.com/nodejs/node/blob/v26.5.0/src/quic/tlscontext.h#L182 + servername: b"localhost\0".to_vec(), certs_pem: Vec::new(), keys_pem: Vec::new(), - ca_pem: Vec::new(), + ca_pem: trust + .ca_bytes() + .map(|certs| certs.map(|cert| [cert, b"\0"].concat()).collect()), + ca_is_default: trust.ca_is_default, crl_pem: Vec::new(), verify_peer_strict: false, verify_hostname: false, @@ -113,7 +154,7 @@ impl TlsConfig { config.alpn = bytes; } } - if let Some(v) = tls.get(global, "servername")?.filter(|v| v.is_string()) { + if let Some(v) = tls.get(global, "servername")? { let mut bytes = bun_core::String::from_js(v, global)?.to_owned_slice(); if bun_core::strings::contains_char(&bytes, 0) { return Err(global.throw_invalid_arguments(format_args!( @@ -121,7 +162,7 @@ impl TlsConfig { ))); } bytes.push(0); - config.servername = Some(bytes); + config.servername = bytes; } if let Some(v) = tls.get(global, "certs")? { config.certs_pem = collect_pem(global, v)?; @@ -129,11 +170,11 @@ impl TlsConfig { if let Some(v) = tls.get(global, "keys")? { config.keys_pem = collect_pem(global, v)?; } - if let Some(v) = tls.get(global, "ca")? { - config.ca_pem = collect_pem(global, v)?; - } if let Some(v) = tls.get(global, "crl")? { - config.crl_pem = collect_pem(global, v)?; + config.crl_pem = collect_pem(global, v)? + .iter() + .flat_map(|pem| split_crls(pem)) + .collect(); } if let Some(v) = tls.get(global, "verifyPeerStrict")? { config.verify_peer_strict = v.to_boolean(); @@ -150,19 +191,14 @@ impl TlsConfig { if let Some(v) = tls.get(global, "enableEarlyData")? { config.enable_early_data = v.to_boolean(); } - if let Some(v) = tls.get(global, "ciphers")?.filter(|v| v.is_string()) { + if let Some(v) = tls.get(global, "ciphers")? { config.ciphers = Some(bun_core::String::from_js(v, global)?.to_owned_slice()); } - if let Some(v) = tls.get(global, "groups")?.filter(|v| v.is_string()) { + if let Some(v) = tls.get(global, "groups")? { let mut bytes = bun_core::String::from_js(v, global)?.to_owned_slice(); bytes.push(0); config.groups = Some(bytes); } - // Node defaults the servername to "localhost" when none is given - // (node/src/quic/tlscontext.h TLSContext::Options::servername). - if config.servername.is_none() { - config.servername = Some(b"localhost\0".to_vec()); - } Ok(config) } } @@ -206,52 +242,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, @@ -284,205 +307,79 @@ unsafe extern "C" fn alpn_select_cb( } } -fn load_cert_chain(ctx: *mut ssl::SSL_CTX, pem: &[u8]) -> Result<(), &'static str> { - // SAFETY: `pem` is a live slice; the BIO copies what it parses. - unsafe { - let bio = ssl::BIO_new_mem_buf(pem.as_ptr().cast(), pem.len() as _); - if bio.is_null() { - return Err("failed to allocate BIO for certificate"); - } - let leaf = ssl::PEM_read_bio_X509_AUX(bio, null_mut(), None, null_mut()); - if leaf.is_null() { - ssl::BIO_free(bio); - return Err("failed to parse certificate PEM"); - } - // `use_certificate` ups the X509's refcount. - if ssl::SSL_CTX_use_certificate(ctx, leaf) != 1 { - ssl::X509_free(leaf); - ssl::BIO_free(bio); - return Err("failed to install certificate"); - } - ssl::X509_free(leaf); - loop { - let extra = ssl::PEM_read_bio_X509(bio, null_mut(), None, null_mut()); - if extra.is_null() { - break; - } - // `add0_chain_cert` takes ownership (no refcount bump). - if ssl::SSL_CTX_add0_chain_cert(ctx, extra) != 1 { - ssl::X509_free(extra); - ssl::BIO_free(bio); - return Err("failed to add chain certificate"); - } - } - ssl::ERR_clear_error(); - ssl::BIO_free(bio); - } - Ok(()) -} - -fn load_private_key(ctx: *mut ssl::SSL_CTX, pem: &[u8]) -> Result<(), &'static str> { - // SAFETY: as above. - unsafe { - let bio = ssl::BIO_new_mem_buf(pem.as_ptr().cast(), pem.len() as _); - if bio.is_null() { - return Err("failed to allocate BIO for key"); - } - let pkey = ssl::PEM_read_bio_PrivateKey(bio, null_mut(), None, null_mut()); - ssl::BIO_free(bio); - if pkey.is_null() { - return Err("failed to parse private key PEM"); - } - let ok = ssl::SSL_CTX_use_PrivateKey(ctx, pkey); - ssl::EVP_PKEY_free(pkey); - if ok != 1 { - return Err("failed to install private key"); - } - } - Ok(()) -} - -const X509_V_FLAG_CRL_CHECK: core::ffi::c_ulong = 0x4; -/// `X509_V_FLAG_CRL_CHECK_ALL` — also check intermediate CAs (Node sets both). -const X509_V_FLAG_CRL_CHECK_ALL: core::ffi::c_ulong = 0x8; -/// `X509_V_FLAG_IGNORE_EXPIRED_TRUST_ANCHORS` (oven-sh/boringssl) — as every other Bun SSL_CTX: an expired CA in the -/// trust set does not shadow the valid certificate for the same issuer that the peer sends. -const X509_V_FLAG_IGNORE_EXPIRED_TRUST_ANCHORS: core::ffi::c_ulong = 0x2000000; - -fn load_crl_store(ctx: *mut ssl::SSL_CTX, pem: &[u8]) -> Result<(), &'static str> { - // SAFETY: as above. - unsafe { - let store = ssl::SSL_CTX_get_cert_store(ctx); - let bio = ssl::BIO_new_mem_buf(pem.as_ptr().cast(), pem.len() as _); - if bio.is_null() { - return Err("failed to allocate BIO for CRL"); - } - let mut added = 0; - loop { - let crl = ssl::PEM_read_bio_X509_CRL(bio, null_mut(), None, null_mut()); - if crl.is_null() { - break; - } - let ok = ssl::X509_STORE_add_crl(store, crl); - ssl::X509_CRL_free(crl); - if ok != 1 { - ssl::ERR_clear_error(); - ssl::BIO_free(bio); - return Err("failed to add CRL to trust store"); - } - added += 1; - } - ssl::ERR_clear_error(); - ssl::BIO_free(bio); - if added == 0 { - return Err("CRL PEM contained no revocation lists"); - } - ssl::X509_STORE_set_flags(store, X509_V_FLAG_CRL_CHECK | X509_V_FLAG_CRL_CHECK_ALL); - } - Ok(()) -} - -fn load_ca_store(ctx: *mut ssl::SSL_CTX, pem: &[u8]) -> Result<(), &'static str> { - // SAFETY: as above. - unsafe { - let store = ssl::SSL_CTX_get_cert_store(ctx); - let bio = ssl::BIO_new_mem_buf(pem.as_ptr().cast(), pem.len() as _); - if bio.is_null() { - return Err("failed to allocate BIO for CA"); - } - let mut added = 0; - loop { - let cert = ssl::PEM_read_bio_X509(bio, null_mut(), None, null_mut()); - if cert.is_null() { - break; - } - let ok = ssl::X509_STORE_add_cert(store, cert); - ssl::X509_free(cert); - if ok != 1 { - ssl::ERR_clear_error(); - ssl::BIO_free(bio); - return Err("failed to add CA certificate to trust store"); - } - added += 1; - } - ssl::ERR_clear_error(); - ssl::BIO_free(bio); - if added == 0 { - return Err("CA PEM contained no certificates"); - } - } - Ok(()) -} - impl TlsContext { - pub(super) fn new(config: &TlsConfig) -> Result { - // SAFETY: each SSL_CTX call below is paired with the matching free on - // failure via Drop (the half-built `this` is dropped on early return). - unsafe { - let Some(owned) = ssl::OwnedSslCtx::from_raw(ssl::SSL_CTX_new(ssl::TLS_method())) - else { - return Err("failed to allocate SSL_CTX"); - }; - let ctx = owned.as_ptr(); - let mut this = TlsContext { - ctx: owned, - _alpn: None, + /// Throws what Node throws: https://github.com/nodejs/node/blob/v26.5.0/src/quic/endpoint.cc#L1198-L1203 + pub(super) fn new(global: &JSGlobalObject, config: &TlsConfig) -> JsResult { + Self::build(config).map_err(|reason| { + global + .err( + bun_jsc::ErrorCode::INVALID_STATE, + format_args!("Failed to create TLS context: {reason}"), + ) + .throw() + }) + } + + fn build(config: &TlsConfig) -> Result { + let [cert, key, crl] = + [&config.certs_pem, &config.keys_pem, &config.crl_pem].map(|pems| pem_list(pems)); + let ca = config.ca_pem.as_deref().map(pem_list); + let mut err = uws::create_bun_socket_error_t::none; + let Some(owned) = (uws::SocketContext::BunSocketContextOptions { + cert: cert.as_ptr(), + cert_count: cert.len() as u32, + key: key.as_ptr(), + key_count: key.len() as u32, + ca: ca.as_ref().map_or(core::ptr::null(), |list| list.as_ptr()), + ca_count: ca.as_ref().map_or(0, |list| list.len() as u32), + ca_is_default: i32::from(config.ca_is_default), + crl: crl.as_ptr(), + crl_count: crl.len() as u32, + ssl_min_version: ssl::TLS1_3_VERSION.into(), + ssl_max_version: ssl::TLS1_3_VERSION.into(), + ..Default::default() + }) + .create_ssl_context(&mut err) else { + // Without a message of its own, the reason is BoringSSL's. + let reason = ssl::ERR_reason_error_string(ssl::ERR_get_error()); + ssl::ERR_clear_error(); + let reason: &[u8] = match err.message() { + Some(message) => message, + None if reason.is_null() => b"Invalid certificate or key", + // SAFETY: BoringSSL's reason strings are static and NUL-terminated. + None => unsafe { CStr::from_ptr(reason) }.to_bytes(), }; - + return Err(core::str::from_utf8(reason).unwrap_or_default()); + }; + let ctx = owned.as_ptr(); + let mut this = TlsContext { + ctx: owned, + _alpn: None, + }; + // SAFETY: `ctx` is live and nothing else has it yet. The pointers it's given are NUL-terminated or come with a length. + unsafe { if let Some(policy) = config.ciphers.as_deref().and_then(tls13_policy_for_ciphers) { - if ssl::SSL_CTX_set_compliance_policy(ctx, policy) != 1 { + // A policy also sets the versions and the signature algorithms. + if ssl::SSL_CTX_set_compliance_policy(ctx, policy) != 1 + || ssl::SSL_CTX_set_min_proto_version(ctx, ssl::TLS1_3_VERSION) != 1 + || ssl::SSL_CTX_set_max_proto_version(ctx, ssl::TLS1_3_VERSION) != 1 + || us_ssl_ctx_set_verify_signature_algorithms(ctx) != 1 + { return Err("failed to apply cipher policy"); } } - if ssl::SSL_CTX_set_min_proto_version(ctx, ssl::TLS1_3_VERSION) != 1 - || ssl::SSL_CTX_set_max_proto_version(ctx, ssl::TLS1_3_VERSION) != 1 - { - return Err("failed to pin TLS 1.3"); - } - ssl::X509_VERIFY_PARAM_set_flags( - ssl::SSL_CTX_get0_param(ctx), - X509_V_FLAG_IGNORE_EXPIRED_TRUST_ANCHORS, - ); - if us_ssl_ctx_set_verify_signature_algorithms(ctx) != 1 { - return Err("failed to set the signature algorithms"); - } - us_ssl_ctx_reject_blocked_keys(ctx); if let Some(groups) = &config.groups { if ssl::SSL_CTX_set1_groups_list(ctx, groups.as_ptr().cast()) != 1 { return Err("invalid TLS groups list"); } } - if config.ca_pem.is_empty() { - ssl::SSL_CTX_set_default_verify_paths(ctx); - } - - // Node pairs `certs[i]` with `keys[i]`. - for (i, pem) in config.certs_pem.iter().enumerate() { - if i > 0 { - ssl::SSL_CTX_clear_chain_certs(ctx); - } - load_cert_chain(ctx, pem)?; - if let Some(key) = config.keys_pem.get(i) { - load_private_key(ctx, key)?; - } - } - if config.certs_pem.is_empty() { - if let Some(key) = config.keys_pem.first() { - load_private_key(ctx, key)?; - } - } - for pem in &config.ca_pem { - load_ca_store(ctx, pem)?; - } - for pem in &config.crl_pem { - load_crl_store(ctx, pem)?; - } ssl::SSL_CTX_set_early_data_enabled(ctx, config.enable_early_data as c_int); if config.keylog { 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); @@ -512,18 +409,26 @@ impl TlsContext { }; ssl::SSL_CTX_set_verify(ctx, mode, None); if config.verify_hostname { - let Some(servername) = &config.servername else { - return Err("verifyHostname requires a servername"); - }; - let host = servername.strip_suffix(b"\0").unwrap_or(servername); + let host = &config.servername[..config.servername.len() - 1]; if host.is_empty() { 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 - { + // 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"); } } @@ -537,16 +442,6 @@ impl TlsContext { pub(super) fn raw(&self) -> *mut ssl::SSL_CTX { self.ctx.as_ptr() } - - pub(super) fn alpn_cstr(config: &TlsConfig) -> Vec { - if config.alpn.len() < 2 { - return Vec::new(); - } - let n = config.alpn[0] as usize; - let mut out = config.alpn[1..1 + n.min(config.alpn.len() - 1)].to_vec(); - out.push(0); - out - } } pub(super) fn negotiated_alpn(ssl: *mut ssl::SSL) -> Option> { @@ -576,8 +471,6 @@ unsafe extern "C" { safe fn Bun__X509__validationErrorCode(err: i32) -> *const core::ffi::c_char; /// openssl.c: the signature algorithms every other TLS context of Bun accepts from a peer. 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); } /// node's `X509_V_ERR_UNSPECIFIED`, reported when a peer sent no certificate @@ -654,8 +547,7 @@ pub(super) fn local_certificate_der(ssl: *mut ssl::SSL) -> Option> { if ssl.is_null() { return None; } - // `load_cert_chain` only installs certificates on the context. The context parses its - // leaf once, but `SSL_get_certificate` parses a copy for each SSL. + // The context parses its leaf once, but `SSL_get_certificate` parses a copy for each SSL. // SAFETY: returns a borrowed X509 owned by `ssl`'s context. let cert = unsafe { ssl::SSL_CTX_get0_certificate(ssl::SSL_get_SSL_CTX(ssl)) }; if cert.is_null() { diff --git a/src/runtime/node/types.rs b/src/runtime/node/types.rs index 88f2a3f7dc6e..e93e9060771d 100644 --- a/src/runtime/node/types.rs +++ b/src/runtime/node/types.rs @@ -535,11 +535,15 @@ impl StringOrBuffer<'static> { encoding_value: JSValue, string_objects: StringObjects, ) -> JsResult> { - let encoding: Encoding = 'brk: { - if !encoding_value.is_cell() { - break 'brk Encoding::Utf8; - } - break 'brk Encoding::from_js(encoding_value, global)?.unwrap_or(Encoding::Utf8); + // Bytes have no encoding, and node:net names one ("buffer") with every chunk. Anything but a string is still coerced. + let names_no_encoding = !encoding_value.is_cell() + || (encoding_value.is_string() + && value.is_cell() + && value.js_type().is_array_buffer_like()); + let encoding = if names_no_encoding { + Encoding::Utf8 + } else { + Encoding::from_js(encoding_value, global)?.unwrap_or(Encoding::Utf8) }; Self::from_js_with_encoding_maybe_async( global, @@ -1880,7 +1884,7 @@ impl PathOrBlob { )); }; if let Some(blob) = arg.as_class_ref::() { - return Ok(PathOrBlob::Blob(Box::new(blob.borrowed_view()))); + return Ok(PathOrBlob::Blob(Box::new(blob.dupe()))); } Err(ctx.throw_invalid_argument_type_value( b"destination", diff --git a/src/runtime/server/AnyRequestContext.rs b/src/runtime/server/AnyRequestContext.rs index 914dbe8b2454..60cc21fbf897 100644 --- a/src/runtime/server/AnyRequestContext.rs +++ b/src/runtime/server/AnyRequestContext.rs @@ -9,18 +9,17 @@ use crate::webcore::CookieMap; pub(crate) use super::request_context::AdditionalOnAbortCallback; use super::request_context::RequestContext; -use super::{DebugHTTPSServer, DebugHTTPServer, HTTPSServer, HTTPServer}; // The eight monomorphizations of `NewRequestContext` (ssl × debug × mux), // where mux = HTTP/2 or HTTP/3 (see `request_context::Req`). -type HttpCtx = RequestContext; -type HttpsCtx = RequestContext; -type DebugHttpCtx = RequestContext; -type DebugHttpsCtx = RequestContext; -type HttpMuxCtx = RequestContext; -type HttpsMuxCtx = RequestContext; -type DebugHttpMuxCtx = RequestContext; -type DebugHttpsMuxCtx = RequestContext; +type HttpCtx = RequestContext; +type HttpsCtx = RequestContext; +type DebugHttpCtx = RequestContext; +type DebugHttpsCtx = RequestContext; +type HttpMuxCtx = RequestContext; +type HttpsMuxCtx = RequestContext; +type DebugHttpMuxCtx = RequestContext; +type DebugHttpsMuxCtx = RequestContext; // The `bun_ptr::impl_tagged_ptr_union!` macro hits the orphan rule from // outside `bun_ptr`, so store `(tag: u8, ptr: *mut ())` as two fields. @@ -74,12 +73,8 @@ const fn ctx_tag_for(ssl: bool, dbg: bool, mux: bool) -> CtxTag { } } -// Blanket impl over the const-generic params so any `Ctx: RequestCtx` (which -// is always a `RequestContext<_, SSL, DBG, MUX>`) also satisfies `CtxKind` -// without callers having to spell the eight concrete types. -impl CtxKind - for RequestContext -{ +// One impl over the const parameters, so that no caller spells the eight concrete types. +impl CtxKind for RequestContext { const TAG: CtxTag = ctx_tag_for(SSL, DBG, MUX); } 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 3e9e1d138f31..a2ef063017c6 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -13,9 +13,9 @@ use bun_sys::{self, Fd, File}; use bun_uws::{AnyRequest, AnyResponse}; use crate::server::file_response_stream::{StartOptions as FileResponseStreamOptions, StreamOwner}; -use crate::server::file_route::{status_for_preconditions, write_any_status, write_content_range}; +use crate::server::file_route::{status_for_preconditions, write_alt_svc, write_content_range}; use crate::server::jsc::{JSGlobalObject, JsResult}; -use crate::server::{AnyServer, FileResponseStream, HTTPStatusText, RangeRequest}; +use crate::server::{AnyServer, FileResponseStream, HTTPStatusText, RangeRequest, write_status}; bun_output::declare_scope!(DirectoryRoute, hidden); @@ -118,7 +118,6 @@ impl DirectoryRoute { }; if let Some(server) = this.server.get() { server.on_pending_request(); - resp.timeout(server.config().idle_timeout); } let mut path_buf = bun_paths::path_buffer_pool::get(); @@ -141,8 +140,7 @@ impl DirectoryRoute { return; } req.set_yield(false); - write_any_status(resp, 301); - resp.write_mark(); + write_status(resp, 301); resp.write_header(b"location", &loc.0[..n]); resp.end(b"", resp.should_close_connection()); return; @@ -170,11 +168,10 @@ impl DirectoryRoute { }; let status_code = - status_for_preconditions(&req, method, 200, Some(etag), last_modified_ms, range); + status_for_preconditions(&req, method, 200, Some(etag), || last_modified_ms, range); req.set_yield(false); - write_any_status(resp, status_code); - resp.write_mark(); + write_status(resp, status_code); let ext: &[u8] = if is_index { b"html" @@ -189,13 +186,7 @@ impl DirectoryRoute { resp.write_header(b"last-modified", lm); } resp.write_header(b"etag", etag); - if !matches!(resp, AnyResponse::H3(_)) { - if let Some(srv) = this.server.get() { - if let Some(alt) = srv.h3_alt_svc() { - resp.write_header(b"alt-svc", alt); - } - } - } + write_alt_svc(this.server.get(), resp); if HTTPStatusText::is_null_body(status_code) { resp.end_without_body(resp.should_close_connection()); @@ -221,10 +212,7 @@ impl DirectoryRoute { } }; - if !resp.state().has_written_content_length_header() { - resp.write_header_int(b"content-length", body_len); - resp.mark_wrote_content_length_header(); - } + resp.write_header_int(b"content-length", body_len); if method == Method::HEAD { resp.end_without_body(resp.should_close_connection()); @@ -248,7 +236,6 @@ impl DirectoryRoute { pollable: false, offset: body_offset, length: Some(body_len), - idle_timeout: server.config().idle_timeout, owner: StreamOwner::DirectoryRoute(guard.into_route()), }); } @@ -358,7 +345,7 @@ impl DirectoryRoute { resp.clear_on_writable(); resp.clear_timeout(); if let Some(server) = self.server.get() { - server.on_static_request_complete(); + server.on_request_complete(); } } } @@ -400,8 +387,7 @@ enum Subpath { fn write_miss(req: &mut AnyRequest, resp: AnyResponse) { req.set_yield(false); - write_any_status(resp, 404); - resp.write_mark(); + write_status(resp, 404); resp.end(b"", resp.should_close_connection()); } diff --git a/src/runtime/server/FileResponseStream.rs b/src/runtime/server/FileResponseStream.rs index de834809063d..1ad6bce0176f 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(); @@ -461,6 +472,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()), @@ -492,7 +510,7 @@ impl FileResponseStream { self.as_ptr(), ); } - self.resp.get().mark_needs_more(); + self.resp.get().request_writable(); true } @@ -505,7 +523,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 @@ -540,6 +558,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); @@ -584,7 +611,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 c8d233a45032..9a489535792f 100644 --- a/src/runtime/server/FileRoute.rs +++ b/src/runtime/server/FileRoute.rs @@ -17,7 +17,7 @@ use crate::server::file_response_stream::{StartOptions as FileResponseStreamOpti use crate::server::jsc::{JSGlobalObject, JSValue, JsResult, VirtualMachine}; use bun_jsc::bun_string_jsc; -use crate::server::{AnyServer, FileResponseStream, HTTPStatusText, RangeRequest}; +use crate::server::{AnyServer, FileResponseStream, HTTPStatusText, RangeRequest, write_status}; use crate::webcore::blob::store::Data as StoreData; use crate::webcore::body::Value as BodyValue; use crate::webcore::{Blob, FetchHeaders, Response}; @@ -35,9 +35,7 @@ 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, } pub(crate) struct InitOptions<'a> { @@ -48,7 +46,6 @@ pub(crate) struct InitOptions<'a> { use crate::webcore::headers_ref::blob_content_type; -#[inline] fn headers_from( global: &JSGlobalObject, fetch_headers: Option<&FetchHeaders>, @@ -57,9 +54,32 @@ fn headers_from( bun_http_jsc::headers_jsc::from_fetch_headers(global, fetch_headers, blob_content_type(blob)) } -#[inline] -fn sp_slice<'a>(ptr: StringPointer, buf: &'a [u8]) -> &'a [u8] { - &buf[ptr.offset as usize..][..ptr.length as usize] +/// The headers of a route's `Response`, and the server's Alt-Svc. +pub(crate) fn write_route_headers(headers: &Headers, server: Option, resp: AnyResponse) { + use bun_http_types::ETag::HeaderEntryColumns; + let entries = headers.entries.slice(); + let names: &[StringPointer] = entries.items_name(); + let values: &[StringPointer] = entries.items_value(); + let buf = headers.buf.as_slice(); + + debug_assert_eq!(names.len(), values.len()); + for (name, value) in names.iter().zip(values) { + resp.write_header( + &buf[name.offset as usize..][..name.length as usize], + &buf[value.offset as usize..][..value.length as usize], + ); + } + write_alt_svc(server, resp); +} + +/// Advertises the server's HTTP/3 endpoint on its HTTP/1 and HTTP/2 responses (RFC 7838). +pub(crate) fn write_alt_svc(server: Option, resp: AnyResponse) { + if !matches!(resp, AnyResponse::H3(_)) + && let Some(server) = server + && let Some(alt_svc) = server.h3_alt_svc() + { + resp.write_header(b"alt-svc", alt_svc); + } } /// What `FileRoute::serve` left for `on()` to do with the open fd. @@ -117,9 +137,7 @@ 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(), blob, headers, status_code, @@ -204,23 +222,7 @@ impl FileRoute { } fn write_headers(&self, resp: AnyResponse) { - use bun_http_types::ETag::HeaderEntryColumns; - let entries = self.headers.entries.slice(); - let names: &[StringPointer] = entries.items_name(); - let values: &[StringPointer] = entries.items_value(); - let buf = self.headers.buf.as_slice(); - - debug_assert_eq!(names.len(), values.len()); - for (name, value) in names.iter().zip(values) { - resp.write_header(sp_slice(*name, buf), sp_slice(*value, buf)); - } - if !matches!(resp, AnyResponse::H3(_)) { - if let Some(srv) = self.server.get() { - if let Some(alt) = srv.h3_alt_svc() { - resp.write_header(b"alt-svc", alt); - } - } - } + write_route_headers(&self.headers, self.server.get(), resp); if !self.has_last_modified_header { // `Cell::take` then restore — `write_header` is a sync uWS buffer @@ -231,10 +233,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) { @@ -258,7 +256,6 @@ impl FileRoute { let route = RefPtr::from_this(this); if let Some(server) = route.server.get() { server.on_pending_request(); - resp.timeout(server.config().idle_timeout); } let store = route.blob.store().unwrap().clone(); let Some(path) = store.get_path() else { @@ -294,7 +291,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. @@ -322,7 +319,6 @@ impl FileRoute { pollable, offset, length, - idle_timeout: server.config().idle_timeout, owner: StreamOwner::FileRoute(route), }); } @@ -403,26 +399,26 @@ impl FileRoute { RangeRequest::Result::None }; - let status_code = - status_for_preconditions(req, method, self.status_code, etag, last_modified_ms, range); + let status_code = status_for_preconditions( + req, + method, + self.status_code, + etag, + || last_modified_ms, + range, + ); req.set_yield(false); - write_any_status(resp, status_code); - if self.has_date_header { - resp.mark_wrote_date_header(); - } - resp.write_mark(); + write_status(resp, status_code); 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; } @@ -448,10 +444,7 @@ 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); } if method == Method::HEAD { @@ -479,7 +472,7 @@ impl FileRoute { resp.clear_on_writable(); resp.clear_timeout(); if let Some(server) = self.server.get() { - server.on_static_request_complete(); + server.on_request_complete(); } } } @@ -501,7 +494,7 @@ pub(crate) fn status_for_preconditions( method: Method, base_status: u16, etag: Option<&[u8]>, - last_modified_ms: Option, + last_modified_ms: impl Fn() -> Option, range: RangeRequest::Result, ) -> u16 { if (method == Method::HEAD || method == Method::GET) && base_status == 200 { @@ -513,7 +506,7 @@ pub(crate) fn status_for_preconditions( .header(b"if-unmodified-since") .and_then(crate::jsc_hooks::parse_http_date) { - if let Some(lm) = last_modified_ms { + if let Some(lm) = last_modified_ms() { if lm / 1000 > ius / 1000 { return 412; } @@ -537,7 +530,7 @@ pub(crate) fn status_for_preconditions( // Compare at second precision: the Last-Modified we emit is // second-granular (HTTP-date), so a sub-second mtime would never // satisfy `<=` against the client's echoed value otherwise. - if let Some(lm) = last_modified_ms { + if let Some(lm) = last_modified_ms() { if lm / 1000 <= ims / 1000 { return 304; } @@ -570,14 +563,3 @@ pub(crate) fn write_content_range( _ => None, } } - -pub(crate) fn write_any_status(resp: AnyResponse, status: u16) { - match resp { - AnyResponse::SSL(r) => crate::server::write_status::(r, status), - AnyResponse::TCP(r) => crate::server::write_status::(r, status), - AnyResponse::H3(_) | AnyResponse::H2(_) => { - let mut b = bun_core::fmt::ItoaBuf::new(); - resp.write_status(bun_core::fmt::itoa(&mut b, status)); - } - } -} diff --git a/src/runtime/server/HTMLBundle.rs b/src/runtime/server/HTMLBundle.rs index bb564511d883..2bd5c8170556 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); } @@ -237,6 +249,7 @@ impl Route { }; route.pending_responses.with_mut(|v| v.push(pending)); resp.on_aborted_this(Self::on_pending_response_aborted, this); + resp.suspend_timeout(); req.set_yield(false); } State::Err(_log) => { @@ -247,8 +260,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 { @@ -274,7 +286,7 @@ impl Route { /// Entering `State::Building` counts as a pending request on the server: /// the plugin load and the build finish on later event-loop turns and call /// back into it (`on_plugins_resolved`, `on_complete`), so its - /// `deinit_if_we_can` must not free it before then. Nothing else holds it + /// `apply_phase` must not release it before then. Nothing else holds it /// on the route's behalf; the clients waiting in `pending_responses` only /// count as connections and can disconnect at any time. `finish_building` /// releases the pending request when the route leaves `State::Building`. @@ -299,7 +311,7 @@ impl Route { /// Leaves `State::Building`: answers the requests that arrived while the /// route was building, then releases the pending request `schedule_bundle` /// took on the server. The release comes last because it runs the server's - /// idle pass (`deinit_if_we_can`), which schedules the server's deinit when + /// idle pass (`apply_phase`), which schedules the server's deinit when /// this build was the only thing still keeping a stopped server alive. /// The build task will not deliver (the context that started it stopped, or the VM is going): /// what waits for the page is answered as for a failed build, and the server is released. @@ -314,14 +326,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)); } @@ -405,7 +415,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. @@ -468,7 +482,7 @@ impl Route { } let mut log = Log::init(); completion_task.log.append_to(&mut log); - self.set_build_error(server, log); + self.set_build_error(log); } BundleV2Result::Value(bundle) => 'bundle: { if bun_core::Environment::ENABLE_LOGS { @@ -503,7 +517,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; }; @@ -511,6 +525,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. @@ -583,27 +598,32 @@ impl Route { continue; } - bun_core::handle_oom(server.append_static_route( + let static_route = RefPtr::new(static_route); + self.assets + .with_mut(|assets| assets.push((route_path.into(), static_route.clone()))); + server.append_static_route( route_path, - AnyRoute::Static(RefPtr::new(static_route)), + AnyRoute::Static(static_route), MethodOptional::Any, - )); + ); } 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 + }) { + 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()) { - // Server has shutdown, so it won't receive any new requests - // TODO: handle this case - } + server.reload_static_routes(); } BundleV2Result::Pending => unreachable!(), } @@ -634,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); @@ -661,6 +662,15 @@ impl Route { } } } + + // No body, so production doesn't leak the build error. + 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 97372f3275de..00d0ddd2a6ed 100644 --- a/src/runtime/server/NodeHTTPResponse.bind.ts +++ b/src/runtime/server/NodeHTTPResponse.bind.ts @@ -5,8 +5,9 @@ export default b.module({ rustPath: "crate::server::node_http_response" }); 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, + /** `ServerResponse` has checked the range. */ + statusCode: b.u16.default(200), + statusMessage: b.string.loose.nullable, /** The flat `[name, value, ...]` array, which C++ reads in place. */ headers: b.any, }; @@ -34,14 +35,11 @@ export const NodeHTTPResponse = b.class({ internalFields: ["onAborted", "onWritable", "onData", "pendingWriteBuffer"], proto: { abort: { capability: "js" }, - aborted: { getter: b.bool, pure: true }, bufferedAmount: { getter: b.u64, pure: true }, - cork: { ...method, args: { callback: b.callable } }, end: { ...method, ...body, returns: b.any.returning("number", "undefined"), passThis: true, length: 2 }, finished: { getter: b.bool, pure: true }, flags: { getter: b.i32, pure: true }, flushHeaders: {}, - getBytesWritten: { returns: b.usize }, hasBody: { getter: b.i32, pure: true }, headersObject: { returns: b.any.returning("object", "undefined"), capability: "js" }, notifyWhenReadParsed: { returns: b.bool }, @@ -49,8 +47,6 @@ export const NodeHTTPResponse = b.class({ ondata: eventHandler, onwritable: eventHandler, pause: { returns: b.bool }, - pauseReads: {}, - ref: { rustName: "js_ref" }, resume: { returns: b.bool }, takeRawHeaders: { ...method, args: { headers: b.any }, returns: b.any.returning("Array", "undefined") }, takeRequestTrailers: { @@ -59,8 +55,6 @@ export const NodeHTTPResponse = b.class({ returns: b.any.returning("Array", "undefined"), length: 0, }, - unref: { rustName: "js_unref" }, - upgraded: { getter: b.bool, pure: true }, write: { ...method, ...body, returns: b.any.returning("number"), length: 3 }, writeContinue: { capability: "js", throws: true }, writeHead: { args: { ...head, ...autoHeaders }, capability: "js", throws: true, length: 3 }, @@ -72,6 +66,12 @@ export const NodeHTTPResponse = b.class({ passThis: true, length: 8, }, + writeHeadAndWrite: { + ...method, + args: { ...head, ...chunk, callback: b.callable.optional, strictContentLength, ...autoHeaders }, + expected: body.expected, + length: 9, + }, writeInformational: { args: chunk, expected: { chunk: ["string", "Buffer", "TypedArray", "DataView"] }, diff --git a/src/runtime/server/NodeHTTPResponse.rs b/src/runtime/server/NodeHTTPResponse.rs index 6e10d6d94523..bb736d29beb0 100644 --- a/src/runtime/server/NodeHTTPResponse.rs +++ b/src/runtime/server/NodeHTTPResponse.rs @@ -19,7 +19,7 @@ use crate::node::{Encoding, StringOrBuffer}; use crate::server::jsc::{ self, ErrorCode, JSGlobalObject, JSValue, JsResult, StrongOptional, VirtualMachine, }; -use crate::server::{AnyServer, AnyServerTag, HTTPStatusText, ServerWebSocket}; +use crate::server::{AnyServer, AnyServerTag, HTTPStatusText}; use crate::webcore::AutoFlusher; bun_core::declare_scope!(NodeHTTPResponse, visible); @@ -55,15 +55,6 @@ mod connection { self.0.get() } - /// A WebSocket adopts the socket. Only the current response can hand it over. - pub(super) fn take_for_upgrade(&self, flags: Flags) -> Option { - if flags.contains(Flags::CURRENT) { - self.0.take() - } else { - None - } - } - /// The socket closed, or the server socket took the connection back. pub(super) fn release(&self) { self.0.set(None); @@ -123,15 +114,13 @@ bitflags! { const SOCKET_CLOSED = 1 << 0; const REQUEST_HAS_COMPLETED = 1 << 1; const ENDED = 1 << 2; - const UPGRADED = 1 << 3; + /// The call that emits 'request' hasn't returned, so no tick has run since the response was made. + const IN_LISTENER = 1 << 3; /// 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; - /// node:http handed this connection to a raw 'upgrade'/'connect' - /// tunnel (JSNodeHTTPServerSocket::upgradeToTunnelMode). - const TUNNELED = 1 << 8; - /// Its dispatch threw while it was queued (pipelining): advanceResponsePipeline decides at its turn. - const DISPATCH_THREW_WHILE_QUEUED = 1 << 9; + /// See `update_parked`. + const PARKED = 1 << 7; } } @@ -139,8 +128,7 @@ const _: () = assert!(size_of::() == 2); impl Default for Flags { fn default() -> Self { - // is_request_pending defaults to true; all others false. - Flags::IS_REQUEST_PENDING + Flags::IS_REQUEST_PENDING | Flags::IN_LISTENER } } @@ -168,8 +156,6 @@ pub(crate) enum BodyReadState { Complete = 2, /// The connection closed before the last chunk. Aborted = 3, - /// A WebSocket took the connection before the last chunk. - Upgraded = 4, /// The reader let go of the body before the last chunk. Detached = 5, } @@ -179,11 +165,8 @@ unsafe extern "C" { // only reads its ext slot. Module-private — the sole callers below pass a // live handle, so no caller-side precondition remains. safe fn Bun__getNodeHTTPResponseThisValue(is_ssl: bool, socket: *mut c_void) -> JSValue; - safe fn Bun__getNodeHTTPServerSocketThisValue(is_ssl: bool, socket: *mut c_void) -> JSValue; - // 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; @@ -453,39 +436,19 @@ impl NodeHTTPResponse { let Some(raw) = self.reader() else { return JSValue::ZERO; }; - if flags.contains(Flags::SOCKET_CLOSED) || flags.contains(Flags::UPGRADED) { + if flags.contains(Flags::SOCKET_CLOSED) { return JSValue::ZERO; } Bun__getNodeHTTPResponseThisValue(any_response_is_ssl(&raw), raw.socket().cast()) } - /// Flags this response when it is not the connection's current response, and says so. - pub(crate) fn mark_dispatch_threw_if_queued(&self) -> bool { - let queued = !self.flags.get().contains(Flags::CURRENT); - if queued { - self.update_flags(|f| f.insert(Flags::DISPATCH_THREW_WHILE_QUEUED)); - } - queued - } - - fn get_server_socket_value(&self) -> JSValue { - let flags = self.flags.get(); - let Some(raw) = self.reader() else { - return JSValue::ZERO; - }; - if flags.contains(Flags::SOCKET_CLOSED) || flags.contains(Flags::UPGRADED) { - return JSValue::ZERO; - } - Bun__getNodeHTTPServerSocketThisValue(any_response_is_ssl(&raw), raw.socket().cast()) - } - /// Called at this request's body fin, still inside the parser: move the /// connection's captured trailer section onto this request before the next /// pipelined message's parse can overwrite it. A no-op unless a chunked /// body's trailer section was captured for this exact message. fn capture_request_trailers(&self) { let flags = self.flags.get(); - if flags.contains(Flags::SOCKET_CLOSED) || flags.contains(Flags::UPGRADED) { + if flags.contains(Flags::SOCKET_CLOSED) { return; } let Some(raw) = self.reader() else { @@ -512,146 +475,22 @@ impl NodeHTTPResponse { let Some(raw) = self.reader() else { return; }; - if flags.contains(Flags::SOCKET_CLOSED) - || flags.contains(Flags::UPGRADED) - || raw.is_connect_request() - { - return; - } - raw.pause(); - } - - /* Pipelined flood prevention pauses READS on the connection, legal after the in-flight - * response has ended — so this intentionally skips doPause's ENDED/REQUEST_HAS_COMPLETED - * guards (those exist for request-body flow control). */ - pub(crate) fn pause_reads(&self) { - let flags = self.flags.get(); - let Some(raw) = self.reader() else { - return; - }; - if flags.contains(Flags::SOCKET_CLOSED) - || flags.contains(Flags::UPGRADED) - || raw.is_connect_request() - { + if flags.contains(Flags::SOCKET_CLOSED) { return; } raw.pause(); - Bun__NodeHTTP__onReadsPaused( - any_response_is_ssl(&raw) as core::ffi::c_int, - raw.socket().cast(), - ); + self.update_parked(); } fn resume_socket(&self) { scoped_log!(NodeHTTPResponse, "resumeSocket"); let flags = self.flags.get(); - let Some(raw) = self.reader() else { - return; - }; - if flags.contains(Flags::SOCKET_CLOSED) - || flags.contains(Flags::UPGRADED) - || raw.is_connect_request() + if let Some(raw) = self.reader() + && !flags.contains(Flags::SOCKET_CLOSED) { - return; - } - // Not a bare resume: flood prevention can still hold the reads. - Bun__NodeHTTP__onReadsResumable( - any_response_is_ssl(&raw) as core::ffi::c_int, - raw.socket().cast(), - ); - } - - /// Whether this response can still be upgraded. Check before writing anything. - /// - /// `server` must be read last. JS can hold a response after its server was freed, but the - /// server stays alive while the connection is open (`NewServer::is_drained`). - pub(crate) fn can_upgrade(&self) -> bool { - !self - .flags - .get() - .intersects(Flags::ENDED | Flags::SOCKET_CLOSED) - && self.writer().is_some_and(|writer| writer.can_upgrade()) - && !self.server.terminated() - && !self.get_server_socket_value().is_empty() - } - - /// Upgrades the connection for the `ws` package, which has already negotiated and built - /// `handshake` (the full response head). Returns the `ServerWebSocket`, or `None` if the - /// upgrade failed, in which case nothing was written. - pub(crate) fn upgrade_for_ws_package( - &self, - global: &JSGlobalObject, - websocket: JSValue, - handlers: JSValue, - handshake: &[u8], - max_payload_length: u32, - auto_pong: bool, - skip_utf8_validation: bool, - deflate_params: Option<&uws_sys::response::PerMessageDeflateParams>, - ) -> JsResult> { - if !self.can_upgrade() { - return Ok(None); - } - let server = self.server; - server.init_ws_package_handlers(global, handlers)?; - self.resume_socket(); - if !self.can_upgrade() { - return Ok(None); - } - let Some(handler) = server.ws_package_handler() else { - return Ok(None); - }; - let Some(context) = server.ws_package_context( - max_payload_length, - auto_pong, - skip_utf8_validation, - deflate_params.is_some(), - ) else { - return Ok(None); - }; - - let ws = ServerWebSocket::init_for_ws_package(handler.get(), websocket); - // SAFETY: `init` just stored `ws` inline in its JS wrapper, which `ws` keeps alive. - let ws_ref = unsafe { &*ws }; - - let armed_reader = self.armed_this_value.get(); - let Some(raw_response) = self.connection.take_for_upgrade(self.flags.get()) else { - ws_ref.discard_unopened(); - return Ok(None); - }; - self.update_flags(|f| f.insert(Flags::UPGRADED)); - let ended_unfinished_body = self.leave_pending(BodyReadState::Upgraded); - // Unref the poll_ref since the socket is now upgraded to WebSocket - // and will have its own lifecycle management - let vm = self.server.global_this().bun_vm().as_mut(); - self.poll_ref.with_mut(|r| r.unref(vm)); - // During the upgrade the connection counts as neither HTTP nor WebSocket. Hold a pending - // request so a stopped server doesn't drain if this is its last connection. - server.on_pending_request(); - let upgraded = !raw_response - .upgrade_raw( - ws, - handshake, - deflate_params, - // S008: `WebSocketUpgradeContext` is an `opaque_ffi!` ZST — safe deref. The app owns it. - bun_opaque::opaque_deref_mut(context.as_ptr()), - ) - .is_null(); - server.on_request_complete(); - if upgraded { - self.bytes_written - .set(self.bytes_written.get().saturating_add(handshake.len())); - } else { - ws_ref.discard_unopened(); - } - - // Last step: a reader that waits for the body gets its 'end', like Node 25 and older. - if ended_unfinished_body && !armed_reader.is_empty() { - let _guard = self.ref_guard(); - self.on_data_or_aborted(b"", true, AbortEvent::None, armed_reader); + raw.resume(); } - - Ok(if upgraded { ws_ref.js_value() } else { None }) + self.update_parked(); } pub(crate) fn maybe_stop_reading_body(&self, this_value: JSValue) { @@ -659,8 +498,6 @@ impl NodeHTTPResponse { // An ended response keeps a body that a reader is armed for: it completes at its last chunk. let stopped = if flags.contains(Flags::SOCKET_CLOSED) { Some(BodyReadState::Aborted) - } else if flags.contains(Flags::UPGRADED) { - Some(BodyReadState::Upgraded) } else if flags.contains(Flags::ENDED) && !js::on_data_get_cached(this_value).is_some_and(|cb| cb.is_cell()) { @@ -680,17 +517,45 @@ impl NodeHTTPResponse { if self.body_read_state.get() != BodyReadState::Pending { return false; } - let flags = self.flags.get(); - if !flags.contains(Flags::UPGRADED) && !flags.contains(Flags::SOCKET_CLOSED) { + if !self.flags.get().contains(Flags::SOCKET_CLOSED) { 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::REQUEST_HAS_COMPLETED) + && self + .reader() + .is_some_and(|raw| raw.is_paused() && 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); @@ -713,25 +578,14 @@ impl NodeHTTPResponse { fn should_request_be_pending(&self) -> bool { let flags = self.flags.get(); - // Once the socket is closed or has been adopted by the WebSocket - // layer, the HTTP request/response cycle is over — no further uws - // callbacks will arrive on the connection to balance the - // IS_REQUEST_PENDING ref, so report not-pending so - // `mark_request_as_done()` can release it. - if flags.contains(Flags::SOCKET_CLOSED) || flags.contains(Flags::UPGRADED) { + // No further uws callback comes to balance the IS_REQUEST_PENDING ref, so `mark_request_as_done()` has to release it. + if flags.contains(Flags::SOCKET_CLOSED) { return false; } // The body keeps the request pending only while uws still owes it chunks. let body_pending = self.body_still_arriving(); - // A raw 'upgrade'/'connect' tunnel handoff ends the HTTP exchange the - // same way, except an Upgrade carrying a body keeps parsing as HTTP - // until the body's fin chunk (the actual tunnel start). - if flags.contains(Flags::TUNNELED) { - return body_pending; - } - if flags.contains(Flags::ENDED) { // Pending until the request body is read and the response body has drained. return body_pending || !flags.contains(Flags::REQUEST_HAS_COMPLETED); @@ -763,6 +617,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 server = self.server; @@ -797,11 +652,8 @@ impl NodeHTTPResponse { if flags.contains(Flags::SOCKET_CLOSED) { return; } - // Don't overwrite WebSocket user data - if !flags.contains(Flags::UPGRADED) { - if let Some(raw_response) = self.reader() { - raw_response.on_timeout(on_timeout_shim, self.as_ctx_ptr()); - } + if let Some(raw_response) = self.reader() { + raw_response.on_timeout(on_timeout_shim, self.as_ctx_ptr()); } } @@ -816,7 +668,7 @@ impl NodeHTTPResponse { return true; } // The connection outlives the socket of a done request: do not read it. - if flags.is_done() || flags.contains(Flags::UPGRADED) { + if flags.is_done() { return false; } self.reader() @@ -831,24 +683,19 @@ impl NodeHTTPResponse { flags.bits() as i32 } - pub(crate) fn get_aborted(&self) -> bool { - self.is_socket_closed_or_closing() - } - pub(crate) fn get_has_body(&self) -> i32 { let mut result: i32 = 0; match self.body_read_state.get() { BodyReadState::None => {} BodyReadState::Pending => result |= 1 << 1, - BodyReadState::Complete | BodyReadState::Upgraded | BodyReadState::Detached => { - result |= 1 << 2 - } + BodyReadState::Complete | BodyReadState::Detached => result |= 1 << 2, BodyReadState::Aborted => result |= 1 << 3, } result } + #[uws::uws_callback(export = "Bun__NodeHTTPResponse_bufferedAmount", no_catch)] pub(crate) fn get_buffered_amount(&self) -> u64 { let flags = self.flags.get(); if flags.contains(Flags::REQUEST_HAS_COMPLETED) || flags.contains(Flags::SOCKET_CLOSED) { @@ -861,18 +708,6 @@ impl NodeHTTPResponse { } 0 } - - pub(crate) fn js_ref(&self) { - if !self.is_done() { - self.poll_ref.with_mut(|r| r.r#ref(vm_get())); - } - } - - pub(crate) fn js_unref(&self) { - if !self.is_done() { - self.poll_ref.with_mut(|r| r.unref(vm_get())); - } - } } fn handle_ended_if_necessary(state: uws::State, global_object: &JSGlobalObject) -> JsResult<()> { @@ -894,8 +729,8 @@ impl NodeHTTPResponse { pub(crate) fn write_head( &self, cx: &mut JsScope<'_>, - status_code: i32, - status_message: Option<&[u8]>, + status_code: u16, + status_message: Option, headers: JsLocal<'_>, auto_header_bits: u32, keep_alive_timeout_secs: u32, @@ -906,7 +741,19 @@ impl NodeHTTPResponse { } else { JSValue::UNDEFINED }; - let status_message_bytes: &[u8] = status_message.unwrap_or_default(); + // The status line is Latin-1, like Node's. ServerResponse has refused control characters and code units above 0xFF. + let status_message = status_message.unwrap_or_default(); + let narrowed: Vec; + let status_message_bytes: &[u8] = if status_message.is_8bit() { + status_message.latin1() + } else { + narrowed = status_message + .utf16() + .iter() + .map(|&unit| u8::try_from(unit).unwrap_or(b'?')) + .collect(); + &narrowed + }; if self.is_requested_completed_or_ended() { return err_throw( @@ -916,12 +763,11 @@ impl NodeHTTPResponse { ); } - let flags = self.flags.get(); let Some(raw_response) = self.writer() else { // We haven't emitted the "close" event yet. return Ok(()); }; - if flags.contains(Flags::UPGRADED) || self.is_socket_closed_or_closing() { + if self.is_socket_closed_or_closing() { // We haven't emitted the "close" event yet. return Ok(()); } @@ -937,24 +783,9 @@ impl NodeHTTPResponse { ); } - // Validate status message does not contain invalid characters (defense-in-depth - // against HTTP response splitting). Matches Node.js checkInvalidHeaderChar: - // rejects any char not in [\t\x20-\x7e\x80-\xff]. - for &c in status_message_bytes { - if c != b'\t' && (c < 0x20 || c == 0x7f) { - return err_throw( - global_object, - ErrorCode::ERR_INVALID_CHAR, - "Invalid character in statusMessage", - ); - } - } - 'do_it: { if status_message_bytes.is_empty() { - if let Some(status_message) = - HTTPStatusText::get(u16::try_from(status_code).expect("int cast")) - { + if let Some(status_message) = HTTPStatusText::get(status_code) { write_head_internal( &raw_response, global_object, @@ -975,7 +806,7 @@ impl NodeHTTPResponse { // 256-byte stack buffer + plain memcpy. The previous Vec + write! + // BStr-Display path showed up at 0.54% incl in perf (core::fmt vtable - // + BStr UTF-8 chunk-validation). status_code is 100..=999 → always 3 digits. + // + BStr UTF-8 chunk-validation). let mut itoa_buf = bun_core::fmt::ItoaBuf::new(); let code = bun_core::fmt::itoa(&mut itoa_buf, status_code); let n = code.len() + 1 + message.len(); @@ -1022,22 +853,75 @@ impl NodeHTTPResponse { &self, this_value: JsWrapper<'_, Self>, cx: &mut JsScope<'_>, - status_code: i32, - status_message: Option<&[u8]>, + status_code: u16, + status_message: Option, + headers: JsLocal<'_>, + chunk: Option>, + encoding: JsLocal<'_>, + strict_content_length: Option, + auto_header_bits: u32, + keep_alive_timeout_secs: u32, + ) -> JsResult { + self.write_head_and::( + this_value.to_js(), + cx, + status_code, + status_message, + headers, + chunk, + encoding, + JSValue::UNDEFINED, + strict_content_length, + auto_header_bits, + keep_alive_timeout_secs, + ) + } + + /// The first `res.write()`: the head and the chunk in one call, so every argument is converted before a byte is sent. + pub(crate) fn write_head_and_write( + &self, + cx: &mut JsScope<'_>, + status_code: u16, + status_message: Option, + headers: JsLocal<'_>, + chunk: Option>, + encoding: JsLocal<'_>, + callback: Option>, + strict_content_length: Option, + auto_header_bits: u32, + keep_alive_timeout_secs: u32, + ) -> JsResult { + self.write_head_and::( + JSValue::ZERO, + cx, + status_code, + status_message, + headers, + chunk, + encoding, + callback.map_or(JSValue::UNDEFINED, JsLocal::to_js), + strict_content_length, + auto_header_bits, + keep_alive_timeout_secs, + ) + } + + fn write_head_and( + &self, + this_value: JSValue, + cx: &mut JsScope<'_>, + status_code: u16, + status_message: Option, headers: JsLocal<'_>, chunk: Option>, encoding: JsLocal<'_>, + callback: JSValue, strict_content_length: Option, auto_header_bits: u32, keep_alive_timeout_secs: u32, ) -> JsResult { - // Same gate as cork(): the old flow threw ERR_STREAM_ALREADY_FINISHED - // from cork() before either phase ran. let flags = self.flags.get(); - if flags.contains(Flags::REQUEST_HAS_COMPLETED) - || flags.contains(Flags::SOCKET_CLOSED) - || flags.contains(Flags::UPGRADED) - { + if flags.contains(Flags::REQUEST_HAS_COMPLETED) || flags.contains(Flags::SOCKET_CLOSED) { return err_throw( cx.global(), ErrorCode::ERR_STREAM_ALREADY_FINISHED, @@ -1045,24 +929,31 @@ impl NodeHTTPResponse { ); } - // BACKREF: same keep-alive pattern as cork() — either phase can reach - // JS (string coercions, drain callbacks), which could drop the last - // reference to this response mid-call. + // Whatever throws, throws before the first byte of the head. + let chunk = Chunk::from_js(cx.global(), chunk, encoding.to_js())?; + self.check_content_length::(cx.global(), chunk.len(), strict_content_length)?; + + // BACKREF: either phase can reach JS (string coercions, drain callbacks), which could drop the last reference to this response mid-call. let this = bun_ptr::BackRef::from(ptr::NonNull::from(self)); let _guard = self.ref_guard(); - if let Some(written) = this.write_whole_response( - this_value.to_js(), - cx.global(), - status_code, - status_message, - headers.to_js(), - WholeBody::Converted(chunk.as_ref()), - encoding.to_js(), - strict_content_length, - auto_header_bits, - keep_alive_timeout_secs, - ) { + if IS_END + && encoding.to_js().is_undefined_or_null() + && status_message + .as_ref() + .is_none_or(|message| message.is_8bit()) + && let Some(written) = this.write_whole_response( + this_value, + cx.global(), + status_code, + status_message.as_ref().map(|message| message.latin1()), + headers.to_js(), + chunk.bytes.slice(), + None, + auto_header_bits, + keep_alive_timeout_secs, + ) + { return Ok(written); } @@ -1078,20 +969,14 @@ impl NodeHTTPResponse { auto_header_bits, keep_alive_timeout_secs, )?; - this.resume_socket(); - this.write_or_end::( - this_value.to_js(), - cx.global(), - chunk, - encoding.to_js(), - JSValue::UNDEFINED, - strict_content_length, - ) + if IS_END { + this.resume_socket(); + } + this.send::(this_value, cx.global(), chunk, callback, None) }; if let Some(raw_response) = raw_response { raw_response.corked(|| { - // Capture `this` so a `self`-derived pointer reaches the - // FFI closure-data slot (see cork()'s R-2 note). + // Capture `this` so a `self`-derived pointer reaches the FFI closure-data slot. let _escape = this; result = run(); }); @@ -1175,7 +1060,7 @@ impl NodeHTTPResponse { let flags = self.flags.get(); if flags.contains(Flags::REQUEST_HAS_COMPLETED) || flags.contains(Flags::SOCKET_CLOSED) - || flags.contains(Flags::UPGRADED) + || !encoding.to_js().is_undefined_or_null() { return Ok(JSValue::UNDEFINED); } @@ -1186,11 +1071,10 @@ impl NodeHTTPResponse { .write_whole_response( this_value.to_js(), cx.global(), - status_code, + status_code as u16, status_message, headers.to_js(), - WholeBody::InPlace(body), - encoding.to_js(), + body, strict_content_length, auto_header_bits as u32, keep_alive_timeout_secs as u32, @@ -1199,12 +1083,6 @@ impl NodeHTTPResponse { } } -#[derive(Clone, Copy)] -enum WholeBody<'a, 'b> { - InPlace(&'a [u8]), - Converted(Option<&'a StringOrJsBytes<'b>>), -} - fn bytes_of_short_chunk<'a>(chunk: JSValue) -> Option<&'a [u8]> { if !chunk.is_cell() { return None; @@ -1226,41 +1104,37 @@ impl NodeHTTPResponse { &self, this_value: JSValue, global_object: &JSGlobalObject, - status_code: i32, + status_code: u16, status_message: Option<&[u8]>, headers: JSValue, - body: WholeBody<'_, '_>, - encoding: JSValue, + bytes: &[u8], strict_content_length: Option, auto_header_bits: u32, keep_alive_timeout_secs: u32, ) -> Option { - if !encoding.is_undefined_or_null() { - return None; - } let mut written_status = [0u8; 128]; let status: &[u8] = match status_message { - None => HTTPStatusText::get(u16::try_from(status_code).expect("int cast"))?, + None => HTTPStatusText::get(status_code)?, Some(message) => { + let mut itoa_buf = bun_core::fmt::ItoaBuf::new(); + let code = bun_core::fmt::itoa(&mut itoa_buf, status_code); + let start = code.len() + 1; if message.is_empty() - || message.len() > written_status.len() - 4 + || message.len() > written_status.len() - start || !message.iter().all(|c| (0x20..=0x7e).contains(c)) { return None; } - let mut itoa_buf = bun_core::fmt::ItoaBuf::new(); - written_status[..3] - .copy_from_slice(bun_core::fmt::itoa(&mut itoa_buf, status_code)); - written_status[3] = b' '; - written_status[4..4 + message.len()].copy_from_slice(message); - &written_status[..4 + message.len()] + written_status[..code.len()].copy_from_slice(code); + written_status[code.len()] = b' '; + written_status[start..start + message.len()].copy_from_slice(message); + &written_status[..start + message.len()] } }; let flags = self.flags.get(); let raw_response = self.writer()?; if flags.is_requested_completed_or_ended() - || flags.contains(Flags::UPGRADED) || self.is_socket_closed_or_closing() || self.pending_pinned_write.get().is_some() { @@ -1276,24 +1150,6 @@ impl NodeHTTPResponse { uws::AnyResponse::H3(_) | uws::AnyResponse::H2(_) => return None, }; - let utf8; - let buffer; - let bytes: &[u8] = match body { - WholeBody::InPlace(bytes) => bytes, - WholeBody::Converted(None) => &[], - WholeBody::Converted(Some(StringOrJsBytes::String(string))) => { - if string.length() > PINNED_WRITE_THRESHOLD { - return None; - } - utf8 = string.to_utf8(); - utf8.slice() - } - WholeBody::Converted(Some(StringOrJsBytes::Bytes(bytes))) => { - buffer = - bun_jsc::MarkedArrayBuffer::from_array_buffer(global_object, bytes.to_js()); - buffer.slice() - } - }; let bytes_written = self.bytes_written.get().saturating_add(bytes.len()); if strict_content_length .is_some_and(|length| bytes_written as u64 != (length as i64).max(0) as u64) @@ -1381,6 +1237,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(()) } @@ -1415,6 +1273,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(()) } } @@ -1516,7 +1375,7 @@ impl NodeHTTPResponse { pub(crate) fn on_read_parsed(&self) { // Same test as notify_when_read_parsed: a TLS close that waits for spilled bytes leaves the socket open. let flags = self.flags.get(); - if flags.contains(Flags::SOCKET_CLOSED) || flags.contains(Flags::UPGRADED) { + if flags.contains(Flags::SOCKET_CLOSED) { return; } let armed = self.armed_this_value.get(); @@ -1533,7 +1392,7 @@ impl NodeHTTPResponse { let Some(raw) = self.reader() else { return false; }; - if flags.contains(Flags::SOCKET_CLOSED) || flags.contains(Flags::UPGRADED) { + if flags.contains(Flags::SOCKET_CLOSED) { return false; } Bun__NodeHTTP__notifyWhenReadParsed( @@ -1556,13 +1415,13 @@ impl NodeHTTPResponse { self.update_flags(|f| f.insert(Flags::CURRENT)); } - /// `js_this` is this response's wrapper. `adopted`: a WebSocket has the socket, so nothing of it is touched. + /// `js_this` is this response's wrapper. #[uws::uws_callback(export = "Bun__NodeHTTPResponse_takeBackConnection", no_catch)] #[inline] - pub(crate) fn take_back_connection(&self, js_this: JSValue, adopted: bool) { + pub(crate) fn take_back_connection(&self, js_this: JSValue) { let flags = self.flags.get(); - if flags.intersects(Flags::IS_REQUEST_PENDING | Flags::UPGRADED) { - return self.take_back_unfinished_connection(js_this, adopted); + if flags.contains(Flags::IS_REQUEST_PENDING) { + return self.take_back_unfinished_connection(js_this); } // Finished, the case of each keep-alive request: `mark_request_as_done()` left nothing of it on the connection. self.connection.release(); @@ -1571,25 +1430,20 @@ impl NodeHTTPResponse { #[cold] #[inline(never)] - fn take_back_unfinished_connection(&self, js_this: JSValue, adopted: bool) { + fn take_back_unfinished_connection(&self, js_this: JSValue) { scoped_log!(NodeHTTPResponse, "takeBackConnection"); let flags = self.flags.get(); - if flags.contains(Flags::UPGRADED) { - // It handed the socket to the WebSocket itself. - return; - } - let _guard = self.ref_guard(); let global_object = self.server.global_this(); let pinned = self.pending_pinned_write.get(); if pinned.is_some() { // The bytes that a write() already counted go out before the next response. - if !adopted && let Some(raw_response) = self.writer() { + if let Some(raw_response) = self.writer() { raw_response.spill_body(pinned.remaining()); } self.clear_pending_pinned_write(global_object, js_this); } - if !adopted && let Some(raw_response) = self.reader() { + if let Some(raw_response) = self.reader() { raw_response.clear_handlers_of(self.as_ctx_ptr()); } self.connection.release(); @@ -1606,14 +1460,6 @@ impl NodeHTTPResponse { } } - /// Flag-only: the pending-request release happens deterministically in - /// the dispatch tail (`on_node_http_request*` in `mod.rs`) or, for an - /// Upgrade with a body, at the body's fin chunk. - #[uws::uws_callback(export = "Bun__NodeHTTPResponse_markTunneled", no_catch)] - pub(crate) fn mark_tunneled(&self) { - self.update_flags(|f| f.insert(Flags::TUNNELED)); - } - /// A raw write or a FIN on the socket has to go out behind the zero-copy tail of an earlier `write()`. #[uws::uws_callback(export = "Bun__NodeHTTPResponse_spillPendingWrite", no_catch)] pub(crate) fn spill_pending_write(&self) { @@ -1631,7 +1477,6 @@ impl NodeHTTPResponse { let ended = flags.contains(Flags::REQUEST_HAS_COMPLETED) || flags.contains(Flags::ENDED); if self.reader().is_none() || flags.contains(Flags::SOCKET_CLOSED) - || flags.contains(Flags::UPGRADED) || (ended && !self.body_still_arriving()) { return false; @@ -1649,7 +1494,6 @@ impl NodeHTTPResponse { && !flags.contains(Flags::REQUEST_HAS_COMPLETED) && !flags.contains(Flags::SOCKET_CLOSED) && !flags.contains(Flags::ENDED) - && !flags.contains(Flags::UPGRADED) } pub(crate) fn on_request_complete(&self) { @@ -1660,6 +1504,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(); } } @@ -1721,10 +1566,7 @@ pub(crate) fn on_reject( this.maybe_stop_reading_body(wrapper.to_js()); let flags = this.flags.get(); - if !flags.contains(Flags::REQUEST_HAS_COMPLETED) - && !flags.contains(Flags::UPGRADED) - && !this.is_socket_closed_or_closing() - { + if !flags.contains(Flags::REQUEST_HAS_COMPLETED) && !this.is_socket_closed_or_closing() { let this_value = this.get_this_value(); if !this_value.is_empty() { js::on_aborted_set_cached(this_value, global_object, JSValue::ZERO); @@ -1751,6 +1593,65 @@ pub(crate) fn on_reject( } } +/// The chunk of a `write()` or an `end()`, once its encoding is known to be valid. +struct Chunk { + bytes: StringOrBuffer<'static>, + /// The JS buffer `bytes` points into. + buffer_value: Option, +} + +impl Chunk { + fn from_js( + global_object: &JSGlobalObject, + chunk: Option>, + encoding_value: JSValue, + ) -> JsResult { + let Some(chunk) = chunk else { + return Ok(Self { + bytes: StringOrBuffer::EMPTY, + buffer_value: None, + }); + }; + let buffer_value = match &chunk { + StringOrJsBytes::Bytes(bytes) => Some(bytes.to_js()), + StringOrJsBytes::String(_) => None, + }; + let mut encoding = Encoding::Utf8; + // Like Writable.prototype.write: a falsy encoding means the default. + if !encoding_value.is_falsey() { + let known = if encoding_value.is_string() { + Encoding::from_js(encoding_value, global_object)? + } else { + None + }; + encoding = match known { + Some(e) => e, + None => { + let name = if encoding_value.is_string() { + encoding_value.to_bun_string(global_object)? + } else { + JSGlobalObject::inspect_for_error_message(global_object, encoding_value)? + }; + return Err(global_object + .err( + ErrorCode::UNKNOWN_ENCODING, + format_args!("Unknown encoding: {}", name), + ) + .throw()); + } + }; + } + Ok(Self { + bytes: StringOrBuffer::from_string_or_js_bytes(global_object, chunk, encoding), + buffer_value, + }) + } + + fn len(&self) -> usize { + self.bytes.slice().len() + } +} + impl NodeHTTPResponse { pub(crate) fn abort(&self, cx: &mut JsScope<'_>) { let global_object = cx.global(); @@ -1760,7 +1661,7 @@ impl NodeHTTPResponse { // uws is parsing this socket: it delivers the rest of the read, then closes (on_abort runs then). if let Some(raw_response) = self.reader() - && raw_response.close_after_message_if_parsing() + && raw_response.close_after_message_if_parsing(self.wrote_only_in_this_tick()) { return; } @@ -1791,11 +1692,17 @@ 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(); } + /// Node corks the socket until the tick after a write, so a destroy in the same tick sends nothing. `flushHeaders()` isn't corked there. + fn wrote_only_in_this_tick(&self) -> bool { + let first_tick = Flags::IN_LISTENER | Flags::CURRENT; + self.flags.get().contains(first_tick) && !self.auto_flusher.get().registered.get() + } + fn get_bytes(&self, global_this: &JSGlobalObject, chunk: &[u8]) -> JSValue { if chunk.is_empty() { return JSValue::UNDEFINED; @@ -1823,6 +1730,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) { @@ -1983,7 +1891,7 @@ impl NodeHTTPResponse { scoped_log!(NodeHTTPResponse, "onDrain({})", offset); let flags = self.flags.get(); - if flags.contains(Flags::SOCKET_CLOSED) || flags.contains(Flags::UPGRADED) { + if flags.contains(Flags::SOCKET_CLOSED) { // return false means we don't have anything to drain return false; } @@ -2048,58 +1956,59 @@ impl NodeHTTPResponse { this_value: JSValue, global_object: &JSGlobalObject, chunk: Option>, - mut encoding_value: JSValue, + encoding_value: JSValue, callback: JSValue, strict_content_length: Option, ) -> JsResult { - let callback_value: JSValue = - if !encoding_value.is_undefined_or_null() && encoding_value.is_callable() { - core::mem::replace(&mut encoding_value, JSValue::UNDEFINED) - } else { - callback - }; - - let strict_content_length: Option = - strict_content_length.map(|length| (length as i64).max(0) as u64); + let chunk = Chunk::from_js(global_object, chunk, encoding_value)?; + self.send::( + this_value, + global_object, + chunk, + callback, + strict_content_length, + ) + } - let buffer_value = match &chunk { - Some(StringOrJsBytes::Bytes(bytes)) => Some(bytes.to_js()), - _ => None, + fn check_content_length( + &self, + global_object: &JSGlobalObject, + chunk_len: usize, + strict_content_length: Option, + ) -> JsResult<()> { + let Some(content_length) = strict_content_length else { + return Ok(()); }; - let mut string_or_buffer = if let Some(chunk) = chunk { - let mut encoding = Encoding::Utf8; - // Like Writable.prototype.write: a falsy encoding means the default. - if !encoding_value.is_falsey() { - let known = if encoding_value.is_string() { - Encoding::from_js(encoding_value, global_object)? - } else { - None - }; - encoding = match known { - Some(e) => e, - None => { - let name = if encoding_value.is_string() { - encoding_value.to_bun_string(global_object)? - } else { - JSGlobalObject::inspect_for_error_message( - global_object, - encoding_value, - )? - }; - return Err(global_object - .err( - ErrorCode::UNKNOWN_ENCODING, - format_args!("Unknown encoding: {}", name), - ) - .throw()); - } - }; - } - - StringOrBuffer::from_string_or_js_bytes(global_object, chunk, encoding) + let content_length = (content_length as i64).max(0) as u64; + let bytes_written = self.bytes_written.get() + chunk_len; + let mismatch = if IS_END { + bytes_written as u64 != content_length } else { - StringOrBuffer::EMPTY + bytes_written as u64 > content_length }; + if mismatch { + return Err(err_throw_content_length_mismatch( + global_object, + bytes_written, + content_length, + )); + } + Ok(()) + } + + /// `strict_content_length` is `None` if the caller has checked it. + fn send( + &self, + this_value: JSValue, + global_object: &JSGlobalObject, + chunk: Chunk, + callback_value: JSValue, + strict_content_length: Option, + ) -> JsResult { + let Chunk { + bytes: mut string_or_buffer, + buffer_value, + } = chunk; if self.is_requested_completed_or_ended() { return err_throw( @@ -2145,32 +2054,20 @@ impl NodeHTTPResponse { bytes.len() ); } - if let Some(content_length) = strict_content_length { - let bytes_written = self.bytes_written.get() + bytes.len(); - - let mismatch = if IS_END { - bytes_written as u64 != content_length - } else { - bytes_written as u64 > content_length - }; - if mismatch { - return Err(err_throw_content_length_mismatch( - global_object, - bytes_written, - content_length, - )); - } - self.bytes_written.set(bytes_written); - } else { - self.bytes_written - .set(self.bytes_written.get().saturating_add(bytes.len())); - } + self.check_content_length::(global_object, bytes.len(), strict_content_length)?; + self.bytes_written + .set(self.bytes_written.get().saturating_add(bytes.len())); let js_this = if !this_value.is_empty() { this_value } else { 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() { @@ -2352,7 +2249,7 @@ impl NodeHTTPResponse { pub(crate) fn get_onabort(&self, this_value: JsWrapper<'_, Self>) -> JSValue { let flags = self.flags.get(); - if flags.contains(Flags::SOCKET_CLOSED) || flags.contains(Flags::UPGRADED) { + if flags.contains(Flags::SOCKET_CLOSED) { return JSValue::UNDEFINED; } js::on_aborted_get_cached(this_value.to_js()).unwrap_or(JSValue::UNDEFINED) @@ -2366,7 +2263,7 @@ impl NodeHTTPResponse { ) { let (this_value, global_object, value) = (this_value.to_js(), cx.global(), value.to_js()); let flags = self.flags.get(); - if flags.contains(Flags::SOCKET_CLOSED) || flags.contains(Flags::UPGRADED) { + if flags.contains(Flags::SOCKET_CLOSED) { return; } @@ -2385,10 +2282,6 @@ impl NodeHTTPResponse { js::on_data_get_cached(this_value.to_js()).unwrap_or(JSValue::UNDEFINED) } - pub(crate) fn get_upgraded(&self) -> bool { - self.flags.get().contains(Flags::UPGRADED) - } - fn clear_on_data_callback(&self, this_value: JSValue, global_object: &JSGlobalObject) { scoped_log!(NodeHTTPResponse, "clearOnDataCallback"); // Clear on the wrapper that armed ondata (see on_data): the parameter may @@ -2402,8 +2295,6 @@ impl NodeHTTPResponse { let flags = self.flags.get(); self.leave_pending(if flags.contains(Flags::SOCKET_CLOSED) { BodyReadState::Aborted - } else if flags.contains(Flags::UPGRADED) { - BodyReadState::Upgraded } else { BodyReadState::Detached }); @@ -2424,7 +2315,6 @@ impl NodeHTTPResponse { if value.is_undefined_or_null() || flags.contains(Flags::SOCKET_CLOSED) || self.body_read_state.get() != BodyReadState::Pending - || flags.contains(Flags::UPGRADED) { js::on_data_set_cached(this_value, global_object, JSValue::UNDEFINED); self.armed_this_value.set(JSValue::ZERO); @@ -2444,8 +2334,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( @@ -2467,8 +2357,7 @@ impl NodeHTTPResponse { } fn on_auto_flush(&self) -> bool { - let flags = self.flags.get(); - if !flags.contains(Flags::UPGRADED) && !self.is_socket_closed_or_closing() { + if !self.is_socket_closed_or_closing() { if let Some(raw_response) = self.writer() { raw_response.uncork(); } @@ -2513,8 +2402,7 @@ impl NodeHTTPResponse { } pub(crate) fn flush_headers(&self) { - let flags = self.flags.get(); - if !flags.contains(Flags::UPGRADED) && !self.is_socket_closed_or_closing() { + if !self.is_socket_closed_or_closing() { if let Some(raw_response) = self.writer() { // Don't flush immediately; queue a microtask to uncork the socket. raw_response.flush_headers(false); @@ -2606,6 +2494,7 @@ impl NodeHTTPResponse { }) } + #[uws::uws_callback(export = "Bun__NodeHTTPResponse_bytesWritten", no_catch)] pub(crate) fn get_bytes_written(&self) -> usize { self.bytes_written.get() } @@ -2617,64 +2506,13 @@ impl NodeHTTPResponse { let Some(raw) = self.writer() else { return; }; - if flags.contains(Flags::REQUEST_HAS_COMPLETED) - || flags.contains(Flags::SOCKET_CLOSED) - || flags.contains(Flags::UPGRADED) - { + if flags.contains(Flags::REQUEST_HAS_COMPLETED) || flags.contains(Flags::SOCKET_CLOSED) { return; } raw.timeout(seconds); } - pub(crate) fn cork( - &self, - cx: &mut JsScope<'_>, - callback: JsLocal<'_, Callable>, - ) -> JsResult { - let (global_object, callback) = (cx.global(), callback.to_js()); - - let flags = self.flags.get(); - if flags.contains(Flags::REQUEST_HAS_COMPLETED) - || flags.contains(Flags::SOCKET_CLOSED) - || flags.contains(Flags::UPGRADED) - { - return err_throw( - global_object, - ErrorCode::ERR_STREAM_ALREADY_FINISHED, - "Stream is already ended", - ); - } - - let mut result: JsResult = Ok(JSValue::UNDEFINED); - - // R-2: this method takes `&self`, so the `noalias` miscompile - // (b818e70e1c57) is structurally impossible — `&T` is `readonly`, not - // `noalias`, so re-entrant writes through other `&self` views are - // sound. No `black_box` launder is needed; it was a hard optimization - // barrier on the node:http hot path (`cork` runs on every `res.end()`) - // that forced `self` to memory and blocked inlining/regalloc of the - // cork prologue. - let this = bun_ptr::BackRef::from(ptr::NonNull::from(self)); - // Keeps `self` alive across re-entry. - let _guard = self.ref_guard(); - - // Snapshot before re-entry; the handle is `Copy`. - let raw_response = this.writer(); - if let Some(raw_response) = raw_response { - raw_response.corked(|| { - // Capture `this` so a `self`-derived pointer reaches the FFI - // closure-data slot (see the R-2 note above). - let _escape = this; - result = callback.call(global_object, JSValue::UNDEFINED, &[]); - }); - } else { - result = callback.call(global_object, JSValue::UNDEFINED, &[]); - } - - result - } - pub(crate) fn finalize(&self) { // The JS wrapper is being collected; drop the raw backref so a late // body delivery cannot read through a dead cell. @@ -2710,12 +2548,7 @@ impl Drop for NodeHTTPResponse { debug_assert!(!self.pending_pinned_write.get().is_some()); let flags = self.flags.get(); debug_assert!(!flags.contains(Flags::IS_REQUEST_PENDING)); - debug_assert!( - flags.contains(Flags::SOCKET_CLOSED) - || flags.contains(Flags::REQUEST_HAS_COMPLETED) - // A tunneled response can be finalized while its socket lives. - || flags.contains(Flags::TUNNELED) - ); + debug_assert!(flags.intersects(Flags::SOCKET_CLOSED | Flags::REQUEST_HAS_COMPLETED)); self.poll_ref.with_mut(|r| r.unref(vm_get())); self.body_read_ref.with_mut(|r| r.unref(vm_get())); @@ -2762,21 +2595,10 @@ pub(crate) unsafe extern "C" fn NodeHTTPResponse__createForJS( let vm = bun_vm_mut(global_object); let method = HttpMethod::which(request_ref.method()).unwrap_or(HttpMethod::OPTIONS); - // Like llhttp, the framing decides, not the method. CONNECT has no body: the parser tunnels every byte after its head. + // Like llhttp, the framing decides, not the method. CONNECT has no body: no byte after its head is parsed. if method != HttpMethod::CONNECT { - let req_len: usize = 'brk: { - if let Some(content_length) = request_ref.header(b"content-length") { - scoped_log!( - NodeHTTPResponse, - "content-length: {}", - BStr::new(content_length) - ); - break 'brk bun_http_types::parse_content_length(content_length); - } - break 'brk 0; - }; - - *has_body = req_len > 0 || request_ref.has_transfer_encoding(); + *has_body = + request_ref.content_length().unwrap_or(0) > 0 || request_ref.has_transfer_encoding(); } let raw_response = if is_ssl != 0 { diff --git a/src/runtime/server/RequestContext.bind.ts b/src/runtime/server/RequestContext.bind.ts index 6f544d4e6f82..23fb850a0159 100644 --- a/src/runtime/server/RequestContext.bind.ts +++ b/src/runtime/server/RequestContext.bind.ts @@ -1,10 +1,9 @@ import * as b from "../../codegen/bindgen-lib.ts"; -/** The promise reactions of `RequestContext`, which C++ tells apart by their address. */ +/** The promise reactions of `RequestContext`, which C++ tells apart by their address. */ function reactions(debug: boolean, mux: boolean, ssl: boolean) { const context = `HTTPRequestContext${debug ? "Debug" : ""}${mux ? "Mux" : ""}${ssl ? "TLS" : ""}`; - const server = `crate::server::${debug ? "Debug" : ""}HTTP${ssl ? "S" : ""}Server`; - const rustPath = `crate::server::request_context::RequestContext::<${server}, ${ssl}, ${debug}, ${mux}>`; + const rustPath = `crate::server::request_context::RequestContext::<${ssl}, ${debug}, ${mux}>`; const reaction = (name: string, rustName: string, contextType: b.Type = b.nativePromiseContext(rustPath)) => b.fn({ export: `Bun__${context}__${name}`, diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index fdab0574eba3..714533d09c0c 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -11,7 +11,7 @@ use bun_jsc::bindgen::{JsLocal, JsScope}; use bun_uws::{self as uws, WebSocketUpgradeContext}; use crate::server::jsc::{self, JSGlobalObject, JSValue, JsResult}; -use crate::server::{RangeRequest, ServerLike}; +use crate::server::{NewServer, RangeRequest}; use crate::webcore::{ self as WebCore, AbortSignal, AnyBlob, ByteStream, CookieMap, CookieMapRef, FetchHeaders, Request, Response, blob::SizeType as BlobSizeType, body, readable_stream, request, response, @@ -76,15 +76,11 @@ const REQUEST_CONTEXT_POOL_CAPACITY: usize = if bun_alloc::heap_breakdown::ENABL 2048 }; -pub(crate) type RequestContextStackAllocator< - ThisServer, - const SSL: bool, - const DBG: bool, - const MUX: bool, -> = bun_collections::hive_array::Fallback< - RequestContext, - REQUEST_CONTEXT_POOL_CAPACITY, ->; +pub(crate) type RequestContextStackAllocator = + bun_collections::hive_array::Fallback< + RequestContext, + REQUEST_CONTEXT_POOL_CAPACITY, + >; #[derive(Clone, Copy)] pub(crate) enum UpgradeState { @@ -114,16 +110,11 @@ impl ResponseRoot { /// `align(16)`: `NativePromiseContext`'s deferred-deref task packs a 4-bit /// type tag into the low bits of a pointer to this. #[repr(align(16))] -pub(crate) struct RequestContext< - ThisServer, - const SSL_ENABLED: bool, - const DEBUG_MODE: bool, - const MUX: bool, -> { +pub(crate) struct RequestContext { /// BACKREF to the embedding `Server` — the server owns this request /// context (allocated from its `HiveArray` pool) and outlives it, so the /// pointee is live for the holder's entire lifetime. `None` once detached. - pub(crate) server: Cell>>, + pub(crate) server: Cell>>>, pub(crate) resp: Cell>, pub(crate) req: Cell>>, pub(crate) request_weakref: JsCell, @@ -167,14 +158,10 @@ 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, - /// Total bytes forwarded to the request-body `ReadableStream`. The - /// up-front `maxRequestBodySize` check only sees Content-Length, so - /// chunked / H3 bodies consumed as a stream are capped against this. - pub(crate) request_body_streamed_len: Cell, pub sink: Cell>>>, pub(crate) byte_stream: Cell>>, @@ -207,10 +194,8 @@ pub(crate) struct RequestContext< // TODO: support builtin compression } -impl - RequestContext -where - ThisServer: ServerLike + 'static, +impl + RequestContext { pub(crate) const IS_MUX: bool = MUX; @@ -274,7 +259,6 @@ mod NativePromiseContext { } use crate::node::types::PathLikeExt as _; use crate::server::{AnyRequestContext, FileResponseStream, HTTPStatusText, file_response_stream}; -use crate::webcore::blob::BlobExt as _; use crate::webcore::sink::ReadableSinkController; use crate::webcore::{Blob, ReadableStream, body as Body}; use bun_jsc::SysErrorJsc as _; @@ -286,21 +270,14 @@ use bun_jsc::SysErrorJsc as _; /// pointer as the first statement: the guard's ref keeps the pooled context /// alive for the whole frame even if the body drops the base ref, and the /// actual pool release happens here at drop. -struct RequestContextRef -where - ThisServer: ServerLike + 'static, -{ - ctx: *mut RequestContext, +struct RequestContextRef { + ctx: *mut RequestContext, is_pin: bool, } -impl - RequestContextRef -where - ThisServer: ServerLike + 'static, -{ +impl RequestContextRef { #[inline] - fn pin(this: *mut RequestContext) -> Self { + fn pin(this: *mut RequestContext) -> Self { // SAFETY: `this` is a live context registered as the callback's // user-data; the base ref cannot be released before this returns. unsafe { @@ -314,7 +291,7 @@ where } #[inline] - fn adopt(this: *mut RequestContext) -> Self { + fn adopt(this: *mut RequestContext) -> Self { Self { ctx: this, is_pin: false, @@ -322,17 +299,13 @@ where } #[inline] - fn ctx(&self) -> &RequestContext { + fn ctx(&self) -> &RequestContext { // SAFETY: this guard owns a ref, so `*self.ctx` is live for `&self`. unsafe { &*self.ctx } } } -impl Drop - for RequestContextRef -where - ThisServer: ServerLike + 'static, -{ +impl Drop for RequestContextRef { #[inline] fn drop(&mut self) { // SAFETY: pointer was live when wrapped (this guard owns one ref) and @@ -375,6 +348,28 @@ 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", + )) +} + +/// Fails an unread body that outgrew `maxRequestBodySize`, or a first read from an abort listener would find it empty. +#[cold] +#[inline(never)] +fn fail_unread_oversized_body(body: Option<&mut Body::Value>, global_this: &JSGlobalObject) { + 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. @@ -444,15 +439,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 @@ -478,31 +465,15 @@ macro_rules! ctx_log { ($($t:tt)*) => { bun_core::scoped_log!(RequestContext, $( macro_rules! stream_log { ($($t:tt)*) => { bun_core::scoped_log!(ReadableStream, $($t)*) }; } /// One instantiation's `Bun__HTTPRequestContext*__on*`, which C++'s `promiseHandlerID` knows by address. -/// -/// NOTE (layering): expressed as a trait (not inherent consts) so -/// downstream `where`-clauses that already name it keep type-checking. -pub(crate) trait RequestContextHostFns { - const ON_RESOLVE: bun_jsc::JSHostFn; - const ON_REJECT: bun_jsc::JSHostFn; - const ON_RESOLVE_STREAM: bun_jsc::JSHostFn; - const ON_REJECT_STREAM: bun_jsc::JSHostFn; -} - -impl RequestContextHostFns - for RequestContext -where - ThisServer: ServerLike + 'static, -{ +impl RequestContext { const ON_RESOLVE: bun_jsc::JSHostFn = exported_host_fns(SSL, DBG, MUX).0; const ON_REJECT: bun_jsc::JSHostFn = exported_host_fns(SSL, DBG, MUX).1; const ON_RESOLVE_STREAM: bun_jsc::JSHostFn = exported_host_fns(SSL, DBG, MUX).2; const ON_REJECT_STREAM: bun_jsc::JSHostFn = exported_host_fns(SSL, DBG, MUX).3; } -impl - RequestContext -where - ThisServer: ServerLike + 'static, +impl + RequestContext { /// Reborrow the owning server. `server` is a BACKREF (LIFETIMES.tsv): set /// at construction in `init()` from the `NewServer` that owns the request @@ -512,10 +483,10 @@ where /// /// Returned lifetime is **decoupled** from `&self` (unbounded `'r`): the /// server is not a sub-field of `RequestContext` (it owns the pool the - /// context lives in), so callers may hold `&ThisServer` past calls that + /// context lives in), so callers may hold `&NewServer` past calls that /// end or recycle this context. #[inline] - pub(crate) fn server<'r>(&self) -> &'r ThisServer { + pub(crate) fn server<'r>(&self) -> &'r NewServer { // SAFETY: BACKREF — `server` is `Some(non-null)` after `init()` and // the pointee `NewServer` outlives this context (it owns the pool). // `'r` may exceed `&self` because the server is not borrowed from @@ -638,7 +609,7 @@ where return; } // SAFETY: FFI handle valid while resp is Some - resp.on_aborted(|this, resp| Self::on_abort(this, resp), self.as_ctx_ptr()); + resp.on_aborted(Self::on_abort, self.as_ctx_ptr()); } pub(crate) fn set_cookies(&self, cookie_map: Option<*mut CookieMap>) { @@ -673,20 +644,28 @@ where ctx.ctx().handle_resolve(global, result); } - /// 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. @@ -794,6 +773,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(); } @@ -955,7 +938,7 @@ where } if let Some(server) = self.server.take() { - server.release_request_context(self.as_ctx_ptr().cast::(), MUX); + server.release_request_context(self.as_ctx_ptr()); server.on_request_complete(); } } @@ -1176,8 +1159,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()); @@ -1188,9 +1171,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()); @@ -1245,7 +1228,7 @@ where if let Some(resp) = self.resp.get() { // SAFETY: FFI handle resp.on_writable( - |this, off, resp| Self::on_writable_complete_response_buffer(this, off, resp), + Self::on_writable_complete_response_buffer, self.as_ctx_ptr(), ); } @@ -1326,15 +1309,18 @@ where } pub(crate) fn end_without_body(&self, close_connection: bool) { + self.end_head(|resp| resp.end_without_body(close_connection)); + } + + fn end_head(&self, end: impl FnOnce(uws::AnyResponse)) { ctx_log!("endWithoutBody"); if let Some(resp) = self.resp.get() { 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); + end(resp); // This end can run uncorked (e.g. render_production_error from a // rejection microtask), where no cork or parser gate runs the // close check for Connection: close or a graceful-stop mark. The @@ -1361,29 +1347,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(); @@ -1449,7 +1412,7 @@ where pub(crate) fn create( this: &mut core::mem::MaybeUninit, - server: &ThisServer, + server: &NewServer, req: *mut Req, resp: uws::AnyResponse, should_deinit_context: Option, @@ -1488,7 +1451,6 @@ where request_body: JsCell::new(None), request_body_buf: JsCell::new(Vec::new()), request_body_content_len: Cell::new(0), - request_body_streamed_len: Cell::new(0), sink: Cell::new(None), byte_stream: Cell::new(None), response_body_readable_stream_ref: JsCell::new(readable_stream::Strong::default()), @@ -1529,6 +1491,20 @@ where return; } } + // uWS answers a body that outgrew `maxRequestBodySize` once this returns. + this.abort(if resp.state().is_request_body_too_large() { + jsc::CommonAbortReason::RequestBodyTooLarge + } else { + 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); @@ -1541,6 +1517,11 @@ where 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); + fail_unread_oversized_body(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. @@ -1557,11 +1538,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); @@ -1574,7 +1551,7 @@ where // SAFETY: `sink_ptr` is the live sink allocated by do_render_stream. // `abort` takes the raw pointer because the teardown it can // re-enter frees the sink. - unsafe { ResponseStream::::abort(sink_ptr.as_ptr()) }; + unsafe { ResponseStream::::abort(sink_ptr.as_ptr(), reason) }; // Reject a parked request-body read while this abort still drains microtasks. let _ = this.end_request_streaming(); this.reclaim_promise_cell(); @@ -1760,10 +1737,7 @@ where } else { self.flags.set_has_marked_pending(true); // SAFETY: FFI handle - resp.on_writable( - |this, off, resp| Self::on_writable_bytes(this, off, resp), - self.as_ctx_ptr(), - ); + resp.on_writable(Self::on_writable_bytes, self.as_ctx_ptr()); true } } @@ -1792,7 +1766,7 @@ where self.flags.set_has_marked_pending(true); // SAFETY: FFI handle resp.on_writable( - |this, off, resp| Self::on_writable_complete_response_buffer(this, off, resp), + Self::on_writable_complete_response_buffer, self.as_ctx_ptr(), ); } @@ -1817,6 +1791,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; @@ -1848,10 +1844,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); } } }; @@ -1871,7 +1868,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); } }; @@ -1904,7 +1901,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) }; @@ -1914,13 +1914,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 { @@ -1928,11 +1921,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 @@ -1940,6 +1928,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); @@ -2057,7 +2049,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, @@ -2145,7 +2136,49 @@ where readable_ref.deinit(); } - fn do_render_stream(pair: &StreamPair<'_, ThisServer, SSL_ENABLED, DEBUG_MODE, MUX>) { + /// 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(sink) = this.sink_mut() else { + return false; + }; + if !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(&self) { + self.render_metadata(); + if let Some(resp) = self.resp.get() { + resp.commit_head(); + } + } + + fn do_render_stream(pair: &StreamPair<'_, SSL_ENABLED, DEBUG_MODE, MUX>) { ctx_log!("doRenderStream"); let this: &Self = pair.this; let stream = &pair.stream; @@ -2184,10 +2217,7 @@ where this.render_metadata(); } - resp.on_writable( - |this, off, resp| Self::on_writable_response_stream(this, off, resp), - this.as_ctx_ptr(), - ); + resp.on_writable(Self::on_writable_response_stream, this.as_ctx_ptr()); // We are already corked! let assignment_result: JSValue = ReadableSinkController::assign_to_stream( @@ -2283,6 +2313,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.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 { @@ -2443,6 +2483,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()); @@ -2454,10 +2502,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; } } @@ -2476,8 +2521,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), )); @@ -2521,9 +2565,7 @@ where || self.server().terminated() } - fn do_render_head_response( - pair: &HeaderResponsePair<'_, ThisServer, SSL_ENABLED, DEBUG_MODE, MUX>, - ) { + fn do_render_head_response(pair: &HeaderResponsePair<'_, SSL_ENABLED, DEBUG_MODE, MUX>) { let this = pair.this; let response_ptr = pair.response; if this.resp.get().is_none() { @@ -2561,38 +2603,26 @@ where // GET strips the handler's Content-Length / Transfer-Encoding and frames // from the body, so HEAD must too (RFC 9110 §9.3.2). Only a bodiless // Response leaves those headers as what GET would have sent (#15355). - let body_decides_framing = { - let body_value = response.get_body_value(); - body_value.to_blob_if_possible(); - !matches!( - body_value, - Body::Value::Used | Body::Value::Null | Body::Value::Empty | Body::Value::Error(_) - ) - }; - // `fast_get`/`fast_has` take `&mut self` (FFI shim), so use the `_mut` - // accessor — `get_fetch_headers()` and `get_init_headers()` alias the - // same `init.headers` field. + let body_decides_framing = !matches!( + response.get_body_value(), + Body::Value::Used | Body::Value::Null | Body::Value::Empty | Body::Value::Error(_) + ); + // `fast_get`/`fast_has` take `&mut self` (FFI shim), so use the `_mut` accessor. if !body_decides_framing { if let Some(headers) = response.get_init_headers_mut() { // first respect the headers - if !MUX { - if let Some(transfer_encoding) = - headers.fast_get(jsc::HTTPHeaderName::TransferEncoding) - { - // fastGet() borrows the header map's StringImpl; renderMetadata() -> - // doWriteHeaders() calls fastRemove(.TransferEncoding) and derefs the - // FetchHeaders, freeing that StringImpl before we write it. Clone so - // the bytes outlive renderMetadata(). - let transfer_encoding_str = transfer_encoding.to_utf8().into_owned(); - this.render_metadata(); - resp.write_header(b"transfer-encoding", transfer_encoding_str.slice()); - this.end_without_body(this.should_close_connection()); - return; - } + if !MUX + && headers + .fast_get(jsc::HTTPHeaderName::TransferEncoding) + .is_some() + { + this.render_metadata(); + let close_connection = this.should_close_connection(); + this.end_head(|resp| resp.end_head_of_stream(close_connection)); + return; } if let Some(content_length) = headers.fast_get(jsc::HTTPHeaderName::ContentLength) { - // Parse before renderMetadata(): doWriteHeaders() will fastRemove(.ContentLength) - // and deref the FetchHeaders, freeing the borrowed StringImpl. + // Parse before renderMetadata(), which derefs the FetchHeaders and frees the borrowed StringImpl. let content_length_str = content_length.to_utf8(); let len: usize = HTTP::parse_content_length(content_length_str.slice()); drop(content_length_str); @@ -2609,52 +2639,33 @@ 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); + if this.range != RangeRequest::Raw::None && !this.honor_embedded_file_range() { + return; } + this.render_metadata(); + // 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(_) => { this.render_metadata(); - if !MUX { - // SAFETY: FFI handle - resp.write_header(b"transfer-encoding", b"chunked"); - } // HEAD never transmits the body. if let Some(response) = this.response_mut() { Self::cancel_unread_body(response, global_this); } - this.end_without_body(this.should_close_connection()); + let close_connection = this.should_close_connection(); + this.end_head(|resp| resp.end_head_of_stream(close_connection)); } Body::Value::Used | Body::Value::Null | Body::Value::Empty | Body::Value::Error(_) => { this.render_metadata(); @@ -2667,7 +2678,11 @@ where /// Drops the callback's result as for any aborted request; `set_abort_handler` delivers the missed close. #[cold] - fn on_connection_closed_during_dispatch(&self, this: &ThisServer, result: JSValue) { + fn on_connection_closed_during_dispatch( + &self, + this: &NewServer, + result: JSValue, + ) { ctx_log!("connection closed during dispatch"); self.discard_handler_result(this.global_this(), result); self.set_abort_handler(); @@ -2691,7 +2706,7 @@ where // drain in the middle of the handler. pub(crate) fn on_response( &self, - this: &ThisServer, + this: &NewServer, request_value: JSValue, response_value: JSValue, ) { @@ -3074,9 +3089,8 @@ where return; } - // If a ReadableStream can trivially be converted to a Blob, do so. - // If it's a WTFStringImpl and it cannot be used as a UTF-8 string, convert it to a Blob. - value.to_blob_if_possible(); + // A stream whose source finished in those microtasks is sent as bytes, with a Content-Length. + value.lift_unread_stream(); let global_this = this.server().global_this(); match value { Body::Value::Error(err_ref) => { @@ -3138,13 +3152,31 @@ 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; } - match stream.ptr { + // A source that something has started feeds the stream's queue, so the stream is read like one script made. + let source = match stream.ptr { + readable_stream::Source::Bytes(_) + if !stream.try_lock_native(global_this) => + { + // The source failed before a byte of the body was sent, so error() can still answer. + let failure = stream.ptr.bytes().and_then(|b| b.take_pending_error()); + if let Some(err) = failure { + this.run_error_handler(err.to_js(global_this)); + return; + } + readable_stream::Source::JavaScript + } + source => source, + }; + match source { readable_stream::Source::Invalid => { this.response_body_readable_stream_ref .with_mut(|s| s.deinit()); @@ -3186,7 +3218,7 @@ where && !byte_stream.has_pending_error() { let mut byte_list = byte_stream.drain(); - this.blob.set(AnyBlob::from_array_list( + this.blob.set(AnyBlob::from_owned_slice( byte_list.move_to_list_managed(), )); this.response_body_readable_stream_ref @@ -3202,7 +3234,6 @@ where byte_stream.sink.set(WebCore::SinkHandle::ServerResponse( AnyRequestContext::init(this.as_ctx_ptr()), )); - stream.lock_native(global_this); byte_stream.signal_consumer_attached(); // Deinit the old Strong reference before creating a new one // to avoid leaking the Strong.Impl memory @@ -3231,14 +3262,15 @@ 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 - resp.run_corked_with_type(Self::render_metadata, this); + // No body bytes yet: send the whole head now. + resp.run_corked_with_type(Self::render_committed_head_corked, this); } // Runs `end_chunk`, which adopts the ref taken above and may free `this`. if byte_stream.end_sink_with_pending_error() { @@ -3276,8 +3308,7 @@ where // `do_render_with_body_locked`. this.ref_(); this.flags.set_has_marked_pending(true); - lock.on_receive_value = - Some(|ctx, value| Self::do_render_with_body_locked(ctx, value)); + lock.on_receive_value = Some(Self::do_render_with_body_locked); lock.task = Some(NonNull::new(this.as_ctx_ptr().cast::()).unwrap()); return; @@ -3298,7 +3329,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() { @@ -3316,10 +3347,7 @@ where uws::WriteResult::Backpressure(n) => { this.flags.set_has_marked_pending(true); // SAFETY: FFI handle - resp.on_writable( - |this, off, resp| Self::on_writable_byte_stream(this, off, resp), - this.as_ctx_ptr(), - ); + resp.on_writable(Self::on_writable_byte_stream, this.as_ctx_ptr()); WebCore::streams::Writable::Backpressure(n as BlobSizeType) } } @@ -3402,7 +3430,7 @@ where graph.compiled_program.is_some() && graph.contains_file(&bytes.stored_name) }) || blob.offset.get() != 0 || blob.size.get() != total - || self.method != Method::GET + || !matches!(self.method, Method::GET | Method::HEAD) { return true; } @@ -3503,7 +3531,7 @@ where let (status, app_content_length) = { let response: &mut Response = this.response_mut().unwrap(); let status = response.status_code(); - // Parsed before render_metadata() fast_remove()s it and derefs the headers. + // Parsed before render_metadata() derefs the headers. let app_cl = (status == 304) .then(|| { let s = response @@ -3531,8 +3559,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; } @@ -3562,12 +3588,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!"; @@ -3613,7 +3638,7 @@ where true } - fn ensure_pathname(&self) -> PathnameFormatter<'_, ThisServer, SSL_ENABLED, DEBUG_MODE, MUX> { + fn ensure_pathname(&self) -> PathnameFormatter<'_, SSL_ENABLED, DEBUG_MODE, MUX> { PathnameFormatter { ctx: self } } @@ -3633,9 +3658,15 @@ where }; let server = &*server; let global_this = server.global_this(); - // `ServerLike::vm()` is the process-static VM `BackRef`; `as_mut()` is + // `NewServer::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; @@ -3654,9 +3685,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(); } @@ -3692,7 +3721,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); @@ -3767,7 +3796,7 @@ where return; } jsc::PromiseResult::Rejected(err) => { - ctx.finish_running_error_handler(err, status); + ctx.finish_running_error_handler(err, 500); return; } } @@ -3802,23 +3831,10 @@ 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) = + let (content_type, needs_content_type) = get_content_type(response.get_init_headers_mut(), blob); - // NOTE: `MimeType` owns a `Cow<'static, [u8]>`; Drop handles the owned case. - // Hold the value past all reads below, then let it drop at scope end. - let _ct_guard = scopeguard::guard(content_type_needs_free, |_needs| { - // Drop of `content_type` (moved into closure capture below would - // change borrow lifetimes); rely on natural end-of-scope drop. - }); let mut has_content_disposition = false; let mut has_content_range = false; if let Some(mut headers_) = response.swap_init_headers() { @@ -3866,8 +3882,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); @@ -3891,10 +3908,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 { @@ -3910,8 +3929,7 @@ where } if self.flags.needs_content_length() { - resp.write_header_int(b"content-length", size as u64); - resp.mark_wrote_content_length_header(); + resp.write_header_int(b"content-length", sendfile.remain as u64); self.flags.set_needs_content_length(false); } @@ -3944,21 +3962,11 @@ where // `AnyResponse` is a `Copy` handle; methods take `self` by value. let Some(resp) = self.resp.get() else { return }; - if let Some(text) = HTTPStatusText::get(status) { - resp.write_status(text); - } else { - let mut buf = [0u8; 48]; - let mut w = &mut buf[..]; - let _ = write!(w, "{} HM", status); - let written = 48 - w.len(); - resp.write_status(&buf[..written]); - } + crate::server::write_status(resp, status); } fn do_write_headers(&self, headers: &mut FetchHeaders) { ctx_log!("writeHeaders"); - headers.fast_remove(jsc::HTTPHeaderName::ContentLength); - headers.fast_remove(jsc::HTTPHeaderName::TransferEncoding); if let Some(resp) = self.resp.get() { headers.to_uws_response(uws::ResponseKind::of(resp), resp.as_ptr()); } @@ -3971,10 +3979,7 @@ where if !resp.try_end(bytes, bytes.len(), self.should_close_connection()) { self.flags.set_has_marked_pending(true); // SAFETY: FFI handle - resp.on_writable( - |this, off, resp| Self::on_writable_bytes(this, off, resp), - self.as_ctx_ptr(), - ); + resp.on_writable(Self::on_writable_bytes, self.as_ctx_ptr()); return; } } @@ -4002,6 +4007,9 @@ where // SAFETY: caller contract — `response` is live and root-provenanced. self.response_weakref .set(unsafe { response::WeakRef::init_ref(response) }); + let global_this = self.server().global_this(); + // SAFETY: caller contract — `response` is live. + crate::dispatch::fold(unsafe { (*response).prepare_headers_for_send(global_this) }); } /// # Safety @@ -4044,10 +4052,11 @@ where /// # Safety /// Same contract as [`Self::render`]. unsafe fn protect_for_body_and_render(&self, response_value: JSValue, response: *mut Response) { + // SAFETY: caller contract. + unsafe { self.set_response(response) }; // SAFETY: caller contract: `response` is live. This is the only borrow // of its body, and it ends before `render` reborrows the cell. let body_value = unsafe { (*response).get_body_value() }; - body_value.to_blob_if_possible(); let sent_after_return = match body_value { Body::Value::Blob(blob) => shim::blob_needs_to_read_file(blob), Body::Value::Locked(_) => true, @@ -4087,62 +4096,6 @@ where if let Some(readable) = readable { debug_assert!(this.request_body_buf.get().is_empty()); - // Cap streamed bytes against maxRequestBodySize too — the up-front - // check only sees Content-Length (see the buffering branch below). - let streamed_len = this - .request_body_streamed_len - .get() - .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(); - return; - } - let _exit = vm.enter_event_loop_scope(); // `RawSlice` is non-owning; ownership of `chunk` stays with the @@ -4196,64 +4149,22 @@ where // This is the start of a task, so it's a good time to drain // The pooled body slot is a separate allocation decoupled from `*this`. if let Some(body) = this.request_body_mut() { - // The up-front maxRequestBodySize check in the server only - // sees Content-Length. HTTP/3 (and H1 chunked) bodies may - // omit it, so cap accumulated bytes here too — otherwise a - // single CL-less stream can grow request_body_buf without - // bound. - if this - .request_body_buf - .get() - .len() - .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(); - return; - } - if last { let mut bytes = this.request_body_buf.replace(Vec::new()); - let mut old = core::mem::replace(body, Body::Value::Null); - let total = bytes.len() + chunk.len(); // Vec aborts on OOM (repo-wide abort-on-OOM policy). bytes.reserve_exact(total.saturating_sub(bytes.len())); bytes.extend_from_slice(chunk); debug_assert_eq!(bytes.len(), total); - *body = Body::Value::InternalBlob(WebCore::InternalBlob { - bytes, - was_string: false, - }); - if matches!(old, Body::Value::Locked(_)) { + { let _exit = vm.enter_event_loop_scope(); - let context = this.script_context(); - let _ = - Body::Value::resolve(&mut old, body, &global_this.js_thread(context), None); // TODO: properly propagate exception upwards + let _ = body.fulfill(bytes, &global_this.js_thread(context)); // 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; } @@ -4273,9 +4184,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(); } } @@ -4359,14 +4273,6 @@ where // This means we have received part of the body but not the whole thing let emptied = self.request_body_buf.replace(Vec::new()); if !emptied.is_empty() { - // Count the drained pre-stream bytes against maxRequestBodySize so - // the streaming-path limit check sees the full body length, not - // just the chunks that arrive after the stream becomes active. - self.request_body_streamed_len.set( - self.request_body_streamed_len - .get() - .saturating_add(emptied.len()), - ); let cap = emptied.capacity(); return WebCore::DrainResult::Owned { list: emptied, @@ -4413,7 +4319,6 @@ where &mut old, &mut new_body, &global_this.js_thread(server.context()), - None, ); // TODO: properly propagate exception upwards *body = new_body; } @@ -4477,6 +4382,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; /// `RequestContext.bind.ts` has one set of reactions for each instantiation. const fn exported_host_fns( @@ -4542,27 +4449,25 @@ const fn exported_host_fns( } } -struct StreamPair<'a, ThisServer, const SSL: bool, const DBG: bool, const MUX: bool> { - pub this: &'a RequestContext, +struct StreamPair<'a, const SSL: bool, const DBG: bool, const MUX: bool> { + pub this: &'a RequestContext, pub stream: WebCore::ReadableStream, } -struct HeaderResponsePair<'a, ThisServer, const SSL: bool, const DBG: bool, const MUX: bool> { - pub this: &'a RequestContext, +struct HeaderResponsePair<'a, const SSL: bool, const DBG: bool, const MUX: bool> { + pub this: &'a RequestContext, /// The JS wrapper's cell pointer, not a `&mut Response`: the receiving /// frame hands it to `set_response`, which stores it in a `WeakPtr` that /// outlives any reborrow. The cell is GC-rooted by the constructing frame. pub(crate) response: *mut Response, } -struct PathnameFormatter<'a, ThisServer, const SSL: bool, const DBG: bool, const MUX: bool> { - ctx: &'a RequestContext, +struct PathnameFormatter<'a, const SSL: bool, const DBG: bool, const MUX: bool> { + ctx: &'a RequestContext, } -impl<'a, ThisServer, const SSL: bool, const DBG: bool, const MUX: bool> core::fmt::Display - for PathnameFormatter<'a, ThisServer, SSL, DBG, MUX> -where - ThisServer: ServerLike + 'static, +impl<'a, const SSL: bool, const DBG: bool, const MUX: bool> core::fmt::Display + for PathnameFormatter<'a, SSL, DBG, MUX> { fn fmt(&self, writer: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { let this = self.ctx; @@ -4632,6 +4537,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; } } @@ -4713,6 +4620,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 { @@ -4740,9 +4652,13 @@ impl Flags { } } -fn get_content_type(headers: Option<&mut FetchHeaders>, blob: &AnyBlob) -> (MimeType, bool, bool) { +/// 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) { let mut needs_content_type = true; - let mut content_type_needs_free = false; let content_type: MimeType = 'brk: { if let Some(headers_) = headers { @@ -4752,11 +4668,7 @@ fn get_content_type(headers: Option<&mut FetchHeaders>, blob: &AnyBlob) -> (Mime let content_slice = content.to_utf8(); // Dupe only when the latin1/utf16 slice was heap-converted. let dupe = content_slice.is_owned(); - let mt = MimeType::init( - content_slice.slice(), - dupe, - Some(&mut content_type_needs_free), - ); + let mt = MimeType::init(content_slice.slice(), dupe); drop(content_slice); break 'brk mt; } @@ -4775,10 +4687,7 @@ fn get_content_type(headers: Option<&mut FetchHeaders>, blob: &AnyBlob) -> (Mime } }; - (content_type, needs_content_type, content_type_needs_free) + (content_type, needs_content_type) } -// `ServerLike` lives in `crate::server` (mod.rs) and is impl'd for the four -// `NewServer` monomorphizations. - static WELCOME_PAGE_HTML_GZ: &[u8] = include_bytes!("../api/welcome-page.html.gz"); diff --git a/src/runtime/server/ServerConfig.rs b/src/runtime/server/ServerConfig.rs index ea40a3f1cbbe..a25516f67e79 100644 --- a/src/runtime/server/ServerConfig.rs +++ b/src/runtime/server/ServerConfig.rs @@ -16,13 +16,33 @@ pub(crate) use http_method::{Method, Optional as MethodOptional}; use super::server_body::ServerInitContext; use super::web_socket_server_context::WebSocketServerContext; use super::{AnyRoute, AnyServer}; +use crate::generated_bindgen::server::{NodeHTTPServeOptions, ServeSwitches}; use crate::server::jsc::{JSGlobalObject, JSPropertyIterator, JSValue, JsResult, Strong}; use bun_core::fmt as bun_fmt; +use crate::api::bun_secure_context::SecureContext; +use crate::socket::ssl_config::ParsedTls; pub(crate) use crate::socket::ssl_config::SSLConfig; -use crate::socket::ssl_config::SSLConfigFromJs; use bun_collections::index_sort; +/// Where a server's `SSL_CTX`s come from. +pub(crate) enum ServerTls { + Off, + /// The `tls` option. `server_names` are the entries of a `tls` array after the first, each with a `serverName`. + Options { + default: Box, + server_names: Vec, + }, + /// The context a node:http server built in its constructor. + Context(bun_boringssl_sys::OwnedSslCtx), +} + +impl ServerTls { + pub(crate) fn is_on(&self) -> bool { + !matches!(self, Self::Off) + } +} + pub(crate) struct ServerConfig { pub(crate) address: Address, pub(crate) idle_timeout: u8, // TODO: should we match websocket default idleTimeout of 120? @@ -33,10 +53,7 @@ pub(crate) struct ServerConfig { // `&self` instead of erased to `'static`. pub(crate) base_uri: Box<[u8]>, - pub(crate) ssl_config: Option, - // Vec drop runs each element's `Drop`, and `SSLConfig: Drop` calls - // `deinit()` (frees the owned C strings). - pub(crate) sni: Option>, + pub(crate) tls: ServerTls, pub(crate) max_request_body_size: usize, pub(crate) development: DevelopmentOption, pub(crate) broadcast_console_log_from_browser_to_server_for_bake: bool, @@ -60,6 +77,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, @@ -83,8 +102,7 @@ impl Default for ServerConfig { idle_timeout: 10, has_idle_timeout: false, base_uri: Box::default(), - ssl_config: None, - sni: None, + tls: ServerTls::Off, max_request_body_size: 1024 * 1024 * 128, development: DevelopmentOption::Development, broadcast_console_log_from_browser_to_server_for_bake: false, @@ -95,6 +113,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, @@ -148,7 +167,7 @@ pub(crate) enum DevelopmentOption { } impl DevelopmentOption { - fn is_hmr_enabled(self) -> bool { + pub(crate) fn is_hmr_enabled(self) -> bool { self == DevelopmentOption::Development } @@ -176,6 +195,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. @@ -223,7 +260,8 @@ impl StaticRouteEntry { } impl ServerConfig { - fn normalize_static_routes_list(&mut self) -> Result<(), crate::Error> { + /// Keeps the last route of each method and path, in a deterministic order. + pub(crate) fn normalize_static_routes_list(&mut self) { fn hash(route: &StaticRouteEntry) -> u64 { let mut hasher = Wyhash::init(0); match &route.method { @@ -265,54 +303,6 @@ impl ServerConfig { // sort the cloned static routes by name for determinism // (descending by path: `order(b, a)`). index_sort::sort_slice_by(list, |a, b| strings::order(&b.path, &a.path)); - - Ok(()) - } - - pub(crate) fn clone_for_reloading_static_routes( - &mut self, - ) -> Result { - // The sole caller is - // `*config = config.clone_for_reloading_static_routes()?;`. - // Move every owning field into `that` and leave the Copy scalars in - // place on `self` (idle_timeout, development, reuse_port, http1/http3, - // etc. retained; resources gone), ensuring the assignment-drop of the - // residual `self` is a no-op. - let mut that = ServerConfig { - address: core::mem::take(&mut self.address), - idle_timeout: self.idle_timeout, - has_idle_timeout: self.has_idle_timeout, - base_uri: core::mem::take(&mut self.base_uri), - ssl_config: self.ssl_config.take(), - sni: self.sni.take(), - max_request_body_size: self.max_request_body_size, - development: self.development, - broadcast_console_log_from_browser_to_server_for_bake: self - .broadcast_console_log_from_browser_to_server_for_bake, - enable_chrome_devtools_automatic_workspace_folders: self - .enable_chrome_devtools_automatic_workspace_folders, - on_error: self.on_error, - on_request: self.on_request, - on_node_http_request: self.on_node_http_request, - is_node_http_server: self.is_node_http_server, - websocket: self.websocket.take(), - reuse_port: self.reuse_port, - id: core::mem::take(&mut self.id), - allow_hot: self.allow_hot, - ipv6_only: self.ipv6_only, - http3: self.http3, - http2: self.http2, - http1: self.http1, - had_routes_object: self.had_routes_object, - static_routes: core::mem::take(&mut self.static_routes), - negative_routes: core::mem::take(&mut self.negative_routes), - user_routes_to_build: core::mem::take(&mut self.user_routes_to_build), - bake: self.bake.take(), - }; - - that.normalize_static_routes_list()?; - - Ok(that) } pub(crate) fn append_static_route( @@ -320,19 +310,18 @@ impl ServerConfig { path: &[u8], route: AnyRoute, method: MethodOptional, - ) -> Result<(), crate::Error> { + ) { self.static_routes.push(StaticRouteEntry { path: Box::<[u8]>::from(path), route, method, }); - Ok(()) } } -pub(crate) fn apply_static_route( +pub(crate) fn apply_static_route( server: AnyServer, - app: &mut uws::NewApp, + app: &mut A, entry: ThisPtr, path: &[u8], method: http_method::Optional, @@ -369,9 +358,8 @@ fn serves_head(method: &http_method::Optional) -> bool { } } -/// The route-registration surface shared by `uws::h2::App` and -/// `uws::h3::App`; both hand the handler type-erased `AnyRequest`/`AnyResponse`. -pub(crate) trait MuxApp { +/// The route registration the HTTP/1, HTTP/2 and HTTP/3 apps share. Each hands the handler an `AnyRequest` and an `AnyResponse`. +pub(crate) trait StaticRouteApp { fn method_this(&mut self, m: Method, p: &[u8], h: H, this: ThisPtr) where H: Fn(ThisPtr, uws::AnyRequest, uws::AnyResponse) + Copy + 'static; @@ -380,9 +368,9 @@ pub(crate) trait MuxApp { H: Fn(ThisPtr, uws::AnyRequest, uws::AnyResponse) + Copy + 'static; } -macro_rules! impl_mux_app { - ($app:ty) => { - impl MuxApp for $app { +macro_rules! impl_static_route_app { + ([$($generics:tt)*] $app:ty) => { + impl<$($generics)*> StaticRouteApp for $app { #[inline] fn method_this(&mut self, m: Method, p: &[u8], h: H, this: ThisPtr) where @@ -400,34 +388,11 @@ macro_rules! impl_mux_app { } }; } -impl_mux_app!(uws::h2::App); -impl_mux_app!(uws::h3::App); +impl_static_route_app!([const SSL: bool] uws::NewApp); +impl_static_route_app!([] uws::h2::App); +impl_static_route_app!([] uws::h3::App); -pub(crate) fn apply_static_route_mux( - server: AnyServer, - app: &mut A, - entry: ThisPtr, - path: &[u8], - method: http_method::Optional, - path_has_user_head_route: bool, -) { - entry.set_server(server); - - if !path_has_user_head_route && serves_head(&method) { - app.method_this(Method::HEAD, path, T::on_head_request, entry); - } - match method { - http_method::Optional::Any => app.any_this(path, T::on_request, entry), - http_method::Optional::Method(m) => { - let mut iter = m.iter(); - while let Some(method_) = iter.next() { - app.method_this(method_, path, T::on_request, entry); - } - } - } -} - -/// Per-route trait that `apply_static_route{,_mux}` monomorphizes over +/// Per-route trait that `apply_static_route` monomorphizes over /// (`StaticRoute`/`FileRoute`/`DirectoryRoute`/`HTMLBundle.Route`). The route /// is the uWS route userdata; the route table holds a ref on it for as long as /// it is registered. @@ -490,6 +455,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 { @@ -535,14 +504,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\ @@ -557,12 +524,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,7 +557,106 @@ fn get_routes_object(global: &JSGlobalObject, arg: JSValue) -> JsResult Box<[u8]> { + let (protocol, default_port) = if tls { ("https", 443) } else { ("http", 80) }; + let (open, close) = if bun_core::ip_address::is_ipv6_address(hostname) && hostname[0] != b'[' { + ("[", "]") + } else { + ("", "") + }; + let mut buf: Vec = Vec::new(); + let _ = write!( + &mut buf, + "{protocol}://{open}{}{close}", + bstr::BStr::new(hostname) + ); + if port != default_port { + let _ = write!(&mut buf, ":{port}"); + } + let _ = write!(&mut buf, "/{}", bstr::BStr::new(pathname)); + buf.into_boxed_slice() +} + impl ServerConfig { + /// Returns the DevServer options for HTML and framework router routes. + fn dev_server_options( + vm: &bun_jsc::virtual_machine::VirtualMachine, + framework: crate::bake::Framework, + bundler_options: crate::bake::SplitBundlerOptions, + ) -> 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 { + let has_html_route = new_config + .static_routes + .iter() + .any(|entry| matches!(entry.route, AnyRoute::Html(_))); + if !has_html_route { + return false; + } + self.bake = new_config.bake.take(); + if self.bake.is_none() { + 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(), + )); + } + true + } + + pub(crate) fn drop_dev_server_options(&mut self) { + self.bake = None; + } + pub(crate) fn from_js( global: &JSGlobalObject, arg: JSValue, @@ -611,7 +691,7 @@ impl ServerConfig { args.development = DevelopmentOption::Production; } - if vm.transpiler.options.production { + if vm.transpiler.options.production || process_env_is_production(global)? { args.development = DevelopmentOption::Production; } @@ -895,8 +975,6 @@ impl ServerConfig { && (!is_removed(Feature::Bundler) || !args.development.is_hmr_enabled()) { 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(); @@ -923,42 +1001,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!( @@ -996,12 +1039,10 @@ impl ServerConfig { }; if let Some(websocket_object) = websocket_object { if !websocket_object.is_object() { - // ssl_config drops with args return Err(global .throw_invalid_arguments(format_args!("Expected websocket to be an object"))); } - // `ssl_config` drops with args on error. args.websocket = Some(super::web_socket_server_context::on_create( global, websocket_object, @@ -1120,25 +1161,12 @@ impl ServerConfig { } } - if let Some(dev) = arg.get(global, "reusePort")? { - args.reuse_port = dev.to_boolean(); - } - - if let Some(dev) = arg.get(global, "ipv6Only")? { - args.ipv6_only = dev.to_boolean(); - } - - if let Some(v) = arg.get(global, "http3")? { - args.http3 = v.to_boolean(); - } - - if let Some(v) = arg.get(global, "http2")? { - args.http2 = v.to_boolean(); - } - - if let Some(v) = arg.get(global, "http1")? { - args.http1 = v.to_boolean(); - } + let switches = ServeSwitches::from_js(global, arg)?; + args.reuse_port = switches.reuse_port.unwrap_or(args.reuse_port); + args.ipv6_only = switches.ipv6_only; + args.http3 = switches.http3; + args.http2 = switches.http2; + args.http1 = switches.http1; if let Some(max_request_body_size) = arg.get_truthy(global, "maxRequestBodySize")? { if max_request_body_size.is_number() { @@ -1154,7 +1182,7 @@ impl ServerConfig { ); } // Raw value — async-context wrapping is deferred to the slot-write - // site (`serve_with!` / `on_reload_from_zig`) so the wrapped fn is + // site (`serve_with!` / `apply_reload`) so the wrapped fn is // rooted by the wrapper's WriteBarrier slot the moment it exists. args.on_error = on_error; } @@ -1167,6 +1195,8 @@ impl ServerConfig { } args.on_node_http_request = on_request_; args.is_node_http_server = true; + + args.listen_fd = NodeHTTPServeOptions::from_js(global, arg)?.fd; } if let Some(on_request_) = arg.get_truthy(global, "fetch")? { @@ -1180,7 +1210,6 @@ impl ServerConfig { && (args.static_routes.len() + args.user_routes_to_build.len()) == 0 && !opts.previous_fetch && !(opts.previous_routes && !args.had_routes_object) - && opts.is_fetch_required { return Err(global.throw_invalid_arguments(format_args!( "Bun.serve() needs either:\n\n\ @@ -1198,53 +1227,53 @@ impl ServerConfig { ))); } - if let Some(tls) = arg.get_truthy(global, "tls")? { - if tls.is_falsey() { - args.ssl_config = None; - } else if tls.js_type().is_array() { + let from_options = |default: SSLConfig| ServerTls::Options { + default: Box::new(default), + server_names: Vec::new(), + }; + if let Some(tls) = arg + .get_truthy(global, "tls")? + .filter(|tls| !tls.is_falsey()) + { + if tls.js_type().is_array() { let mut value_iter = tls.array_iterator(global)?; - if value_iter.len == 0 { - // Empty TLS array means no TLS - this is valid - } else { - while let Some(item) = value_iter.next()? { - let Some(ssl_config) = SSLConfig::from_js(vm, global, item, true)? else { - // Backwards-compatibility; we ignored empty tls objects. - continue; - }; - - if args.ssl_config.is_none() { - args.ssl_config = Some(ssl_config); - } else { - if ssl_config.server_name_bytes().unwrap_or(b"").is_empty() { - drop(ssl_config); - return Err(global.throw_invalid_arguments(format_args!( - "SNI tls object must have a serverName", - ))); - } - if args.sni.is_none() { - args.sni = Some(Vec::with_capacity((value_iter.len - 1) as usize)); - } - - args.sni.as_mut().unwrap().push(ssl_config); - } + while let Some(item) = value_iter.next()? { + let Some(ssl_config) = ParsedTls::from_js(vm, global, item, true)?.or_none() + else { + // Backwards-compatibility; we ignored empty tls objects. + continue; + }; + let ServerTls::Options { server_names, .. } = &mut args.tls else { + args.tls = from_options(ssl_config); + continue; + }; + if ssl_config.server_name_bytes().unwrap_or(b"").is_empty() { + return Err(global.throw_invalid_arguments(format_args!( + "SNI tls object must have a serverName", + ))); } + server_names.push(ssl_config); } - } else { - if let Some(ssl_config) = SSLConfig::from_js(vm, global, tls, true)? { - args.ssl_config = Some(ssl_config); - } + } else if args.is_node_http_server + && let Some(ctx) = SecureContext::ctx_from_options(global, tls)? + { + // Only node:http passes a `secureContext`, so no other server reads the key. + args.tls = ServerTls::Context(ctx); + } else if let Some(ssl_config) = ParsedTls::from_js(vm, global, tls, true)?.or_none() { + args.tls = from_options(ssl_config); } } // @compatibility Bun v0.x - v0.2.1 // this used to be top-level, now it's "tls" object - if args.ssl_config.is_none() { - if let Some(ssl_config) = SSLConfig::from_js(vm, global, arg, true)? { - args.ssl_config = Some(ssl_config); + if !args.tls.is_on() { + if let Some(ssl_config) = ParsedTls::from_js(vm, global, arg, true)?.or_none() { + args.tls = from_options(ssl_config); } } - if args.http3 && args.ssl_config.is_none() { + // QUIC builds its own contexts from the options. + if args.http3 && !matches!(args.tls, ServerTls::Options { .. }) { return Err( global.throw_invalid_arguments(format_args!("HTTP/3 requires 'tls' to be set")) ); @@ -1283,63 +1312,9 @@ impl ServerConfig { } if base_url.protocol.is_empty() { - let protocol: &[u8] = if args.ssl_config.is_some() { - b"https" - } else { - b"http" - }; - let hostname = base_url.hostname; - let needs_brackets: bool = - bun_core::ip_address::is_ipv6_address(hostname) && hostname[0] != b'['; let pathname = strings::trim_leading_char(base_url.pathname, b'/'); - let mut buf: Vec = Vec::new(); - if needs_brackets { - if (port == 80 && args.ssl_config.is_none()) - || (port == 443 && args.ssl_config.is_some()) - { - let _ = write!( - &mut buf, - "{}://[{}]/{}", - bstr::BStr::new(protocol), - bstr::BStr::new(hostname), - bstr::BStr::new(pathname) - ); - } else { - let _ = write!( - &mut buf, - "{}://[{}]:{}/{}", - bstr::BStr::new(protocol), - bstr::BStr::new(hostname), - port, - bstr::BStr::new(pathname) - ); - } - } else { - if (port == 80 && args.ssl_config.is_none()) - || (port == 443 && args.ssl_config.is_some()) - { - let _ = write!( - &mut buf, - "{}://{}/{}", - bstr::BStr::new(protocol), - bstr::BStr::new(hostname), - bstr::BStr::new(pathname) - ); - } else { - let _ = write!( - &mut buf, - "{}://{}:{}/{}", - bstr::BStr::new(protocol), - bstr::BStr::new(hostname), - port, - bstr::BStr::new(pathname) - ); - } - } - // `base_url` (and so `hostname`/`pathname`) borrow into the - // original allocation; drop the borrow before reassigning. - drop(base_url); - args.base_uri = buf.into_boxed_slice(); + args.base_uri = + format_base_uri(args.tls.is_on(), base_url.hostname, port, pathname); } } else { let hostname: &[u8] = match &args.address { @@ -1349,56 +1324,7 @@ impl ServerConfig { } => hostname.as_bytes(), Address::Tcp { hostname: None, .. } | Address::Unix(_) => b"0.0.0.0", }; - - let needs_brackets: bool = - bun_core::ip_address::is_ipv6_address(hostname) && hostname[0] != b'['; - - let protocol: &[u8] = if args.ssl_config.is_some() { - b"https" - } else { - b"http" - }; - let mut buf: Vec = Vec::new(); - if needs_brackets { - if (port == 80 && args.ssl_config.is_none()) - || (port == 443 && args.ssl_config.is_some()) - { - let _ = write!( - &mut buf, - "{}://[{}]/", - bstr::BStr::new(protocol), - bstr::BStr::new(hostname) - ); - } else { - let _ = write!( - &mut buf, - "{}://[{}]:{}/", - bstr::BStr::new(protocol), - bstr::BStr::new(hostname), - port - ); - } - } else { - if (port == 80 && args.ssl_config.is_none()) - || (port == 443 && args.ssl_config.is_some()) - { - let _ = write!( - &mut buf, - "{}://{}/", - bstr::BStr::new(protocol), - bstr::BStr::new(hostname) - ); - } else { - let _ = write!( - &mut buf, - "{}://{}:{}/", - bstr::BStr::new(protocol), - bstr::BStr::new(hostname), - port - ); - } - } - args.base_uri = buf.into_boxed_slice(); + args.base_uri = format_base_uri(args.tls.is_on(), hostname, port, b""); if !strings::is_all_ascii(hostname) { args.base_uri = Box::default(); @@ -1438,9 +1364,8 @@ impl ServerConfig { pub(crate) struct FromJSOptions { pub(crate) allow_bake_config: bool, pub(crate) may_start_development_server: bool, - pub(crate) is_fetch_required: bool, /// What the running server keeps answering with when a `reload()` config - /// names no handler, as `on_reload_from_zig` applies it: `fetch` stays + /// names no handler, as `apply_reload` applies it: `fetch` stays /// unless the new config replaces it, and callback routes stay as long as /// the new config has no `routes` object at all (`routes: {}` replaces /// them). Static routes and the node:http handler are replaced on every diff --git a/src/runtime/server/StaticRoute.rs b/src/runtime/server/StaticRoute.rs index e8179f6e9e43..52455f253f8b 100644 --- a/src/runtime/server/StaticRoute.rs +++ b/src/runtime/server/StaticRoute.rs @@ -4,21 +4,21 @@ use core::cell::Cell; 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_types::ETag; use bun_ptr::{RefPtr, ThisPtr}; use bun_http_types::MimeType::MimeType; use bun_jsc::HTTPHeaderName; use bun_uws::{AnyRequest, AnyResponse}; +use crate::server::file_route::{status_for_preconditions, write_route_headers}; use crate::server::jsc::{JSGlobalObject, JSValue, JsResult}; -use crate::server::{AnyServer, HTTPStatusText, write_status}; +use crate::server::{AnyServer, HTTPStatusText, RangeRequest, write_status}; +use crate::webcore::blob::BodyFacts; 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 { @@ -34,7 +34,6 @@ pub(crate) struct StaticRoute { pub(crate) status_code: u16, pub(crate) blob: AnyBlob, pub(crate) cached_blob_size: u64, - pub(crate) has_date: bool, pub(crate) headers: Headers, } @@ -67,7 +66,6 @@ impl StaticRoute { pending_ref: Cell::new(None), pending_responses: Cell::new(0), cached_blob_size: blob.size(), - has_date: headers.get(b"date").is_some(), blob, headers, server: Cell::new(server), @@ -149,7 +147,6 @@ impl StaticRoute { pending_responses: Cell::new(0), blob: AnyBlob::Blob(duped), cached_blob_size: self.cached_blob_size, - has_date: self.has_date, headers: self.headers.clone(), server: Cell::new(self.server.get()), status_code: self.status_code, @@ -175,11 +172,13 @@ impl StaticRoute { ))); } + // An unread `.body` stream turns back into the bytes it was made from here. + response.prepare_headers_for_send(global_this)?; + // The user may want to pass in the same Response object multiple endpoints // Let's let them do that. let body_value = response.get_body_value(); - let was_string = body_value.was_string(); - body_value.to_blob_if_possible(); + let was_string = body_value.facts().contains(BodyFacts::STRING); let blob: AnyBlob = 'brk: { match body_value { @@ -190,10 +189,7 @@ impl StaticRoute { } BodyValue::Null | BodyValue::Empty => { - break 'brk AnyBlob::InternalBlob(InternalBlob { - bytes: Vec::::new(), - was_string: false, - }); + break 'brk AnyBlob::InternalBlob(InternalBlob::default()); } BodyValue::Blob(_) @@ -225,11 +221,6 @@ impl StaticRoute { } }; - if let Some(h) = response.get_init_headers_mut() { - h.fast_remove(HTTPHeaderName::TransferEncoding); - h.fast_remove(HTTPHeaderName::ContentLength); - } - // Consuming the body left a plain `Blob` behind, which no longer implies // the `text/plain` a string body carried. Record it on the response's own // headers so re-registering the same `Response` serves the same type. @@ -250,16 +241,29 @@ 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) } // HEAD requests have no body. pub(crate) fn on_head_request(this: ThisPtr, mut req: AnyRequest, resp: AnyResponse) { - // Evaluate conditional request preconditions for HEAD with 200 status - if this.status_code == 200 { - if Self::render_precondition(this, &mut req, resp) { - return; - } + if Self::render_precondition(this, Method::HEAD, &mut req, resp) { + return; } // Continue with normal HEAD request handling @@ -272,7 +276,6 @@ impl StaticRoute { Self::retain_for_response(this); if let Some(server) = this.server.get() { server.on_pending_request(); - resp.timeout(server.config().idle_timeout); } resp.corked(|| this.render_metadata_and_end(resp)); Self::on_response_complete(this, resp); @@ -310,11 +313,8 @@ impl StaticRoute { } pub(crate) fn on_get(this: ThisPtr, mut req: AnyRequest, resp: AnyResponse) { - // Evaluate conditional request preconditions for GET with 200 status - if this.status_code == 200 { - if Self::render_precondition(this, &mut req, resp) { - return; - } + if Self::render_precondition(this, Method::GET, &mut req, resp) { + return; } // Continue with normal GET request handling @@ -327,7 +327,6 @@ impl StaticRoute { Self::retain_for_response(this); if let Some(server) = this.server.get() { server.on_pending_request(); - resp.timeout(server.config().idle_timeout); } let mut finished = false; this.do_render_blob(resp, &mut finished); @@ -364,7 +363,7 @@ impl StaticRoute { resp.clear_on_writable(); resp.clear_timeout(); if let Some(server) = this.server.get() { - server.on_static_request_complete(); + server.on_request_complete(); } let n = this.pending_responses.get() - 1; this.pending_responses.set(n); @@ -393,9 +392,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 { @@ -407,10 +405,6 @@ impl StaticRoute { } fn on_writable(this: ThisPtr, write_offset: u64, resp: AnyResponse) -> bool { - if let Some(server) = this.server.get() { - resp.timeout(server.config().idle_timeout); - } - if !this.on_writable_bytes(write_offset, resp) { return false; } @@ -429,52 +423,13 @@ impl StaticRoute { resp.try_end(bytes, all_bytes.len(), resp.should_close_connection()) } - fn do_write_status(&self, status: u16, resp: AnyResponse) { - match resp { - AnyResponse::SSL(r) => write_status::(r, status), - AnyResponse::TCP(r) => write_status::(r, status), - AnyResponse::H3(_) | AnyResponse::H2(_) => { - let mut b = bun_core::fmt::ItoaBuf::new(); - resp.write_status(bun_core::fmt::itoa(&mut b, status)); - } - } - } - - fn do_write_headers(&self, resp: AnyResponse) { - use bun_http_types::ETag::HeaderEntryColumns; - // Date is a singleton field (RFC 9110 §6.6.1); when the snapshot already - // carries one, suppress uWS's auto-Date so only the user's value is sent. - if self.has_date { - resp.mark_wrote_date_header(); - } - let entries = self.headers.entries.slice(); - let names: &[StringPointer] = entries.items_name(); - let values: &[StringPointer] = entries.items_value(); - let buf = self.headers.buf.as_slice(); - - debug_assert_eq!(names.len(), values.len()); - for (name, value) in names.iter().zip(values) { - resp.write_header( - &buf[name.offset as usize..][..name.length as usize], - &buf[value.offset as usize..][..value.length as usize], - ); - } - if !matches!(resp, AnyResponse::H3(_)) { - if let Some(srv) = self.server.get() { - if let Some(alt) = srv.h3_alt_svc() { - resp.write_header(b"alt-svc", alt); - } - } - } - } - fn render_bytes(&self, resp: AnyResponse, did_finish: &mut bool) { *did_finish = self.on_writable_bytes(0, resp); } fn render_metadata(&self, resp: AnyResponse) { - self.do_write_status(self.status_code, resp); - self.do_write_headers(resp); + write_status(resp, self.status_code); + write_route_headers(&self.headers, self.server.get(), resp); } pub(crate) fn on_with_method(this: ThisPtr, method: Method, resp: AnyResponse) { @@ -482,7 +437,7 @@ impl StaticRoute { Method::GET => Self::on(this, resp), Method::HEAD => Self::on_head(this, resp), _ => { - this.do_write_status(405, resp); // Method not allowed + write_status(resp, 405); // Method not allowed resp.write_header(b"Allow", b"GET, HEAD"); resp.write_header_int(b"Content-Length", 0); resp.end_without_body(resp.should_close_connection()); @@ -490,61 +445,27 @@ impl StaticRoute { } } - /// RFC 9110 §13.2.2 precondition evaluation. Writes a 412 or 304 response - /// and returns `true` when a precondition short-circuits the request. - fn render_precondition(this: ThisPtr, req: &mut AnyRequest, resp: AnyResponse) -> bool { - let etag = this.headers.get(b"etag").filter(|v| !v.is_empty()); - // Deferred: `parse_http_date` allocates + calls into WTF; only run it - // when the client actually sent a date-based conditional header. - let last_modified = || { - this.headers - .get(b"last-modified") - .and_then(crate::jsc_hooks::parse_http_date) - }; - - // Step 1: If-Match (strong comparison); step 2: If-Unmodified-Since - // (only when If-Match is absent). - let precondition_failed = - if let Some(im) = req.header(b"if-match").filter(|v| !v.is_empty()) { - !ETag::if_match(etag, im) - } else if let Some(ius) = req - .header(b"if-unmodified-since") - .and_then(crate::jsc_hooks::parse_http_date) - { - matches!(last_modified(), Some(lm) if lm / 1000 > ius / 1000) - } else { - false - }; - if precondition_failed { - return Self::render_bodiless(this, req, resp, 412); - } - - // Step 3: If-None-Match (weak comparison). Presence suppresses step 4. - let not_modified = if let Some(if_none_match) = req.header(b"if-none-match") { - match etag { - Some(etag) if !if_none_match.is_empty() => ETag::if_none_match(etag, if_none_match), - _ => false, - } - // Step 4: If-Modified-Since (only when If-None-Match is absent). - } else if let Some(ims) = req - .header(b"if-modified-since") - .and_then(crate::jsc_hooks::parse_http_date) - { - // §13.1.3: 304 when Last-Modified <= If-Modified-Since. HTTP-date - // is second-granular, so compare at second precision. - match last_modified() { - Some(lm) => lm / 1000 <= ims / 1000, - None => false, - } - } else { - false - }; - - if !not_modified { - return false; - } - - Self::render_bodiless(this, req, resp, 304) + /// Writes a 412 or 304 response and returns `true` when a precondition short-circuits the request. + fn render_precondition( + this: ThisPtr, + method: Method, + req: &mut AnyRequest, + resp: AnyResponse, + ) -> bool { + let status = status_for_preconditions( + req, + method, + this.status_code, + this.headers.get(b"etag").filter(|v| !v.is_empty()), + // `parse_http_date` allocates and calls into WTF. + || { + this.headers + .get(b"last-modified") + .and_then(crate::jsc_hooks::parse_http_date) + }, + RangeRequest::Result::None, + ); + status != this.status_code && Self::render_bodiless(this, req, resp, status) } /// May free `this`. @@ -558,10 +479,9 @@ impl StaticRoute { Self::retain_for_response(this); if let Some(server) = this.server.get() { server.on_pending_request(); - resp.timeout(server.config().idle_timeout); } - this.do_write_status(status, resp); - this.do_write_headers(resp); + write_status(resp, status); + write_route_headers(&this.headers, this.server.get(), resp); if !HTTPStatusText::is_null_body(status) { resp.write_header_int(b"Content-Length", 0); } diff --git a/src/runtime/server/WebSocketServerContext.rs b/src/runtime/server/WebSocketServerContext.rs index 34f9a13e9e22..c81abdff6ce3 100644 --- a/src/runtime/server/WebSocketServerContext.rs +++ b/src/runtime/server/WebSocketServerContext.rs @@ -8,8 +8,7 @@ pub(crate) struct WebSocketServerContext { pub(crate) handler: Handler, pub(crate) max_payload_length: u32, // default 16MB - pub(crate) max_lifetime: u16, - pub(crate) idle_timeout: u16, // default 2 minutes + pub(crate) idle_timeout: u16, // default 2 minutes pub(crate) compression: i32, pub(crate) backpressure_limit: u32, // default 16MB pub(crate) send_pings_automatically: bool, @@ -170,7 +169,6 @@ impl WebSocketServerContext { compression: self.compression, max_backpressure: self.backpressure_limit, send_pings_automatically: self.send_pings_automatically, - max_lifetime: self.max_lifetime, reset_idle_timeout_on_send: self.reset_idle_timeout_on_send, close_on_backpressure_limit: self.close_on_backpressure_limit, ..Default::default() @@ -245,8 +243,7 @@ pub(crate) fn on_create( let mut server = WebSocketServerContext { handler, max_payload_length: 1024 * 1024 * 16, // 16MB - max_lifetime: 0, - idle_timeout: 120, // 2 minutes + idle_timeout: 120, // 2 minutes compression: 0, backpressure_limit: 1024 * 1024 * 16, // 16MB send_pings_automatically: true, diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index ef282f4ba532..b184727559ab 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -12,14 +12,7 @@ use bun_uws_sys::app::c as uws_app_c; use bun_jsc::{JSGlobalObject, JSValue, JsCell, JsCellRefExt as _, JsResult}; use bun_ptr::AsCtxPtr; -// ─── httplog ───────────────────────────────────────────────────────────────── -// Output.scoped(.Server, .visible) — debug-build no-op until bun_output wires. -macro_rules! httplog { - ($($arg:tt)*) => {{ - #[cfg(debug_assertions)] - { let _ = format_args!($($arg)*); } - }}; -} +use crate::socket::listener::Connections; // ─── server-local jsc re-export ────────────────────────────────────────────── // `bun_jsc` is now a dep; forward to it. `AsyncTaskTracker` lives under @@ -45,7 +38,7 @@ pub(crate) use web_socket_server_context::Handler as WebSocketServerHandler; #[path = "ServerConfig.rs"] pub(crate) mod server_config; -pub(crate) use server_config::ServerConfig; +pub(crate) use server_config::{ServerConfig, ServerTls}; #[path = "StaticRoute.rs"] pub(crate) mod static_route; @@ -88,12 +81,10 @@ pub(crate) mod any_request_context; pub(crate) use any_request_context::AnyRequestContext; /// Run `$body` once for each attached multiplexed app (`h3_app`, then -/// `h2_app`) with `$mux: &mut impl server_config::MuxApp`. A macro rather than +/// `h2_app`) as `$mux`. A macro rather than /// a closure because the two apps are different types. macro_rules! for_each_mux_app { ($self:expr, |$mux:ident| $body:block) => {{ - #[allow(unused_imports)] - use server_config::MuxApp as _; if Self::HAS_H3 { if let Some(app) = $self.h3_app.get() { // S008: `h3::App` is an `opaque_ffi!` ZST — safe deref. @@ -117,15 +108,7 @@ pub(crate) mod server_body; pub(crate) use server_body::{GetOrStartLoadResult, ServePluginsCallback}; // ─── write_status ──────────────────────────────────────────────────────────── -pub(crate) fn write_status(resp: *mut uws_sys::NewAppResponse, status: u16) { - // The route handlers (`StaticRoute`/`FileRoute`) call here from completion - // paths where the request may already be aborted/detached, so no-op on null. - if resp.is_null() { - return; - } - // S008: `Response` is a ZST opaque — safe `*mut → &mut` deref - // (non-null checked above). - let resp = bun_opaque::opaque_deref_mut(resp); +pub(crate) fn write_status(resp: uws::AnyResponse, status: u16) { if let Some(text) = HTTPStatusText::get(status) { resp.write_status(text); } else { @@ -174,14 +157,16 @@ impl AnyRoute { // so `Rc` is unsuitable). Re-exported here for `AnyServer` callers. pub(crate) use server_body::ServePlugins; -// ─── ServerFlags ───────────────────────────────────────────────────────────── -bitflags::bitflags! { - #[derive(Default, Clone, Copy)] - pub struct ServerFlags: u8 { - /// See [`NewServer::hold_open_ref`]. - const OPEN_REF_HELD = 1 << 0; - const TERMINATED = 1 << 1; - } +/// How much of a server is still in use, from the facts their owners report ([`NewServer::phase`]). +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Phase { + Listening, + /// No listener. A request, connection, WebSocket or the HTTP/3 transport still holds the event loop. + Stopping, + /// `stop()`'s promise (node:http: 'close') resolves and the loop ref goes. Only parked requests are left. + Closed, + /// Nothing can dispatch a handler any more, so the wrapper goes weak and the server drops its ref on itself. + Drained, } // ─── NewServer ─────────────────────────────────────────────────────────────── @@ -195,17 +180,25 @@ const N_HTTP_METHODS: usize = 36; bun_jsc::impl_abort_handle_owner!( [const SSL: bool, const DEBUG: bool] NewServer, abort_handle, - |this, _cause| { + |this, cause| { + let connections = match cause { + bun_jsc::AbortCause::ContextStopped(bun_jsc::StopReason::VmTeardown) => { + Connections::CloseAsThreadEnds + } + _ => Connections::Close, + }; // SAFETY: trait contract — `this` is live (armed ⇒ not deinit'd). - unsafe { &*this }.stop(true) + unsafe { &*this }.stop(connections) } ); #[derive(bun_ptr::RefCounted)] #[ref_count(destroy = Self::schedule_deinit)] pub(crate) struct NewServer { - /// The JS wrapper holds one ref, [`Self::hold_open_ref`] another. + /// The JS wrapper holds one ref, `open_ref` another. ref_count: bun_ptr::RefCount, + /// Held from `listen()` until the server is drained: uWS and in-flight requests hold raw pointers to it. + open_ref: JsCell>>, pub(crate) app: core::cell::Cell>>, pub(crate) listener: core::cell::Cell>>, // Never set when !SSL. @@ -242,50 +235,48 @@ pub(crate) struct NewServer { /// it stays live for as long as this server's global object — which always /// outlives the server itself. pub(crate) method_name_cache: [core::cell::Cell; N_HTTP_METHODS], - pub(crate) base_url_string_for_joining: Box<[u8]>, + pub(crate) base_url_string_for_joining: JsCell>, pub(crate) config: JsCell, - pub(crate) pending_requests: core::cell::Cell, + pending_requests: core::cell::Cell, /// Live HTTP connections (accepted, not yet closed or upgraded), fed by /// the uWS filter in [`NewServer::listen`]. Any of them can still dispatch /// a handler, so the wrapper stays `Strong` until this drains /// ([`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. - pub(crate) idle_tunnel_count: core::cell::Cell, + active_connection_count: core::cell::Cell, + /// The connections in `active_connection_count` that don't hold the loop: node:http ones that don't read and have nothing left to send. A parked request puts its connection in this state, which can outlive the request, so uWS keeps it per connection. + idle_connection_count: core::cell::Cell, + /// The node:http requests in `pending_requests` that are parked (`NodeHTTPResponse::update_parked`). + 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 - /// via a callback the body fires) early-return instead of re-running the - /// downgrade/teardown under the outer frame. - deinit_running: core::cell::Cell, + active_websocket_count: core::cell::Cell, + /// Set while a frame applies a phase or closes sockets. Reports that arrive meanwhile wait for that frame, and `schedule_deinit` doesn't free the server under it. + phase_held: core::cell::Cell, /// Armed while listening: the server stops with the context that started it. pub(crate) abort_handle: jsc::AbortHandle, /// The context of the script that started the server: what a request makes before its /// handler runs (its `AbortSignal`) belongs to it. pub(crate) context: core::cell::Cell, pub(crate) request_pool: - &'static 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: core::cell::Cell< - *mut request_context::RequestContextStackAllocator, - >, + pub(crate) mux_request_pool: + core::cell::Cell<*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 + /// `get_all_closed_promise`; read in `apply_phase` (which can run /// after the wrapper is collected, so a wrapper-traced slot would not /// suffice — this Strong is what keeps the cell live across that window). - /// A `.then(cb)` can reach the wrapper, so `deinit_if_we_can` moves the Strong into the task that resolves the promise. + /// A `.then(cb)` can reach the wrapper, so `apply_phase` moves the Strong into the task that resolves the promise. pub(crate) all_closed_promise: JsCell, pub poll_ref: JsCell, - pub(crate) flags: core::cell::Cell, + terminated: core::cell::Cell, /// Shared plugin state; also passed through `JSValue::then` as a promise context. pub(crate) plugins: JsCell>>, @@ -296,12 +287,12 @@ pub(crate) struct NewServer { pub(crate) user_routes: JsCell>>, /// Raw shadow of the wrapper's `m_onClientError` WriteBarrier slot. - /// `JSValue::ZERO` when unset; written by `server_set_on_client_error`. + /// `JSValue::ZERO` when unset; written by `set_on_client_error`. pub(crate) on_clienterror: core::cell::Cell, - /// Raw shadow of the wrapper's `m_onConnection` WriteBarrier slot. - /// `JSValue::ZERO` when unset; written by `server_set_on_connection`. - pub(crate) on_connection: core::cell::Cell, + /// node:http: the callbacks of every `req.socket._handle` of this server. The wrapper's `socketHandlers` slot keeps their cell alive. + pub(crate) node_socket_config: + JsCell>>, pub(crate) inspector_server_id: core::cell::Cell, @@ -349,9 +340,9 @@ fn any_response_from(resp: *mut uws_sys::NewAppResponse) - } } -/// HTTP/1 `RequestContext` for a given server monomorphization. -pub(crate) type ServerRequestContext = - request_context::RequestContext, SSL, DEBUG, false>; +/// The `RequestContext` of a server monomorphization. `MUX`: HTTP/2 or HTTP/3. +pub(crate) type ServerRequestContext = + request_context::RequestContext; #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum CreateJsRequest { @@ -360,25 +351,21 @@ pub(crate) enum CreateJsRequest { Bake, } -/// Bundle of the JS-side `Request`, the heap -/// `webcore::Request`, and the per-request `RequestContext`. Only the HTTP/1 -/// instantiation is materialized; H3 -/// callers never `save()` and the H3 dispatch -/// path is private to `set_routes`. -pub(crate) struct PreparedRequest { +/// The JS `Request`, the heap `webcore::Request` behind it and the request's `RequestContext`. +pub(crate) struct PreparedRequest { pub(crate) js_request: JSValue, pub(crate) request_object: *mut crate::webcore::Request, - pub ctx: *mut ServerRequestContext, + pub ctx: *mut ServerRequestContext, } -impl PreparedRequest { +impl PreparedRequest { /// Used by DevServer to defer calling /// the JS handler until the bundle is actually ready. pub(crate) fn save( self, global: &jsc::JSGlobalObject, req: &mut uws_sys::Request, - resp: *mut uws_sys::NewAppResponse, + resp: uws::AnyResponse, ) -> SavedRequest { // By saving a request, all information from `req` must be // copied since the provided uws.Request will be re-used for @@ -397,7 +384,7 @@ impl PreparedRequest { js_request: jsc::StrongOptional::create(self.js_request, global), request: self.request_object, ctx: AnyRequestContext::init(self.ctx), - response: any_response_from::(resp), + response: resp, } } } @@ -471,7 +458,7 @@ impl NewServer { // ───────────────────────────────────────────────────────────────────────── pub(crate) fn memory_cost(&self) -> usize { - self.base_url_string_for_joining.len() + self.base_url_string_for_joining.get().len() + self.config.get().memory_cost() + self .dev_server @@ -487,54 +474,10 @@ impl NewServer { Some(self.h3_alt_svc.get()) } - pub(crate) fn on_pending_request(&self) { - self.pending_requests.set(self.pending_requests.get() + 1); - } - - /// 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. - extern "C" fn on_connection_filter( - _socket: *mut uws_sys::us_socket_t, - opened: i32, - user_data: *mut c_void, - ) { - // SAFETY: `user_data` is the `*mut Self` registered in `listen()`; the server outlives every socket in its app. - let this = unsafe { &*user_data.cast::() }; - // Count from accept (2 / -2), not from open (1 / -1): a TLS socket still handshaking will dispatch once that completes. - match opened { - 2 => { - this.note_connection_opened(); - return; - } - 3 => { - this.note_tunnel_idle(false); - // With a listener open, the loop ref is the user's to drop (`server.unref()`). - if !this.has_listener() { - this.ref_(); - } - return; - } - -3 => this.note_tunnel_idle(true), - -4 => { - this.note_tunnel_idle(false); - this.note_connection_closed(); - } - -2 => this.note_connection_closed(), - _ => return, - } - if !this.has_loop_holding_connections() && !this.has_listener() { - this.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. - pub(crate) fn get_url_as_string(&self) -> Result { + pub(crate) fn get_url_as_string(&self) -> bun_core::String { use bun_core::fmt::{URLFormatter, URLProto}; - use std::io::Write as _; let fmt = match &self.config.get().address { server_config::Address::Unix(unix) => { let unix = unix.as_bytes(); @@ -576,9 +519,7 @@ impl NewServer { } }; - let mut buf = Vec::new(); - write!(&mut buf, "{}", fmt).map_err(|_| bun_alloc::AllocError)?; - Ok(bun_core::String::clone_utf8(&buf)) + bun_core::String::create_format(format_args!("{fmt}")) } /// Return the server's JS object, asserting that the weak ref is still @@ -591,7 +532,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 @@ -601,11 +542,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.get().is_some() - || self.js_value.get().is_strong() - || self.js_value.try_get().is_none(), + self.js_value.get().is_strong() || self.js_value.try_get().is_none(), "a socket outlived the server's connection accounting" ); self.js_value.try_get() @@ -663,337 +601,18 @@ pub(crate) fn wrap_handler_slot( *shadow = v; } -#[cold] -fn throw_listen_failed(global: &JSGlobalObject, address: &server_config::Address) { - let error_instance = match address { - server_config::Address::Tcp { - port, - hostname: _hostname, - } => { - // `target_os = "linux"` excludes Android. - #[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). Elsewhere errno does not reliably get here: #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!( - "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), - } - } - }; - - error_instance.ensure_still_alive(); - let _ = global.throw_value(error_instance); -} - impl NewServer { - /// Per-monomorphization static. - /// Rust statics cannot be const-generic; routed through a - /// `&'static AtomicBool` so the four (SSL,DEBUG) instantiations share one - /// flag — the warning is process-global by intent (printed at most once - /// regardless of how many servers are running). - fn did_send_idletimeout_warning_once() -> &'static core::sync::atomic::AtomicBool { - static FLAG: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); - &FLAG - } - - /// The body ignores both arguments so a single non-generic shim suffices. - fn on_timeout_for_idle_warn(_: *mut c_void, _: &mut uws_sys::NewAppResponse) { - if DEBUG && !Self::did_send_idletimeout_warning_once().load(Ordering::Relaxed) { - if !crate::cli::Command::get().debug.silent { - Self::did_send_idletimeout_warning_once().store(true, Ordering::Relaxed); - bun_core::warn!( - "Bun.serve() timed out a request after 10 seconds. Pass `idleTimeout` to configure." - ); - } - } - } - - fn should_add_timeout_handler_for_warning(&self) -> bool { - if DEBUG { - if !Self::did_send_idletimeout_warning_once().load(Ordering::Relaxed) - && !crate::cli::Command::get().debug.silent - { - return !self.config.get().has_idle_timeout; - } - } - false - } - - /// The HTTP/1 instantiation - /// (`Ctx == RequestContext`). The `Ctx`-generic `prepareJsRequestContextFor` - /// is folded directly: const-generic `bool` cannot select an associated - /// `Req`/`Resp` type in stable Rust without specialization, and the only - /// other instantiation (H3) populates url/headers eagerly via a separate - /// codepath. The bake/saved-request callers reached through `AnyServer` - /// are HTTP/1-only by construction. - /// - /// # Safety - /// `resp` must be the live response handle for this uWS callback frame. - fn prepare_js_request_context( - &self, - req: &mut uws_sys::Request, - resp: *mut uws_sys::NewAppResponse, - should_deinit_context: Option, - create_js_request: CreateJsRequest, - method: Option, - ) -> Option> { - jsc::mark_binding!(); - // S008: `Response` is a ZST opaque — safe `*mut → &mut` deref. - let resp_ref = bun_opaque::opaque_deref_mut(resp); - - // We need to register the handler immediately since uSockets will not buffer. - // - // We first validate the self-reported request body length so that - // we avoid needing to worry as much about what memory to free. - // (RFC 9114 §4.2 transfer-encoding check is H3-only — skipped here.) - - // Resolve once, reuse for both `has_request_body()` here and the - // forward to `RequestContext::create` below. With `Method::which` now - // a length-gated match (316a83f) the - // second call is cheap, but the resolved value is also what `create` - // wants — passing `None` made it parse a second time. - let method = method.or_else(|| bun_http_types::Method::Method::which(req.method())); - - let request_body_length: Option = 'len: { - if method - .unwrap_or(bun_http_types::Method::Method::OPTIONS) - .has_request_body() - { - let len: usize = if let Some(cl) = req.header(b"content-length") { - bun_http_types::parse_content_length(cl) - } else { - 0 - }; - - // Abort the request very early. - if len > self.config.get().max_request_body_size { - resp_ref.write_status(b"413 Request Entity Too Large"); - resp_ref.end_without_body(true); - return None; - } - - break 'len Some(len); - } - None - }; - - // Earlier responses of this read leave before JavaScript runs (the 413 above runs none). - resp_ref.send_corked(); - resp_ref.send_when_complete(); - - self.on_pending_request(); - - req.set_yield(false); - resp_ref.timeout(self.config.get().idle_timeout); - - // Since we do timeouts by default, we should tell the user when - // this happens - but limit it to only warn once. - if self.should_add_timeout_handler_for_warning() { - // We need to pass it a pointer, any pointer should do. - resp_ref.on_timeout( - Self::on_timeout_for_idle_warn, - Self::did_send_idletimeout_warning_once() - .as_ptr() - .cast::(), - ); - } - - // The `HiveSlot` token is leaked deliberately: the slot is recycled by - // `RequestContext::deinit` via `Fallback::put`, never via `HiveSlot::Drop`. - let ctx_slot = std::mem::ManuallyDrop::new(self.request_pool.claim()) - .addr() - .as_ptr(); - // SAFETY: `claim` hands out an uninitialized slot; `create()` fully - // initializes it via `MaybeUninit::write`. - let ctx_uninit = unsafe { - &mut *ctx_slot.cast::>>() - }; - ServerRequestContext::::create( - ctx_uninit, - self, - std::ptr::from_mut::(req).cast::(), - any_response_from::(resp), - should_deinit_context, - method, - ); - let ctx: *mut ServerRequestContext = ctx_slot; - // SAFETY: fully initialized by `create()`. - let ctx_ref = unsafe { &*ctx }; - - self.vm() - .jsc_vm() - .deprecated_report_extra_memory( - core::mem::size_of::>(), + /// The VM's pool of this server's `RequestContext`s. + fn request_pool_for(&self) -> *const RequestPool { + if MUX { + debug_assert!( + !self.mux_request_pool.get().is_null(), + "HTTP/2 or HTTP/3 request dispatched but mux_request_pool was never allocated" ); - - // Allocate the pooled body slot (ref_count = 1). - let body_hive = crate::webcore::body::hive_alloc(crate::webcore::body::Value::Null); - // Raw payload pointer for the deferred Locked write below. - // SAFETY: slot stays live — both `ctx.request_body` and `Request.body` hold a +1. - let body_value: *mut crate::webcore::body::Value = - unsafe { core::ptr::addr_of_mut!((*body_hive.as_ptr()).value) }; - // Bump once so the ctx and JS Request each - // own a +1 on the same slot (streamed bytes buffered into the ctx - // surface on `request.body`/`request.json()`). - ctx_ref.request_body.set(Some(body_hive.clone())); - - let global = self.global_this(); - let _context = self.vm().enter_context(self.context.get()); - let signal = jsc::AbortSignal::new(global); - // S008: `AbortSignal` is an `opaque_ffi!` ZST — safe deref. - ctx_ref.signal.set(core::ptr::NonNull::new(signal)); - bun_opaque::opaque_deref_mut(signal).pending_activity_ref(); - - let signal_ref = bun_opaque::opaque_deref_mut(signal).ref_(); - // ownership: `Request::new` is `bun.TrivialNew` — the heap - // allocation is handed to the JS GC via `to_js`/`to_js_for_bake` (C++ - // wrapper finalizer frees it), or, for `CreateJsRequest::No`, retained - // by `ctx.request_weakref` until `RequestContext::deinit` releases it. - // `body_hive` (the original +1) moves into the Request — paired drop in - // `Request::destroy`. - let request_object: *mut crate::webcore::Request = - crate::webcore::Request::new(crate::webcore::Request::init( - ctx_ref.method, - AnyRequestContext::init(ctx), - SSL, - Some(signal_ref), - body_hive, - )); - // SAFETY: freshly leaked from `heap::into_raw`, so `request_object` - // carries the allocation's provenance, as `init_ref` requires. - ctx_ref - .request_weakref - .set(unsafe { bun_ptr::WeakPtr::init_ref(request_object) }); - - // (H3 eager-url/header population is unreachable on this path.) - - if DEBUG { - ctx_ref.flags.set_is_web_browser_navigation('brk: { - if let Some(fetch_dest) = req.header(b"sec-fetch-dest") { - if fetch_dest == b"document" { - break 'brk true; - } - } - false - }); - } - - if let Some(req_len) = request_body_length { - ctx_ref.request_body_content_len.set(req_len); - let is_transfer_encoding = req.has_transfer_encoding(); - ctx_ref.flags.set_is_transfer_encoding(is_transfer_encoding); - if req_len > 0 || is_transfer_encoding { - // we defer pre-allocating the body until we receive the first chunk - // that way if the client is lying about how big the body is or the client aborts - // we don't waste memory - // SAFETY: `body_value` is the freshly-initialized hive payload. - unsafe { - *body_value = - crate::webcore::body::Value::Locked(crate::webcore::body::PendingValue { - task: Some(core::ptr::NonNull::new(ctx).unwrap().cast::()), - global: std::ptr::from_ref(global), - on_start_buffering: Some( - ServerRequestContext::::on_start_buffering_callback, - ), - on_start_streaming: Some( - ServerRequestContext::::on_start_streaming_request_body_callback, - ), - on_readable_stream_available: Some( - ServerRequestContext::::on_request_body_readable_stream_available, - ), - producer: crate::webcore::streams::SourceHandle::ServerRequestBody( - AnyRequestContext::init(ctx), - ), - ..Default::default() - }); - } - ctx_ref.flags.set_is_waiting_for_request_body(true); - - resp_ref.on_data( - |u: *mut ServerRequestContext, - _: &mut uws_sys::NewAppResponse, - chunk: &[u8], - last: bool| { - ServerRequestContext::::on_buffered_body_chunk(u, chunk, last) - }, - ctx, - ); - } + self.mux_request_pool.get().cast() + } else { + self.request_pool.cast() } - - Some(PreparedRequest { - js_request: match create_js_request { - // SAFETY: `request_object` is the freshly-allocated heap - // `Request`; ownership transfers to the JS wrapper. - CreateJsRequest::Yes => unsafe { - crate::webcore::Request::to_js(request_object, global) - }, - CreateJsRequest::Bake => { - match crate::webcore::Request::to_js_for_bake(request_object, global) { - Ok(v) => v, - Err(jsc::JsError::OutOfMemory) => bun_core::out_of_memory(), - Err(_) => return None, - } - } - CreateJsRequest::No => JSValue::ZERO, - }, - request_object, - ctx, - }) } /// Invoke the user's route handler for a @@ -1004,7 +623,7 @@ impl NewServer { fn on_saved_request( &self, req: SavedRequestUnion<'_>, - resp: *mut uws_sys::NewAppResponse, + resp: uws::AnyResponse, callback: JSValue, extra_args: [JSValue; ARG_COUNT], ) { @@ -1013,21 +632,22 @@ impl NewServer { // never `Finalized` here), but the VM's script gate can have closed // while the bundle was being built. let Some(server_js) = self.js_value_for_dispatch() else { - server_body::respond_stopped_503(bun_opaque::opaque_deref_mut(resp)); + server_body::respond_stopped_503(resp); return; }; - let prepared: PreparedRequest = match &req { + let prepared: PreparedRequest = match &req { SavedRequestUnion::Stack(r) => { // reshaped for borrowck — decouple the inner // `&mut uws::Request` lifetime from the `req` match guard. let r = std::ptr::from_ref::(*r).cast_mut(); - match self.prepare_js_request_context( - // S008: `uws::Request` is an `opaque_ffi!` ZST — safe deref. - bun_opaque::opaque_deref_mut(r), + match Self::prepare_js_request_context::( + self.as_ctx_ptr(), + uws::AnyRequest::H1(r), resp, None, CreateJsRequest::Bake, None, + None, ) { Some(p) => p, None => return, @@ -1041,10 +661,10 @@ impl NewServer { request_object: data.request, // SAFETY: `SavedRequest` was produced by `PreparedRequest::save` // for this exact (SSL,DEBUG) monomorphization, so the erased - // `AnyRequestContext` payload is `ServerRequestContext`. + // `AnyRequestContext` payload is `ServerRequestContext`. ctx: data .ctx - .get::>() + .get::>() .expect("ctx tag mismatch"), }, }; @@ -1117,157 +737,6 @@ impl NewServer { } } - /// Common tail of `on_request` / - /// `on_user_route_request`: hand the user-handler's return value to the - /// `RequestContext`, then either tear down synchronously or transition to - /// the async path. - /// - /// `should_deinit_context` is the same `Cell` already stored in - /// `ctx.defer_deinit_until_callback_completes` by - /// `prepare_js_request_context`; `&Cell` (shared) and the stored `BackRef` - /// can coexist under Stacked Borrows, so no raw-pointer dance is needed. - fn handle_request( - &self, - should_deinit_context: &core::cell::Cell, - prepared: &PreparedRequest, - req: &mut uws_sys::Request, - response_value: JSValue, - ) { - let ctx = prepared.ctx; - let request_object = prepared.request_object; - - // uWS request will not live longer than this function - // SAFETY: `request_object` is the heap allocation produced by - // `Request::new`; kept alive by `ctx.request_weakref` until - // `RequestContext::deinit` releases it. - let _detach_guard = unsafe { DetachRequestOnDrop::new(request_object) }; - - // SAFETY: `ctx` was allocated by `prepare_js_request_context` for this - // frame and cannot be freed while `defer_deinit_...` is set (it is set - // by the caller until cleared below). - let ctx_ref = unsafe { &*ctx }; - ctx_ref.on_response(self, prepared.js_request, response_value); - // Reference in the stack here in case it is not for whatever reason - prepared.js_request.ensure_still_alive(); - - ctx_ref.defer_deinit_until_callback_completes.set(None); - - if should_deinit_context.get() { - ctx_ref.deinit(); - return; - } - - if ctx_ref.should_render_missing() { - ctx_ref.render_missing(); - return; - } - - // The request is asynchronous, and all information from `req` must be - // copied since the provided uws.Request will be re-used for future - // requests (stack allocated). - // SAFETY: `request_object` is the live heap `Request` (see above). - ctx_ref.to_async( - std::ptr::from_mut::(req).cast::(), - unsafe { &mut *request_object }, - ); - } - - /// Dispatch the user `fetch` handler. - /// - /// # Safety - /// `resp` must be the live response handle for this request frame. - fn on_request(&self, req: &mut uws_sys::Request, resp: *mut uws_sys::NewAppResponse) { - // Idle keep-alive sockets aren't counted in pending_requests, so the - // wrapper can have been finalized before this fires. Refuse and close - // rather than dispatching with a stale handler shadow. - let Some(js_value) = self.js_value_for_dispatch() else { - server_body::respond_stopped_503(bun_opaque::opaque_deref_mut(resp)); - return; - }; - let should_deinit_context = core::cell::Cell::new(false); - let Some(prepared) = self.prepare_js_request_context( - req, - resp, - Some(bun_ptr::BackRef::new(&should_deinit_context)), - CreateJsRequest::Yes, - None, - ) else { - return; - }; - - let _entered = self.vm().enter_event_loop_scope_without_checkpoint(); - // The handler and the render of what it returns continue the script that made the server. - let _context = self.vm().enter_context(self.context.get()); - let on_request = self.config.get().on_request; - debug_assert!(!on_request.is_empty()); - - let global = self.global_this(); - let response_value = - match on_request.call(global, js_value, &[prepared.js_request, js_value]) { - Ok(v) => v, - Err(err) => global.take_exception(err), - }; - - self.handle_request(&should_deinit_context, &prepared, req, response_value); - } - - /// Dispatch a per-route handler - /// (`routes: { "/path": handler }`). - /// - /// # Safety - /// `user_route` must point to a live entry in `server.user_routes` (the - /// address registered as the uWS callback userdata); `resp` must be the - /// live response handle for this request frame. - fn on_user_route_request( - user_route: *const UserRoute, - req: &mut uws_sys::Request, - resp: *mut uws_sys::NewAppResponse, - ) { - // SAFETY: `user_route` is the live entry in `server.user_routes` whose - // address was registered as the uws callback userdata. - let user_route = unsafe { &*user_route }; - // SAFETY: `server` is the live backref stored in `user_route`. - let server = unsafe { &*user_route.server }; - let index = user_route.id; - - let Some(server_js) = server.js_value_for_dispatch() else { - server_body::respond_stopped_503(bun_opaque::opaque_deref_mut(resp)); - return; - }; - - let should_deinit_context = core::cell::Cell::new(false); - let Some(mut prepared) = server.prepare_js_request_context( - req, - resp, - Some(bun_ptr::BackRef::new(&should_deinit_context)), - CreateJsRequest::No, - user_route.route.method.specific(), - ) else { - return; - }; - - let _entered = server.vm().enter_event_loop_scope_without_checkpoint(); - // As in `on_request`. - let _context = server.vm().enter_context(server.context.get()); - let global = server.global_this(); - let server_request_list = - Self::js_route_list_get_cached(server_js).expect("routeList cached value missing"); - let response_value = bun_jsc::host_fn::from_js_host_call(global, || { - Bun__ServerRouteList__callRoute( - global, - index, - prepared.request_object, - server_js, - server_request_list, - &mut prepared.js_request, - core::ptr::from_mut(req).cast::(), - ) - }) - .unwrap_or_else(|err| global.take_exception(err)); - - server.handle_request(&should_deinit_context, &prepared, req, response_value); - } - /// Invoke the JS-side /// `node:http` request handler (`NodeHTTPServer__onRequest_{http,https}`), /// then drive the returned promise / [`NodeHTTPResponse`] through the @@ -1283,7 +752,7 @@ impl NewServer { jsc::mark_binding!(); if self.js_value_for_dispatch().is_none() { - server_body::respond_stopped_503(resp); + server_body::respond_stopped_503(any_response_from::(resp)); return; } self.on_pending_request(); @@ -1292,10 +761,8 @@ impl NewServer { // The listener and what it starts continue the script that made the server. let _context = vm.enter_context(self.context.get()); req.set_yield(false); - resp.timeout(self.config.get().idle_timeout); let global = self.global_this(); - let this_object = self.js_value.try_get().unwrap_or(JSValue::UNDEFINED); // Compute the JS method-name string up front so the FFI closure // doesn't need to reborrow `req` (it's already `&mut`-borrowed below). @@ -1340,7 +807,6 @@ impl NewServer { on_request_ffi( any_server_packed, global, - this_object, callback, method_string, req, @@ -1359,15 +825,15 @@ impl NewServer { // SAFETY: `vm_mut()` is the process-static VirtualMachine. let _ = unsafe { (*self.vm_mut()).uncaught_exception(global, result, false) }; } - server_body::respond_stopped_503(resp); + server_body::respond_stopped_503(any_response_from::(resp)); // Balances `on_pending_request` above. - self.on_static_request_complete(); + self.on_request_complete(); return; } - // SAFETY: out-param written by `on_request_ffi`. One of its three refs is - // this frame's: `deref()` below drops it, or `nhr.promise` holds it for the - // promise reactions. Shared: its state is `Cell`/`JsCell`. + // SAFETY: `on_request_ffi` wrote it and took the ref that `deref()` below drops. Shared: its state is `Cell` / `JsCell`. let nhr = unsafe { &*node_http_response }; + nhr.flags + .set(nhr.flags.get().difference(NhrFlags::IN_LISTENER)); enum HttpResult { Rejection(JSValue), @@ -1413,13 +879,8 @@ impl NewServer { } jsc::js_promise::Status::Pending => { let _ = global.handle_rejected_promises(); - // Single `Cell` load for all three flag checks (no - // re-entry between them). - let nhr_flags = nhr.flags.get(); - if nhr_flags.contains(NhrFlags::REQUEST_HAS_COMPLETED) - || nhr_flags.contains(NhrFlags::SOCKET_CLOSED) - || nhr_flags.contains(NhrFlags::UPGRADED) - { + let over = NhrFlags::REQUEST_HAS_COMPLETED | NhrFlags::SOCKET_CLOSED; + if nhr.flags.get().intersects(over) { strong_promise.deinit(); break 'brk HttpResult::Success; } @@ -1463,12 +924,9 @@ impl NewServer { }; // A pipelined response stays queued: the connection's state describes the one ahead of it. - let threw_while_queued = nhr.mark_dispatch_threw_if_queued(); - - if !threw_while_queued { + if nhr.flags.get().contains(NhrFlags::CURRENT) { let nhr_flags = nhr.flags.get(); - if !nhr_flags.contains(NhrFlags::UPGRADED) && !nhr.is_socket_closed_or_closing() - { + if !nhr.is_socket_closed_or_closing() { if let Some(raw) = nhr.writer() { if !nhr_flags.contains(NhrFlags::REQUEST_HAS_COMPLETED) && raw.state().is_response_pending() @@ -1500,35 +958,17 @@ impl NewServer { } let nhr_flags = nhr.flags.get(); - if !nhr_flags.contains(NhrFlags::UPGRADED) { - if let Some(raw) = nhr.reader() { - if !nhr_flags.contains(NhrFlags::REQUEST_HAS_COMPLETED) - && raw.state().is_response_pending() - { - nhr.set_on_aborted_handler(); - } - // If we ended the response without attaching an ondata handler, we discard the body read stream - else if !matches!(http_result, HttpResult::Pending) { - let this_value = nhr.get_this_value(); - nhr.maybe_stop_reading_body(this_value); - } + if let Some(raw) = nhr.reader() { + if !nhr_flags.contains(NhrFlags::REQUEST_HAS_COMPLETED) + && raw.state().is_response_pending() + { + nhr.set_on_aborted_handler(); } - if nhr_flags.contains(NhrFlags::TUNNELED) { - // Raw 'upgrade'/'connect' handoff: the exchange left HTTP, so - // release the pending-request accounting now - a half-open - // tunnel never closes, which stranded `pending_requests`. - nhr.mark_request_as_done_if_necessary(); + // If we ended the response without attaching an ondata handler, we discard the body read stream + else if !matches!(http_result, HttpResult::Pending) { + let this_value = nhr.get_this_value(); + nhr.maybe_stop_reading_body(this_value); } - } else if nhr_flags.contains(NhrFlags::IS_REQUEST_PENDING) { - // The socket was adopted by the WebSocket context inside the - // handler; the connection is gone and no further uws abort/end - // callback will fire on it, so the IS_REQUEST_PENDING ref - // (one of the initial 3) would otherwise strand and leak the - // box. Release it now and balance the server's - // pending-request counter via `mark_request_as_done()`. - // `should_request_be_pending()` returns false once UPGRADED - // is set, so this reaches `mark_request_as_done()`. - nhr.on_request_complete(); } // Cleanup, hoisted out of scopeguards (no early @@ -1555,282 +995,259 @@ impl NewServer { server_js_cached!(SSL, DEBUG, to_js(this.cast(), global)) } - // `on_reload_from_zig` body lives in `server_body.rs` (`impl NewServer { … }`); - // same crate, separate file, alongside `reload`/`reload_static_routes`. - - pub(crate) fn on_static_request_complete(&self) { - self.on_request_complete(); + pub(crate) fn set_node_http_flags(&self, lenient_http_flags: u8, http_allow_half_open: bool) { + if let Some(app) = self.app.get() { + // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. + bun_opaque::opaque_deref_mut(app).set_flags(lenient_http_flags, http_allow_half_open); + } } - #[inline] - pub(crate) fn on_request_complete(&self) { - self.pending_requests.set(self.pending_requests.get() - 1); - self.deinit_if_we_can(); + pub(crate) fn set_max_http_header_size(&self, max_header_size: u64) { + if let Some(app) = self.app.get() { + // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. + bun_opaque::opaque_deref_mut(app).set_max_http_header_size(max_header_size); + } } - pub(crate) fn active_sockets_count(&self) -> u32 { - self.active_websocket_count.get() + pub(crate) fn set_max_headers_count(&self, max_headers_count: u32) { + if let Some(app) = self.app.get() { + // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. + bun_opaque::opaque_deref_mut(app).set_max_headers_count(max_headers_count); + } } - pub(crate) fn has_active_connections(&self) -> bool { - self.active_connection_count.get() > 0 + // ─── lifecycle ─────────────────────────────────────────────────────────── + + pub(crate) fn on_pending_request(&self) { + self.pending_requests.set(self.pending_requests.get() + 1); } - pub(crate) fn note_connection_opened(&self) { - self.active_connection_count - .set(self.active_connection_count.get().saturating_add(1)); + #[inline] + pub(crate) fn on_request_complete(&self) { + self.pending_requests.set(self.pending_requests.get() - 1); + self.apply_phase(); } - pub(crate) fn note_connection_closed(&self) { - self.active_connection_count - .set(self.active_connection_count.get().saturating_sub(1)); + /// A parked node:http request holds neither the loop nor `stop()`. Its connection reports `-3` next, which applies the phase. + 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 }); } - fn note_tunnel_idle(&self, idle: bool) { - let count = self.idle_tunnel_count.get(); - // `AsyncSocketData::filteredIdleTunnel` pairs every -3 with one +3 or -4. + fn note_connection_idle(&self, idle: bool) { + let count = self.idle_connection_count.get(); + // `AsyncSocketData::filteredParked` pairs every -3 with one +3 or -4. debug_assert!(idle || count > 0); - self.idle_tunnel_count.set(if idle { + self.idle_connection_count.set(if idle { count + 1 } else { count.saturating_sub(1) }); } - /// An idle tunnel is open but, like a libuv handle at EOF with 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() - } - - fn note_websocket_opened(&self) { - self.active_websocket_count - .set(self.active_websocket_count.get().saturating_add(1)); - } - - /// Returns true when this close drained the last live websocket. - fn note_websocket_closed(&self) -> bool { - let prev = self.active_websocket_count.get(); - // Guard underflow: a `close` reaching us without a matching `open` - // (double-close on the JS side) must not wrap and return `true`. - if prev == 0 { - return false; + /// uWS filter. `+2`: accepted, before any TLS handshake. `-2`: closed, handed to another owner (a WebSocket, the socket of 'connect' / 'upgrade'), or only lingering. `-3`: a node:http connection becomes idle (it doesn't read and has nothing to send). `+3`: it has bytes to send or reads again. `-4`: it closes idle. + extern "C" fn on_connection_filter( + _socket: *mut uws_sys::us_socket_t, + opened: i32, + user_data: *mut c_void, + ) { + // SAFETY: `user_data` is the `*mut Self` registered in `listen()`; the server outlives every socket in its app. + let this = unsafe { &*user_data.cast::() }; + let connections = &this.active_connection_count; + match opened { + 2 => { + connections.set(connections.get().saturating_add(1)); + return; + } + 3 => { + this.note_connection_idle(false); + // With a listener open, the loop ref is the user's to drop (`server.unref()`). + if !this.has_listener() { + this.ref_(); + } + return; + } + -3 => this.note_connection_idle(true), + -4 => { + this.note_connection_idle(false); + connections.set(connections.get().saturating_sub(1)); + } + -2 => connections.set(connections.get().saturating_sub(1)), + _ => return, } - let remaining = prev - 1; - self.active_websocket_count.set(remaining); - remaining == 0 + this.apply_phase(); } - pub(crate) fn has_active_web_sockets(&self) -> bool { - self.active_sockets_count() > 0 + /// 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. This can run inside + // lsquic's `process_conns`: `apply_phase` reads cells and queues tasks, it doesn't + // enter the engine. Under the H3 close calls in `stop_listening` it returns at once. + unsafe { &*user_data.cast::() }.apply_phase(); } - /// What the `stop()` promise (node:http: 'close') and the loop unref wait for. A connection counts from accept, before its TLS handshake completes. - pub(crate) fn is_closed(&self) -> bool { - self.pending_requests.get() == 0 - && !self.has_listener() - && !self.has_active_web_sockets() - && !self.has_loop_holding_connections() - } - - /// 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`]). - pub(crate) fn is_drained(&self) -> bool { - self.is_closed() && !self.has_active_connections() + /// 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 + .get() + // S008: `h3::App` is an `opaque_ffi!` ZST — safe deref. + .is_some_and(|h3a| !bun_opaque::opaque_deref_mut(h3a).is_released()) } pub(crate) fn has_listener(&self) -> bool { self.listener.get().is_some() || (Self::HAS_H3 && self.h3_listener.get().is_some()) } - pub(crate) fn set_flags( - &self, - require_host_header: bool, - use_strict_method_validation: bool, - lenient_http_flags: u8, - http_allow_half_open: bool, - ) { - if let Some(app) = self.app.get() { - // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. - bun_opaque::opaque_deref_mut(app).set_flags( - require_host_header, - use_strict_method_validation, - lenient_http_flags, - http_allow_half_open, - ); + fn phase(&self) -> Phase { + if self.has_listener() { + return Phase::Listening; + } + let requests = self.pending_requests.get(); + let connections = self.active_connection_count.get(); + if requests > self.parked_request_count.get() + || connections > self.idle_connection_count.get() + || self.active_websocket_count.get() > 0 + || self.has_open_h3_transport() + { + Phase::Stopping + } else if requests > 0 || connections > 0 { + Phase::Closed + } else { + Phase::Drained } } - pub(crate) fn set_max_http_header_size(&self, max_header_size: u64) { - if let Some(app) = self.app.get() { - // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. - bun_opaque::opaque_deref_mut(app).set_max_http_header_size(max_header_size); - } + pub(crate) fn is_closed(&self) -> bool { + self.phase() >= Phase::Closed } - pub(crate) fn set_max_headers_count(&self, max_headers_count: u32) { - if let Some(app) = self.app.get() { - // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. - bun_opaque::opaque_deref_mut(app).set_max_headers_count(max_headers_count); - } + pub(crate) fn is_drained(&self) -> bool { + self.phase() == Phase::Drained } pub(crate) fn ref_(&self) { - // Once `is_closed()`, nothing is left that would ever `unref()` again - // (`deinit_if_we_can` already dropped the loop ref), so a ref taken - // here would pin the process forever. - if self.poll_ref.get().is_active() || self.is_closed() { - return; + // Nothing would unref a closed server again, so a ref taken now would pin the process. + if !self.is_closed() { + self.poll_ref.with_mut(|r| r.ref_(self.vm.loop_ctx())); } - self.poll_ref.with_mut(|r| r.ref_(self.vm.loop_ctx())); } pub(crate) fn unref(&self) { self.poll_ref.with_mut(|r| r.unref(self.vm.loop_ctx())); } - pub(crate) fn stop_listening(&self, abrupt: bool) { - // httplog!("stopListening", .{}); + /// Runs `close`, which closes sockets whose close callbacks report back to this server. The caller runs `apply_phase` afterwards. + fn while_closing(&self, close: impl FnOnce() -> R) -> R { + let was_held = self.phase_held.replace(true); + let result = close(); + self.phase_held.set(was_held); + result + } + + fn with_websocket_handler(&self, update: impl FnOnce(&mut WebSocketServerHandler)) { + self.config.with_mut(|config| { + if let Some(ws) = config.websocket.as_mut() { + update(&mut ws.handler); + } + }); + } + + /// Closes the listener, every connection and every WebSocket now. `thread_ends`: see `thread_ends` in libusockets.h. + fn terminate(&self, thread_ends: bool) { + if self.terminated.replace(true) { + return; + } + self.with_websocket_handler(|handler| handler.app = None); + if let Some(app) = self.app.get() { + // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. + let app = bun_opaque::opaque_deref_mut(app); + self.while_closing(|| { + if thread_ends { + app.close_as_thread_ends() + } else { + app.close() + } + }); + } + // Not before the close: `on_close` reaches `on_websocket_closed` through `handler.server`. + self.with_websocket_handler(|handler| handler.server = None); + } - // A graceful stop leaves connections open, and this handle is how their context's stop - // reaches them: it stays until they are gone (`deinit_if_we_can`). + fn stop_listening(&self, connections: Connections) { + let abrupt = connections != Connections::Keep; + // A graceful stop leaves connections open, and this handle is how their context's stop reaches them. if abrupt { self.abort_handle.leave(); } if Self::HAS_H3 { let h3l = self.h3_listener.take(); - // 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. - if !abrupt { - if let (Some(h3l), Some(h3a)) = (h3l, self.h3_app.get()) { - // S008: `h3::App` is an `opaque_ffi!` ZST — safe deref. - bun_opaque::opaque_deref_mut(h3a).close(); - self.h3_draining_listener.set(Some(h3l)); - } - } else if let Some(h3l) = h3l.or_else(|| self.h3_draining_listener.take()) { - // S008: `h3::ListenSocket` is an `opaque_ffi!` ZST — safe deref. - bun_opaque::opaque_deref_mut(h3l).close(); - } - } - - let already_terminated = self.flags.get().contains(ServerFlags::TERMINATED); - let Some(listener) = self.listener.take() else { - if Self::HAS_H3 && self.h3_app.get().is_some() { - self.unref(); - self.notify_inspector_server_stopped(); - if abrupt { - self.flags.set(self.flags.get() | ServerFlags::TERMINATED); - } - } - // An earlier graceful stop already took the listener. An abrupt - // stop still has to tear down surviving connections; gated on the - // pre-call `TERMINATED` so the H3 arm's insert above does not - // mask the TCP teardown. - if abrupt && !already_terminated { - self.unref(); - self.config.with_mut(|config| { - if let Some(ws) = config.websocket.as_mut() { - ws.handler.app = None; + // Graceful: GOAWAY, then the open UDP socket drains the connections. Abrupt: close the fd now. + self.while_closing(|| { + if !abrupt { + if let (Some(h3l), Some(h3a)) = (h3l, self.h3_app.get()) { + // S008: `h3::App` is an `opaque_ffi!` ZST — safe deref. + bun_opaque::opaque_deref_mut(h3a).close(); + self.h3_draining_listener.set(Some(h3l)); } - }); - self.flags.set(self.flags.get() | ServerFlags::TERMINATED); - if let Some(app) = self.app.get() { - self.deinit_running.set(true); - // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. - bun_opaque::opaque_deref_mut(app).close(); - self.deinit_running.set(false); + } else if let Some(h3l) = h3l.or_else(|| self.h3_draining_listener.take()) { + // S008: `h3::ListenSocket` is an `opaque_ffi!` ZST — safe deref. + bun_opaque::opaque_deref_mut(h3l).close(); } - self.config.with_mut(|config| { - if let Some(ws) = config.websocket.as_mut() { - ws.handler.server = None; - } - }); - } - return; - }; - if abrupt || self.is_closed() { - self.unref(); + }); } - // A graceful stop with work in flight keeps the ref (deinit_if_we_can - // unrefs when the drain completes): on Windows uv_run skips I/O with - // zero ref'd handles, so unrefing here wedged server.close() teardown. - if !SSL { - // SAFETY: `listener` is a live uws ListenSocket FFI handle just taken - // from `self.listener`; deref'd once to read the socket fd. `vm` is a - // STATIC ref (see `NewServer::vm_mut`) — non-null for the server's - // lifetime, so the raw→`&mut` deref is sound. - unsafe { - let fd = (*listener).socket().fd(); - (*self.vm_mut()).remove_listening_socket_for_watch_mode(fd); - } + let listener = self.listener.take(); + // A graceful stop keeps the loop ref until `Phase::Closed`: on Windows uv_run skips I/O with zero ref'd handles. + if abrupt { + self.unref(); } self.notify_inspector_server_stopped(); - match &self.config.get().address { - server_config::Address::Unix(path) => { - let bytes = path.as_bytes(); - if !bytes.is_empty() && bytes[0] != 0 { - let _ = bun_sys::unlink(path.as_zstr()); + if let Some(listener) = listener { + if !SSL { + // SAFETY: `listener` is the live uws ListenSocket just taken from `self.listener`, + // and `vm_mut()` is the process-static VM. + unsafe { + let fd = (*listener).socket().fd(); + (*self.vm_mut()).remove_listening_socket_for_watch_mode(fd); } } - server_config::Address::Tcp { .. } => {} + match &self.config.get().address { + // The socket file of an inherited descriptor belongs to the process that bound it. + server_config::Address::Unix(path) if self.config.get().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::Unix(_) | server_config::Address::Tcp { .. } => {} + } } - if !abrupt { + if abrupt { + self.terminate(connections == Connections::CloseAsThreadEnds); + } else if let Some(listener) = listener { // S012: `app::ListenSocket` is a ZST opaque — safe deref. bun_opaque::opaque_deref_mut(listener).close(); - // Close idle keep-alive connections now and mark busy ones to - // close once their in-flight work completes; open websockets are - // untouched and drain on their own. Each close reaches - // `on_connection_filter(-2)` synchronously, so hold the guard so - // that path cannot run the idle pass under this frame — - // `stop()` runs `deinit_if_we_can` right after this returns. - // - // node:http servers are exempt: Node's `close()` sweeps idle - // connections exactly once (the JS layer already called - // `closeIdleConnections()`), and a connection whose response - // completes after `close()` stays keep-alive until its timeout - // reaps it — verified against Node v26. - if !self.config.get().is_node_http_server { - if let Some(app) = self.app.get() { - self.deinit_running.set(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); - } + // Node's `close()` sweeps idle connections once, from JS, and a response that completes later keeps its connection. + if !self.config.get().is_node_http_server + && let Some(app) = self.app.get() + { + // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. + self.while_closing(|| { + bun_opaque::opaque_deref_mut(app).close_idle_connections(true) + }); } - } else if !self.flags.get().contains(ServerFlags::TERMINATED) { - self.config.with_mut(|config| { - if let Some(ws) = config.websocket.as_mut() { - ws.handler.app = None; - } - }); - self.flags.set(self.flags.get() | ServerFlags::TERMINATED); - // `app.close()` synchronously drains every open websocket; their - // `on_close` defers call `on_websocket_closed`, which would - // dispatch `deinit_if_we_can` under this frame. 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); - // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. - bun_opaque::opaque_deref_mut(self.app.get().unwrap()).close(); - self.deinit_running.set(false); - // 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 - // would never see it drained. - self.config.with_mut(|config| { - if let Some(ws) = config.websocket.as_mut() { - ws.handler.server = None; - } - }); } } - pub(crate) fn stop(&self, abrupt: bool) { + /// `CloseAsThreadEnds`: the abrupt stop of a server whose thread ends (see `thread_ends` in libusockets.h). + pub(crate) fn stop(&self, connections: Connections) { if self.config.get().allow_hot && !self.config.get().id.is_empty() { // SAFETY: `vm_mut()` is the non-null process-static VM pointer. unsafe { @@ -1840,79 +1257,29 @@ impl NewServer { } } - self.stop_listening(abrupt); - self.deinit_if_we_can(); - } - - /// Refs the server until it is drained: uWS and in-flight requests hold raw pointers to it. - fn hold_open_ref(&self) { - if self.is_drained() || self.flags.get().contains(ServerFlags::OPEN_REF_HELD) { - return; - } - self.flags - .set(self.flags.get() | ServerFlags::OPEN_REF_HELD); - // SAFETY: `self` is the live heap allocation. - unsafe { bun_ptr::RefCount::::ref_(self.as_ctx_ptr()) }; + self.stop_listening(connections); + self.apply_phase(); } - fn take_open_ref(&self) -> Option> { - if !self.flags.get().contains(ServerFlags::OPEN_REF_HELD) { - return None; + /// Does what [`Self::phase`] allows. Whoever reports that a listener, request, connection or WebSocket is gone calls this. + #[inline] + pub(crate) fn apply_phase(&self) { + if !self.has_listener() && !self.phase_held.get() { + self.apply_phase_without_listener(); } - self.flags - .set(self.flags.get().difference(ServerFlags::OPEN_REF_HELD)); - // SAFETY: `OPEN_REF_HELD` witnesses the one ref taken in `hold_open_ref`. - Some(unsafe { bun_ptr::RefPtr::from_raw(self.as_ctx_ptr()) }) } - #[inline] - pub(crate) fn deinit_if_we_can(&self) { - // Re-entrance guard. The websocket-close trigger reaches this through - // an `AnyServer` raw-ptr dispatch and can land here while an outer - // frame is already on the stack (abrupt `app.close()` - // drains sockets synchronously; their close defers call back in). The - // body is idempotent, so the outer call will finish the work — skip - // the nested run. - if self.deinit_running.get() { + #[cold] + fn apply_phase_without_listener(&self) { + let phase = self.phase(); + if phase < Phase::Closed { return; } - self.deinit_running.set(true); - // Cleared inline at the tail (no early returns below). - - httplog!( - "deinitIfWeCan. requests={}, listener={}, connections={}, websockets={}, all_closed_promise={}, has_js_deinited={}", - self.pending_requests.get(), - if self.listener.get().is_none() { - "null" - } else { - "some" - }, - self.active_connection_count.get(), - if self.has_active_web_sockets() { - "active" - } else { - "no" - }, - if self.all_closed_promise.get().has_value() { - "has" - } else { - "no" - }, - self.js_value.get().is_finalized(), - ); + // `drop(dev)` can report completed requests, and `schedule_deinit` must not free `self` in this frame. + self.phase_held.set(true); - let closed = self.is_closed(); - if closed - && self.all_closed_promise.get().has_value() - // `ServerAllConnectionsClosedTask::run_from_js_thread` early-returns - // (without resolving the promise) when the VM is shutting down — - // see the `if !vm.is_shutting_down()` gate there. Skip the - // allocation entirely so a `Server::finalize()` that fires during - // `lastChanceToFinalize()` doesn't strand a `Box` (and its - // `JSPromiseStrong`) that no event-loop tick will ever drain. - && !self.vm().is_shutting_down() - { - httplog!("schedule other promise"); + // At VM shutdown no tick would run the task. + if self.all_closed_promise.get().has_value() && !self.vm().is_shutting_down() { let vm_ref = jsc::VirtualMachine::get_mut(); ServerAllConnectionsClosedTask::schedule( ServerAllConnectionsClosedTask { @@ -1924,19 +1291,15 @@ impl NewServer { vm_ref, ); } - if closed { - self.unref(); - } - if self.is_drained() { + self.unref(); + + if phase == Phase::Drained { self.abort_handle.leave(); - // No handler is dispatched from here on (`js_value_for_dispatch`), so the wrapper — - // the handlers' only GC root — may become collectible. + // The wrapper is the handlers' only GC root. self.js_value.with_mut(|js_value| js_value.downgrade()); - self.config.with_mut(|config| { - if let Some(ws) = config.websocket.as_mut() { - ws.handler.app = None; - ws.handler.server = None; - } + self.with_websocket_handler(|handler| { + handler.app = None; + handler.server = None; }); self.ws_package_handler.with_mut(|handler| { if let Some(handler) = handler.as_mut() { @@ -1945,61 +1308,52 @@ impl NewServer { } }); - // Detach DevServer. This is needed because there are aggressive - // tests that check for DevServer memory soundness. Keeping the JS - // binding alive should not pin `dev.memory_cost()` bytes. + // Keeping the JS binding alive should not pin `dev.memory_cost()` bytes. if let Some(dev) = self.dev_server.take() { if let Some(app) = self.app.get() { // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. bun_opaque::opaque_deref_mut(app).clear_routes(); } - drop(dev); // dev.deinit() + drop(dev); } - // Under `deinit_running`, so that `schedule_deinit` never frees `self` in this frame. - drop(self.take_open_ref()); + drop(self.open_ref.take()); } - self.deinit_running.set(false); + self.phase_held.set(false); } /// The last ref is gone: the JS wrapper is collected and the server is drained. fn schedule_deinit(this: *mut Self) { - httplog!("scheduleDeinit"); // SAFETY: `this` stays allocated until `deinit`. let server = unsafe { &*this }; if server.vm().is_shutting_down() { - // No more ticks. `TERMINATED` means `app.close()` already ran, so `NewApp::destroy` can't orphan a keep-alive socket. - if server.flags.get().contains(ServerFlags::TERMINATED) && !server.deinit_running.get() - { + // No more ticks. + if !server.phase_held.get() { Self::deinit(this); } return; } - if !server.flags.get().contains(ServerFlags::TERMINATED) { - // App.close can cause finalizers to run. - // scheduleDeinit can be called inside a finalizer. - // Therefore, we split it into two tasks. - server - .flags - .set(server.flags.get() | ServerFlags::TERMINATED); - let app = server.app.get().unwrap(); - server - .vm() - .enqueue_task(bun_event_loop::Task::init(app.cast::>())); - } - server.vm().enqueue_task(bun_event_loop::Task::init( this.cast::>(), )); } - pub(crate) fn on_listen(&self, socket: Option<*mut uws_sys::app::ListenSocket>) { - let Some(socket) = socket else { - return self.on_listen_failed(); + pub(crate) fn on_listen( + &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.set(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 + .set(self.config.get().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()) }; @@ -2012,14 +1366,19 @@ 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(&self) { - self.listener.set(None); - throw_listen_failed(self.global_this(), &self.config.get().address); + fn on_listen_failed(&self, failure: uws_sys::app::ListenError) { + use crate::socket::uws_jsc::{ListenApi, ListenTarget, throw_listen_error}; + let config = self.config.get(); + let target = match (config.listen_fd, &config.address) { + (Some(_), _) => ListenTarget::Fd, + (None, server_config::Address::Tcp { port, hostname }) => ListenTarget::Tcp { + hostname: hostname.as_ref().map(|hostname| hostname.as_bytes()), + port: *port, + }, + (None, server_config::Address::Unix(path)) => ListenTarget::Unix(path.as_bytes()), + }; + let _ = throw_listen_error(self.global_this(), ListenApi::BunServe, &target, failure); } pub(crate) fn on_h3_listen(&self, socket: Option<*mut uws_sys::h3::ListenSocket>) { @@ -2036,6 +1395,9 @@ impl NewServer { .into_bytes() .into_boxed_slice(), ); + // With `http1: false` this is the only listener. + self.base_url_string_for_joining + .set(self.config.get().base_url_string_for_joining(Some(port))); } // An `http3_server` feature counter is not (yet) declared in // `bun_analytics`. No-op until it is. @@ -2050,7 +1412,9 @@ impl NewServer { /// produced by [`Self::init`]; no other reference may be live, and `this` /// 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 { &*this }.terminate(false); // SAFETY: paired with heap::alloc in `init()`; `this` is uniquely // owned here, so reclaim the Box up front and let it drive // the teardown (dropped — and freed — at scope exit). @@ -2097,19 +1461,17 @@ 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 { ref_count: bun_ptr::RefCount::init(), + open_ref: JsCell::new(None), global_this: std::ptr::from_ref(global), // Set below, once the server has its final (stable) heap address. any_server_packed: 0, method_name_cache: [const { core::cell::Cell::new(JSValue::ZERO) }; N_HTTP_METHODS], config: JsCell::new(core::mem::take(config)), - base_url_string_for_joining: base_url, + base_url_string_for_joining: JsCell::new(base_url), vm: bun_ptr::BackRef::new(jsc::VirtualMachine::get()), dev_server: JsCell::new(None), app: core::cell::Cell::new(None), @@ -2122,22 +1484,23 @@ impl NewServer { js_value: JsCell::new(jsc::JsRef::empty()), pending_requests: core::cell::Cell::new(0), active_connection_count: core::cell::Cell::new(0), - idle_tunnel_count: core::cell::Cell::new(0), + idle_connection_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), + phase_held: core::cell::Cell::new(false), abort_handle: jsc::AbortHandle::for_owner::(), context: core::cell::Cell::new(jsc::ContextId::default()), 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::cell::Cell::new(core::ptr::null_mut()), + mux_request_pool: core::cell::Cell::new(core::ptr::null()), all_closed_promise: JsCell::new(jsc::JSPromiseStrong::default()), poll_ref: JsCell::new(KeepAlive::default()), - flags: core::cell::Cell::new(ServerFlags::default()), + terminated: core::cell::Cell::new(false), plugins: JsCell::new(None), user_routes: JsCell::new(Vec::new()), on_clienterror: core::cell::Cell::new(JSValue::ZERO), - on_connection: core::cell::Cell::new(JSValue::ZERO), + node_socket_config: JsCell::new(None), inspector_server_id: core::cell::Cell::new(jsc::DebuggerId::init(0)), ws_package_handler: JsCell::new(None), })); @@ -2158,27 +1521,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. - let config = unsafe { (*server).config.get_mut() }; - if let Some(bake_options) = &mut config.bake { - let broadcast = 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.set(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 { @@ -2190,6 +1536,60 @@ impl NewServer { Ok(server) } + /// Creates the DevServer from `self.config.bake`, which must already be at its final heap address. + fn init_dev_server(&self) -> JsResult<()> { + // SAFETY: JS thread only; both callers hold no other borrow of the config. + let config = unsafe { self.config.get_mut() }; + let Some(bake_options) = &mut 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: 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.get(); + self.dev_server.set(Some(dev)); + Ok(()) + } + + /// Registers a static route on the HTTP/1 app and on the attached HTTP/2 and HTTP/3 apps. + fn register_static_route( + &self, + route: bun_ptr::ThisPtr, + entry: &server_config::StaticRouteEntry, + path_has_user_head_route: bool, + ) { + let server = AnyServer::from(std::ptr::from_ref(self)); + let (path, method) = (&entry.path[..], entry.method); + // S008: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. `listen()` set it. + let app = bun_opaque::opaque_deref_mut(self.app.get().unwrap()); + server_config::apply_static_route( + server, + app, + route, + path, + method, + path_has_user_head_route, + ); + for_each_mux_app!(self, |mux| { + server_config::apply_static_route( + server, + mux, + route, + path, + method, + path_has_user_head_route, + ); + }); + } + // ─── 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 @@ -2270,8 +1670,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; let websocket_ptr = self.config.get().websocket.as_ref(); @@ -2281,9 +1680,6 @@ impl NewServer { let ud: *mut c_void = user_route.as_ctx_ptr().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/")) { @@ -2306,7 +1702,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 { @@ -2336,9 +1732,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 @@ -2358,9 +1751,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.get().on_node_http_request.is_empty() { + let default_h1 = if !self.config.get().on_node_http_request.is_empty() { trampoline::on_node_http_request:: } else if !self.config.get().on_request.is_empty() { trampoline::on_request:: @@ -2369,8 +1762,8 @@ impl NewServer { }; for route_path in self.config.get().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); @@ -2379,19 +1772,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.get().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 @@ -2412,88 +1796,18 @@ impl NewServer { server_config::RouteMethod::Any => false, }); - // Each `p`/`r` is the live `RefPtr<_>` stored in `entry.route`; - // `app`/`h2_app`/`h3_app` are the live uWS app handles owned by `self`. match &entry.route { AnyRoute::Static(r) => { - server_config::apply_static_route::( - any_server, - app, - r.this_ptr(), - &entry.path, - entry.method, - path_has_user_head_route, - ); - for_each_mux_app!(self, |mux| { - server_config::apply_static_route_mux::( - any_server, - mux, - r.this_ptr(), - &entry.path, - entry.method, - path_has_user_head_route, - ); - }); + self.register_static_route(r.this_ptr(), entry, path_has_user_head_route); } AnyRoute::File(r) => { - server_config::apply_static_route::( - any_server, - app, - r.this_ptr(), - &entry.path, - entry.method, - path_has_user_head_route, - ); - for_each_mux_app!(self, |mux| { - server_config::apply_static_route_mux::( - any_server, - mux, - r.this_ptr(), - &entry.path, - entry.method, - path_has_user_head_route, - ); - }); + self.register_static_route(r.this_ptr(), entry, path_has_user_head_route); } AnyRoute::Directory(r) => { - server_config::apply_static_route::( - any_server, - app, - r.this_ptr(), - &entry.path, - entry.method, - path_has_user_head_route, - ); - for_each_mux_app!(self, |mux| { - server_config::apply_static_route_mux::( - any_server, - mux, - r.this_ptr(), - &entry.path, - entry.method, - path_has_user_head_route, - ); - }); + self.register_static_route(r.this_ptr(), entry, path_has_user_head_route); } AnyRoute::Html(r) => { - server_config::apply_static_route::( - any_server, - app, - r.this_ptr(), - &entry.path, - entry.method, - path_has_user_head_route, - ); - for_each_mux_app!(self, |mux| { - server_config::apply_static_route_mux::( - any_server, - mux, - r.this_ptr(), - &entry.path, - entry.method, - path_has_user_head_route, - ); - }); + self.register_static_route(r.this_ptr(), entry, path_has_user_head_route); if let Some(dev) = dev_server { // SAFETY: `dev` is the live `*mut DevServer` snapshotted // from `self.dev_server` above; no other `&mut` to it @@ -2541,19 +1855,18 @@ 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; no other `&mut` to it is live. - has_dev_server_for_star_path = + let has_dev_server_for_star_path = bun_core::handle_oom(unsafe { &mut *dev }.set_routes::(self)); 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; } } @@ -2573,72 +1886,22 @@ impl NewServer { // --- 9. Consolidated "/*" HTTP fallback registration --- let ud = self_ptr.cast::(); - let has_node_http = !self.config.get().on_node_http_request.is_empty(); - let has_on_request = !self.config.get().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.get().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( @@ -2663,13 +1926,14 @@ impl NewServer { where Self: ServerPools, { - let (http2, is_node_http, http1, idle_timeout, has_dev_server) = { + let (http2, is_node_http, http1, idle_timeout, max_request_body_size, has_dev_server) = { let cfg = self.config.get(); ( cfg.http2, cfg.is_node_http_server || !cfg.on_node_http_request.is_empty(), cfg.http1, u32::from(cfg.idle_timeout), + cfg.max_request_body_size as u64, self.dev_server.get().is_some(), ) }; @@ -2693,9 +1957,12 @@ impl NewServer { return true; } // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. - let Some(h2) = - create_http2_app::(bun_opaque::opaque_deref_mut(app), http1, idle_timeout) - else { + let Some(h2) = create_http2_app::( + bun_opaque::opaque_deref_mut(app), + http1, + idle_timeout, + max_request_body_size, + ) else { let global = self.global_this(); if !global.has_exception() { let _ = global.throw(format_args!("Failed to create HTTP/2 server")); @@ -2738,7 +2005,6 @@ impl NewServer { where Self: ServerPools, { - httplog!("listen"); // A `BackRef`, not `&Self`: the failure paths free `this`. let this_ref = bun_ptr::BackRef::from( core::ptr::NonNull::new(this).expect("listen: this non-null (from init())"), @@ -2773,38 +2039,33 @@ impl NewServer { if SSL { bun_boringssl::load(); - let Some(ssl_options) = this_ref - .config - .get() - .ssl_config - .as_ref() - .map(|c| c.as_usockets()) - else { - // unreachable in practice — fromJS guarantees ssl_config when SSL. - let _ = global.throw(format_args!( - "Failed to create HTTPS server: missing tls config" - )); + // The options point into `config.tls`, which nothing below replaces. + let (default_ctx, ssl_options) = match &this_ref.config.get().tls { + ServerTls::Context(ctx) => (Some(ctx.clone()), None), + ServerTls::Options { default, .. } => { + let options = default.as_usockets(); + (create_ssl_ctx(&options), Some(options)) + } + ServerTls::Off => (None, None), + }; + let Some(default_ctx) = default_ctx else { + if !global.has_exception() && !throw_ssl_error_if_necessary(global) { + let _ = global.throw(format_args!("Failed to create HTTP server")); + } // SAFETY: caller contract — `this` is the live boxed server from `init()`. Self::deinit(this); return JSValue::ZERO; }; - - app = match uws_sys::NewApp::::create(&ssl_options) { - Some(a) => a, - None => { - if !global.has_exception() && !throw_ssl_error_if_necessary(global) { - let _ = global.throw(format_args!("Failed to create HTTP server")); - } - // SAFETY: caller contract — `this` is the live boxed server from `init()`. - Self::deinit(this); - return JSValue::ZERO; - } - }; + app = uws_sys::NewApp::::create(Some(&default_ctx)); this_ref.app.set(Some(app)); - if Self::HAS_H3 && this_ref.config.get().http3 { + if let Some(ssl_options) = ssl_options + .as_ref() + .filter(|_| Self::HAS_H3 && this_ref.config.get().http3) + { let idle_timeout = this_ref.config.get().idle_timeout as u32; - let h3 = match create_http3_app(&ssl_options, idle_timeout) { + let max_request_body_size = this_ref.config.get().max_request_body_size as u64; + let h3 = match create_http3_app(ssl_options, idle_timeout, max_request_body_size) { Some(a) => Some(a), None => { if !global.has_exception() { @@ -2828,101 +2089,75 @@ impl NewServer { route_list_value = this_ref.set_routes(); - // add serverName to the SSL context using the default ssl options - let server_name = this_ref - .config - .get() - .ssl_config - .as_ref() - .and_then(|c| c.server_name_cstr()) - .filter(|n| !n.to_bytes().is_empty()); - if let Some(server_name) = server_name { - // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. - if bun_opaque::opaque_deref_mut(app) - .add_server_name_with_options(server_name, &ssl_options, false) - .is_err() + if let ServerTls::Options { + default, + server_names, + } = &this_ref.config.get().tls + { + // Its own name selects the default context ahead of a wildcard entry. + if let Some(server_name) = default + .server_name_cstr() + .filter(|name| !name.to_bytes().is_empty()) { - if !global.has_exception() && !throw_ssl_error_if_necessary(global) { - let _ = global.throw(format_args!( - "Failed to add serverName: {}", - bstr::BStr::new(server_name.to_bytes()) - )); - } - // SAFETY: caller contract — `this` is the live boxed server from `init()`. - Self::deinit(this); - return JSValue::ZERO; - } - if throw_ssl_error_if_necessary(global) { - // SAFETY: caller contract — `this` is the live boxed server from `init()`. - Self::deinit(this); - return JSValue::ZERO; + // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. + bun_opaque::opaque_deref_mut(app).add_server_name( + server_name, + &default_ctx, + false, + ); } - } - // SNI: per-hostname contexts - for sni_ssl_config in this_ref.config.get().sni.as_deref().unwrap_or_default() { - let Some(sni_name) = sni_ssl_config.server_name_cstr() else { - continue; - }; - if sni_name.to_bytes().is_empty() { - continue; - } - let sni_opts = sni_ssl_config.as_usockets(); - let z = bun_core::ZStr::from_cstr(sni_name); + for server_name in server_names { + let Some(name) = server_name + .server_name_cstr() + .filter(|name| !name.to_bytes().is_empty()) + else { + continue; + }; + let options = server_name.as_usockets(); - if Self::HAS_H3 { - if let Some(h3_app) = this_ref.h3_app.get() { - // S008: `h3::App` is an `opaque_ffi!` ZST — safe deref. - if bun_opaque::opaque_deref_mut(h3_app) - .add_server_name_with_options(z, &sni_opts) - .is_err() - { - if !global.has_exception() { - let _ = global.throw(format_args!( - "Failed to add serverName \"{}\" for HTTP/3", - bstr::BStr::new(sni_name.to_bytes()) - )); + if Self::HAS_H3 { + if let Some(h3_app) = this_ref.h3_app.get() { + // S008: `h3::App` is an `opaque_ffi!` ZST — safe deref. + if bun_opaque::opaque_deref_mut(h3_app) + .add_server_name_with_options( + bun_core::ZStr::from_cstr(name), + &options, + ) + .is_err() + { + if !global.has_exception() { + let _ = global.throw(format_args!( + "Failed to add serverName \"{}\" for HTTP/3", + bstr::BStr::new(name.to_bytes()) + )); + } + // SAFETY: caller contract — `this` is the live boxed server from `init()`. + Self::deinit(this); + return JSValue::ZERO; } - // SAFETY: caller contract — `this` is the live boxed server from `init()`. - Self::deinit(this); - return JSValue::ZERO; } } - } - // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. - if bun_opaque::opaque_deref_mut(app) - .add_server_name_with_options(sni_name, &sni_opts, true) - .is_err() - { - if !global.has_exception() && !throw_ssl_error_if_necessary(global) { - let _ = global.throw(format_args!( - "Failed to add serverName: {}", - bstr::BStr::new(sni_name.to_bytes()) - )); - } - // SAFETY: caller contract — `this` is the live boxed server from `init()`. - Self::deinit(this); - return JSValue::ZERO; - } - if throw_ssl_error_if_necessary(global) { - // SAFETY: caller contract — `this` is the live boxed server from `init()`. - Self::deinit(this); - return JSValue::ZERO; + // A build that succeeds can still leave errors queued. + let Some(ctx) = + create_ssl_ctx(&options).filter(|_| !throw_ssl_error_if_necessary(global)) + else { + if !global.has_exception() && !throw_ssl_error_if_necessary(global) { + let _ = global.throw(format_args!( + "Failed to add serverName: {}", + bstr::BStr::new(name.to_bytes()) + )); + } + // SAFETY: caller contract — `this` is the live boxed server from `init()`. + Self::deinit(this); + return JSValue::ZERO; + }; + // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. + bun_opaque::opaque_deref_mut(app).add_server_name(name, &ctx, true); } } } else { - app = match uws_sys::NewApp::::create(&uws_sys::BunSocketContextOptions::default()) - { - Some(a) => a, - None => { - if !global.has_exception() { - let _ = global.throw(format_args!("Failed to create HTTP server")); - } - // SAFETY: caller contract — `this` is the live boxed server from `init()`. - Self::deinit(this); - return JSValue::ZERO; - } - }; + app = uws_sys::NewApp::::create(None); this_ref.app.set(Some(app)); if !this_ref.attach_http2(app) { // SAFETY: caller contract — `this` is the live boxed server from `init()`. @@ -2934,25 +2169,36 @@ 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.get() { + // 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::()); + } + } + bun_opaque::opaque_deref_mut(app).set_idle_timeout(this_ref.config.get().idle_timeout); if this_ref.config.get().is_node_http_server { this_ref.set_using_custom_expect_handler(true); + } else { + bun_opaque::opaque_deref_mut(app) + .set_max_request_body_size(this_ref.config.get().max_request_body_size as u64); } // Owns the bracket-stripped copy of an IPv6 literal hostname; `host` // points into it, so it has to outlive the listen calls below. let mut stripped_hostname: Option = None; - // `host` and the path reference `config.address`'s backing storage, - // which the trampolines never touch (they only write `listener`/ - // `h3_listener`), so the bytes remain valid through the listen calls. + // `host` and the path reference `config.address`'s backing storage, which `on_listen` and `on_h3_listen` never touch. enum Addr<'a> { Tcp { port: u16, host: *const c_char }, Unix(&'a bun_core::ZStr), + Fd(i32), } let (addr, tcp, options) = { let cfg = this_ref.config.get(); - 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(); @@ -2967,12 +2213,17 @@ impl NewServer { } Addr::Tcp { port: *port, host } } - server_config::Address::Unix(unix) => Addr::Unix(unix.as_zstr()), + (None, server_config::Address::Unix(unix)) => Addr::Unix(unix.as_zstr()), }; (addr, cfg.http1 || cfg.http2, cfg.get_usockets_options()) }; 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; + this_ref.on_listen(bun_opaque::opaque_deref_mut(app).listen_fd(fd, options)); + } 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 @@ -2990,17 +2241,14 @@ impl NewServer { attempt += 1; if tcp { // SAFETY: app is a live uws handle owned by this server. - 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, - }, - ); - } + let listened = unsafe { + (*app).listen_with_config(uws_app_c::uws_app_listen_config_t { + port: port as c_int, + host, + options, + }) + }; + this_ref.on_listen(listened); } if Self::HAS_H3 { @@ -3066,14 +2314,8 @@ impl NewServer { } } // SAFETY: app is a live uws handle owned by this server. - unsafe { - (*app).listen_on_unix_socket( - trampoline::on_listen_unix::, - this.cast::(), - path, - options, - ); - } + let listened = unsafe { (*app).listen_on_unix_socket(path, options) }; + this_ref.on_listen(listened); } } @@ -3083,7 +2325,11 @@ impl NewServer { return JSValue::ZERO; } - this_ref.hold_open_ref(); + debug_assert!(this_ref.has_listener()); + // SAFETY: `this` is the live boxed server from `init()`. + this_ref + .open_ref + .set(Some(unsafe { bun_ptr::RefPtr::init_ref(this) })); this_ref.ref_(); // NOTE: the "starting an HTTP server is a good time to GC" nudge runs @@ -3112,27 +2358,27 @@ impl NewServer { } // ─── route-list codegen externs ────────────────────────────────────────────── -// Canonical decls live in `server_body.rs` (shared with the H3 path); reuse -// them here instead of redeclaring with a divergent `req` pointer type. -use server_body::{Bun__ServerRouteList__callRoute, Bun__ServerRouteList__create}; +use server_body::Bun__ServerRouteList__create; #[inline(never)] fn create_http2_app( parent: &mut uws_sys::app::App, allow_http1: bool, idle_timeout_s: u32, + max_request_body_size: u64, ) -> Option<*mut uws_sys::h2::App> { bun_removable_features::removable_definition!(ServeHttp2, create_http2_app::); - uws_sys::h2::App::create::(parent, allow_http1, idle_timeout_s) + uws_sys::h2::App::create::(parent, allow_http1, idle_timeout_s, max_request_body_size) } #[inline(never)] fn create_http3_app( options: &uws_sys::BunSocketContextOptions, idle_timeout_s: u32, + max_request_body_size: u64, ) -> Option<*mut uws_sys::h3::App> { bun_removable_features::removable_definition!(Http3, create_http3_app); - uws_sys::h3::App::create(options, idle_timeout_s) + uws_sys::h3::App::create(options, idle_timeout_s, max_request_body_size) } /// `(SSL, DEBUG)` → the generated `js_*Server` module of that class. @@ -3168,7 +2414,8 @@ impl NewServer { on_node_http_request_set_cached ); slot_setter!(js_gc_on_client_error_set, on_client_error_set_cached); - slot_setter!(js_gc_on_connection_set, on_connection_set_cached); + slot_setter!(js_gc_socket_handlers_set, socket_handlers_set_cached); + slot_setter!(js_gc_socket_data_set, socket_data_set_cached); slot_setter!(js_gc_ws_on_open_set, ws_on_open_set_cached); slot_setter!(js_gc_ws_on_message_set, ws_on_message_set_cached); slot_setter!(js_gc_ws_on_close_set, ws_on_close_set_cached); @@ -3231,7 +2478,7 @@ impl NewServer { /// Writes all slots unconditionally — `JSValue::ZERO` clears, so a reload /// that omits a callback drops the previous root. Same contract as /// [`wrap_handler_slot`]; called after `ptr_to_js` in `serve()` and after - /// the websocket-context swap in `on_reload_from_zig`; dispatch keeps + /// the websocket-context swap in `apply_reload`; dispatch keeps /// reading the shadow. pub(crate) fn write_ws_handler_slots(&self, server_js: JSValue, global: &JSGlobalObject) { // No websocket config: route a throwaway ZERO through each slot so a @@ -3272,30 +2519,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 { &*user_data.cast::>() }; - 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, @@ -3313,10 +2537,13 @@ mod trampoline { req: *mut UwsRequest, user_data: *mut c_void, ) { - // SAFETY: user_data is the `*mut NewServer<..>` registered in set_routes. - let server = unsafe { &*user_data.cast::>() }; - // S008: `uws::Request` is an `opaque_ffi!` ZST — safe deref. - server.on_request(bun_opaque::opaque_deref_mut(req), res.cast()); + // user_data is the `*mut NewServer<..>` registered in set_routes. + NewServer::::on_request::( + user_data.cast(), + uws::AnyRequest::H1(req), + any_response_from::(res.cast()), + None, + ); } pub(super) extern "C" fn on_user_route_request( @@ -3324,12 +2551,12 @@ mod trampoline { req: *mut UwsRequest, user_data: *mut c_void, ) { - // user_data is the `*mut UserRoute<..>` registered in set_routes. - // S008: `uws::Request` is an `opaque_ffi!` ZST — safe deref. - NewServer::::on_user_route_request( - user_data.cast::>(), - bun_opaque::opaque_deref_mut(req), - res.cast(), + NewServer::::on_user_route_request::( + // SAFETY: user_data is the `*mut UserRoute<..>` registered in set_routes, a live entry of `server.user_routes`. + unsafe { &*user_data.cast::>() }, + uws::AnyRequest::H1(req), + any_response_from::(res.cast()), + None, ); } @@ -3384,60 +2611,79 @@ 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` is `!Sync`: `claim`/`put` mutate through `&self` with no 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() - -> &'static 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; + +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 { + &raw const **slot.get_or_init(RequestPool::::new_boxed) + } +} + +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() -> &'static request_context::RequestContextStackAllocator { - type Pool = request_context::RequestContextStackAllocator, $ssl, $debug, false>; - thread_local! { - static POOL: core::cell::Cell> = - const { core::cell::Cell::new(None) }; - } - POOL.with(|cell| { - cell.get().unwrap_or_else(|| { - // `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. - let p: &'static Pool = Box::leak(Pool::new_boxed()); - cell.set(Some(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 = Box::into_raw(Pool::new_boxed()); - 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 { @@ -3469,7 +2715,6 @@ mod ffi { pub(super) safe fn NodeHTTPServer__onRequest_http( any_server: usize, global: &jsc::JSGlobalObject, - this_value: jsc::JSValue, callback: jsc::JSValue, method_string: jsc::JSValue, request: *mut uws_sys::Request, @@ -3480,7 +2725,6 @@ mod ffi { pub(super) safe fn NodeHTTPServer__onRequest_https( any_server: usize, global: &jsc::JSGlobalObject, - this_value: jsc::JSValue, callback: jsc::JSValue, method_string: jsc::JSValue, request: *mut uws_sys::Request, @@ -3490,6 +2734,16 @@ mod ffi { } } +/// Servers with the same options share a context, unless it reads files. `None` leaves BoringSSL's error queued for `throw_ssl_error_if_necessary`. +fn create_ssl_ctx( + options: &uws_sys::BunSocketContextOptions, +) -> Option { + let mut err = uws::create_bun_socket_error_t::none; + // A context from the cache builds nothing, so an error that another call left queued would pass for this one's. + bun_boringssl_sys::ERR_clear_error(); + crate::api::SSLContextCache::SSLContextCache::get_or_create_for_server(options, &mut err) +} + /// Drain the BoringSSL error queue; if non-empty, throw the top error on /// `global` and return true. fn throw_ssl_error_if_necessary(global: &JSGlobalObject) -> bool { @@ -3504,82 +2758,36 @@ fn throw_ssl_error_if_necessary(global: &JSGlobalObject) -> bool { // `RequestContext` reaches back into its server via this; mirrors the // field/method surface the per-request state machine needs without naming // `NewServer` (avoids a generic-parameter cycle). -pub(crate) trait ServerLike { - fn global_this(&self) -> &jsc::JSGlobalObject; - fn vm(&self) -> &jsc::VirtualMachine; +// What a `RequestContext` asks of its server. +impl NewServer { /// The context of the script that made the server: what a request starts continues it. - fn context(&self) -> &jsc::ScriptExecutionContext; - fn config(&self) -> &ServerConfig; - fn on_request_complete(&self); - fn dev_server(&self) -> Option<&crate::bake::DevServer::DevServer>; - fn js_value(&self) -> &jsc::JsRef; - fn h3_alt_svc(&self) -> Option<&[u8]>; - fn terminated(&self) -> bool; - /// Return `ctx` to the per-server HiveArray pool for the matching transport. - /// Erased to `*mut c_void` so the trait stays object-safe and doesn't need - /// to name `RequestContext` (which would re-introduce the - /// generic-parameter cycle this trait exists to break). - fn release_request_context(&self, ctx: *mut c_void, is_mux: bool); -} - -impl ServerLike for NewServer { - // These trait-method forwards are on the per-request hot path (called via - // `RequestContext::server.vm()` etc.). Without `#[inline]` a generic trait - // impl is not eligible for cross-crate inlining at all, so each accessor - // would compile to a real `call` even though the inherent method it - // forwards to is itself one instruction. - #[inline(always)] - fn global_this(&self) -> &jsc::JSGlobalObject { - Self::global_this(self) - } - #[inline(always)] - fn vm(&self) -> &jsc::VirtualMachine { - Self::vm(self) - } #[inline] - fn context(&self) -> &jsc::ScriptExecutionContext { - Self::vm(self).context_of(self.context.get()) + pub(crate) fn context(&self) -> &jsc::ScriptExecutionContext { + self.vm().context_of(self.context.get()) } #[inline(always)] - fn config(&self) -> &ServerConfig { + pub(crate) fn config(&self) -> &ServerConfig { self.config.get() } #[inline] - fn on_request_complete(&self) { - Self::on_request_complete(self) - } - #[inline] - fn dev_server(&self) -> Option<&crate::bake::DevServer::DevServer> { + pub(crate) fn dev_server(&self) -> Option<&crate::bake::DevServer::DevServer> { self.dev_server.get().as_deref() } #[inline(always)] - fn js_value(&self) -> &jsc::JsRef { + pub(crate) fn js_value(&self) -> &jsc::JsRef { self.js_value.get() } - #[inline] - fn h3_alt_svc(&self) -> Option<&[u8]> { - Self::h3_alt_svc(self) - } #[inline(always)] - fn terminated(&self) -> bool { - self.flags.get().contains(ServerFlags::TERMINATED) + pub(crate) fn terminated(&self) -> bool { + self.terminated.get() } - fn release_request_context(&self, ctx: *mut c_void, is_mux: bool) { - // SAFETY: ctx was allocated from this exact pool by `prepare_js_request_context`; - // it is `RequestContext` by construction. - unsafe { - if is_mux { - (*self.mux_request_pool.get()).put( - &raw mut *ctx.cast::>(), - ); - } else { - self.request_pool - .put( - &raw mut *ctx - .cast::>(), - ); - } - } + /// Returns `ctx` to the pool `prepare_js_request_context` took it from. + pub(crate) fn release_request_context( + &self, + ctx: *mut ServerRequestContext, + ) { + // SAFETY: the pool is the VM's and outlives the server, and `ctx` is one of its slots. + unsafe { (*self.request_pool_for::()).put(ctx) }; } } @@ -3674,42 +2882,6 @@ macro_rules! any_server_dispatch { }}; } -/// Dispatch over the four `NewServer` monomorphizations, simultaneously -/// downcasting an [`uws::AnyResponse`] to the matching `*mut Response`. -/// -/// Binds `$s: &NewServer` and `$r: *mut -/// uws_sys::Response`. Tag↔SSL invariant is enforced by -/// `assert_ssl`/`assert_no_ssl` (panics on `AnyResponse::H3`, matching the -/// hand-written arms this replaces). The body is monomorphized four times. -macro_rules! any_server_dispatch_resp { - ($self:expr, $resp:expr, |$s:ident, $r:ident| $body:expr) => {{ - let this = $self; - let __resp = $resp; - match this.tag { - AnyServerTag::HTTPServer => { - let $s = this.as_http(); - let $r = __resp.assert_no_ssl(); - $body - } - AnyServerTag::HTTPSServer => { - let $s = this.as_https(); - let $r = __resp.assert_ssl(); - $body - } - AnyServerTag::DebugHTTPServer => { - let $s = this.as_debug_http(); - let $r = __resp.assert_no_ssl(); - $body - } - AnyServerTag::DebugHTTPSServer => { - let $s = this.as_debug_https(); - let $r = __resp.assert_ssl(); - $body - } - } - }}; -} - impl AnyServer { pub(crate) fn from( server: *const NewServer, @@ -3726,21 +2898,6 @@ impl AnyServer { } } - /// `None` if `value` is not what `Bun.serve` returns. `value` keeps the server alive. - pub(crate) fn from_js(value: JSValue) -> Option { - if let Some(server) = value.as_class_ref::() { - Some(Self::from(std::ptr::from_ref(server))) - } else if let Some(server) = value.as_class_ref::() { - Some(Self::from(std::ptr::from_ref(server))) - } else if let Some(server) = value.as_class_ref::() { - Some(Self::from(std::ptr::from_ref(server))) - } else { - value - .as_class_ref::() - .map(|server| Self::from(std::ptr::from_ref(server))) - } - } - /// Re-pack into the tagged-pointer wire format /// (`u49` ptr | `u15` tag) for the C++ FFI boundary. Inverse of /// [`NodeHTTPResponse::any_server_from_packed`]. Tag values are @@ -3808,7 +2965,7 @@ impl AnyServer { #[inline] pub(crate) fn terminated(&self) -> bool { - any_server_dispatch!(self, |s| s.flags.get().contains(ServerFlags::TERMINATED)) + any_server_dispatch!(self, |s| s.terminated.get()) } /// Same gate as [`NewServer::js_value_for_dispatch`]. @@ -3866,25 +3023,16 @@ impl AnyServer { } fn on_websocket_opened(&self) { - any_server_dispatch!(self, |s| s.note_websocket_opened()); + any_server_dispatch!(self, |s| s + .active_websocket_count + .set(s.active_websocket_count.get().saturating_add(1))); } - /// Decrement the live-socket count and, when the last socket drained on - /// an already-stopped server, run the idle pass so the `JsRef` downgrade - /// (and deferred deinit) that was held back by the open sockets fires. - /// - /// Skipped while still listening (the idle pass would no-op). Re-entrance - /// during the abrupt-stop synchronous drain is handled by the - /// `deinit_running` guard inside `deinit_if_we_can` itself — gating on - /// `TERMINATED` here also blocked the post-`stop(true)` close defer that - /// must fire the downgrade when `stop` was called from inside a close - /// handler (the socket whose handler ran decrements only after `stop` - /// returns, so `stop`'s own idle pass still sees it live). fn on_websocket_closed(&self) { any_server_dispatch!(self, |s| { - if s.note_websocket_closed() && !s.has_listener() { - s.deinit_if_we_can(); - } + s.active_websocket_count + .set(s.active_websocket_count.get().saturating_sub(1)); + s.apply_phase(); }) } @@ -3903,19 +3051,22 @@ impl AnyServer { any_server_dispatch!(self, |s| s.on_pending_request()) } - /// Dispatch the user `fetch` handler: - /// un-erase the SSL bool from the tag and downcast - /// `AnyResponse` to the matching `NewAppResponse` variant. + /// Calls the `fetch` handler for an HTTP/1 request. pub(crate) fn on_request(&self, req: &mut uws_sys::Request, resp: uws::AnyResponse) { - any_server_dispatch_resp!(self, resp, |s, r| s.on_request(req, r)) + any_server_dispatch!(self, |s| NewServer::on_request::( + s.as_ctx_ptr(), + uws::AnyRequest::H1(req), + resp, + None + )) } pub(crate) fn on_request_complete(&self) { any_server_dispatch!(self, |s| s.on_request_complete()) } - pub(crate) fn on_static_request_complete(&self) { - any_server_dispatch!(self, |s| s.on_static_request_complete()) + pub(crate) fn note_request_parked(&self, parked: bool) { + any_server_dispatch!(self, |s| s.note_request_parked(parked)) } pub(crate) fn num_subscribers(&self, topic: &[u8]) -> u32 { @@ -3962,13 +3113,19 @@ impl AnyServer { global: &jsc::JSGlobalObject, method: Option, ) -> jsc::JsResult> { - let req: &mut uws_sys::Request = req; - Ok(any_server_dispatch_resp!(self, resp, |s, r| { - let Some(p) = s.prepare_js_request_context(req, r, None, CreateJsRequest::Bake, method) - else { + Ok(any_server_dispatch!(self, |s| { + let Some(p) = NewServer::prepare_js_request_context::( + s.as_ctx_ptr(), + uws::AnyRequest::H1(req), + resp, + None, + CreateJsRequest::Bake, + method, + None, + ) else { return Ok(None); }; - Some(p.save(global, req, r)) + Some(p.save(global, req, resp)) })) } @@ -3981,8 +3138,8 @@ impl AnyServer { callback: jsc::JSValue, extra_args: [jsc::JSValue; EXTRA_ARG_COUNT], ) { - any_server_dispatch_resp!(self, resp, |s, r| { - s.on_saved_request(req, r, callback, extra_args) + any_server_dispatch!(self, |s| { + s.on_saved_request(req, resp, callback, extra_args) }) } @@ -4010,42 +3167,19 @@ impl AnyServer { path: &[u8], route: AnyRoute, method: server_config::MethodOptional, - ) -> Result<(), crate::Error> { + ) { any_server_dispatch!(self, |s| s .config .with_mut(|config| config.append_static_route(path, route, method))) } - pub(crate) fn reload_static_routes(&self) -> Result { + pub(crate) fn reload_static_routes(&self) { any_server_dispatch!(self, |s| s.reload_static_routes()) } - pub(crate) fn get_url_as_string(&self) -> Result { + pub(crate) fn get_url_as_string(&self) -> bun_core::String { any_server_dispatch!(self, |s| s.get_url_as_string()) } - - pub(crate) fn set_flags( - &self, - require_host_header: bool, - use_strict_method_validation: bool, - lenient_http_flags: u8, - http_allow_half_open: bool, - ) { - any_server_dispatch!(self, |s| s.set_flags( - require_host_header, - use_strict_method_validation, - lenient_http_flags, - http_allow_half_open, - )) - } - - pub(crate) fn set_max_http_header_size(&self, max_header_size: u64) { - any_server_dispatch!(self, |s| s.set_max_http_header_size(max_header_size)) - } - - pub(crate) fn set_max_headers_count(&self, max_headers_count: u32) { - any_server_dispatch!(self, |s| s.set_max_headers_count(max_headers_count)) - } } // ─── http_server_agent ─────────────────────────────────────────────────────── @@ -4066,7 +3200,7 @@ pub(crate) mod http_server_agent { let Some(agent) = this.agent else { return }; this.next_server_id = DebuggerId::init(this.next_server_id.get() + 1); instance.set_inspector_server_id(this.next_server_id); - let url = bun_core::handle_oom(instance.get_url_as_string()); + let url = instance.get_url_as_string(); // SAFETY: `agent` is a non-null C++ InspectorHTTPServerAgent handle // (set via `Bun__HTTPServerAgent__setEnabled`); `vm()` is the live @@ -4179,34 +3313,7 @@ pub(crate) enum SavedRequestUnion<'a> { Saved(SavedRequest), } -// ─── schedule_deinit's tasks ───────────────────────────────────────────────── -/// `schedule_deinit`'s first task, `app.close()`: same pointer as the app, one tag per `SSL`. -#[repr(transparent)] -pub(crate) struct AppCloseTask(uws_sys::NewApp); - -impl bun_event_loop::Taskable for AppCloseTask { - const TAG: bun_event_loop::TaskTag = if SSL { - bun_event_loop::task_tag::HTTPSAppClose - } else { - bun_event_loop::task_tag::HTTPAppClose - }; - /// The app goes with its server. - unsafe fn release_unrun(_: *mut Self) {} - /// Enters no context. - unsafe fn context(_: *const Self) -> bun_event_loop::ContextId { - bun_event_loop::ContextId::NONE - } -} - -impl AppCloseTask { - pub(crate) fn run(this: *mut Self) { - // S008: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. - bun_opaque::opaque_deref_mut(this.cast::>()).close(); - } -} - -/// `schedule_deinit`'s second task, `deinit()`: same pointer as the server, one tag per -/// monomorphization. +/// Runs `deinit()` outside the finalizer that can drop the last ref. Same pointer as the server, one tag per monomorphization. #[repr(transparent)] pub(crate) struct ServerDeinitTask(NewServer); @@ -4270,8 +3377,6 @@ impl ServerAllConnectionsClosedTask { /// [`Self::schedule`]; ownership is reclaimed and `this` must not be used /// after this returns. pub(crate) fn run_from_js_thread(this: *mut Self) -> JsResult<()> { - httplog!("ServerAllConnectionsClosedTask runFromJSThread"); - // SAFETY: `this` was `heap::alloc`'d in `schedule()`; reclaim // ownership. Keep the `Box` because `Self` has a `Drop` // impl, which forbids per-field moves. diff --git a/src/runtime/server/server.bind.ts b/src/runtime/server/server.bind.ts index ee3072acf226..d35cb2ad9294 100644 --- a/src/runtime/server/server.bind.ts +++ b/src/runtime/server/server.bind.ts @@ -1,7 +1,23 @@ import * as b from "../../codegen/bindgen-lib.ts"; +import { Request } from "../webcore/response.bind.ts"; export const ServerProtocol = b.enumeration(["http", "https"]); +/** The options of `Bun.serve` that are `ToBoolean` of a property. `ServerConfig::from_js` reads them between `app` and `maxRequestBodySize`. */ +export const ServeSwitches = b.dictionary( + { + reusePort: b.bool.loose.optional, + ipv6Only: b.bool.loose.default(false), + http3: b.bool.loose.default(false), + http2: b.bool.loose.default(false), + http1: b.bool.loose.default(true), + }, + { fromJS: true, allowArray: true }, +); + +/** What only `node:http` passes, next to `onNodeHTTPRequest`. */ +export const NodeHTTPServeOptions = b.dictionary({ fd: b.i32.optional }, { fromJS: true, allowArray: true }); + /** What `Bun.serve` returns. There is one class for each `NewServer`. */ function server(name: string) { return b.class({ @@ -17,7 +33,8 @@ function server(name: string) { "onError", "onNodeHTTPRequest", "onClientError", - "onConnection", + "socketHandlers", + "socketData", "wsOnOpen", "wsOnMessage", "wsOnClose", @@ -62,7 +79,8 @@ function server(name: string) { length: 2, }, requestIP: { - args: { request: b.any }, + args: { request: Request }, + expected: { request: "Expected Request object" }, returns: b.any.returning("object", "null"), capability: "js", throws: true, @@ -81,7 +99,6 @@ function server(name: string) { }, timeout: { args: { request: b.any, seconds: b.any }, - returns: b.any.returning("undefined", "null"), capability: "heap", throws: true, }, diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 9818eaaaa8c7..c7f4e946298c 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -12,25 +12,26 @@ use crate::generated_bindgen::server_body::{ BunServe__onRejectPlugins, BunServe__onResolvePlugins, }; use crate::node::types::PathLikeExt as _; +use crate::socket::listener::Connections; use crate::webcore::BlobExt; use crate::webcore::body::Value as BodyValue; use crate::webcore::fetch as Fetch; use crate::webcore::response::HeadersRef; -use crate::webcore::{ - self as WebCore, AbortSignal, AnyBlob, Blob, FetchHeaders, Request, Response, request, -}; +use crate::webcore::{AbortSignal, AnyBlob, Blob, FetchHeaders, Request, Response, request}; use ::bstr::BStr; use bun_collections::HashMap; use bun_core::{EncodedSlice, String as BunString, StringView, 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::bindgen::{self, JsHeap, JsLocal, JsScope, JsWrapper}; use bun_jsc::uuid::UUID; use bun_jsc::{ self as jsc, ArrayBuffer, GlobalRef, JSGlobalObject, JSPromise, JSValue, JsError, JsResult, - Node, StringJsc as _, VirtualMachine, host_fn, + Node, StringJsc as _, VirtualMachine, }; use bun_paths as paths; use bun_ptr::{AsCtxPtr, RefPtr}; @@ -61,414 +62,27 @@ pub(super) use super::html_bundle::{self as html_bundle, HTMLBundle}; pub(super) use super::any_request_context::AnyRequestContext; pub(super) use super::file_route::FileRoute; pub(super) use super::node_http_response::NodeHTTPResponse; -pub(super) use super::request_context::{ - DeferDeinitFlag, RequestContext as NewRequestContext, UpgradeState, -}; +pub(super) use super::request_context::{DeferDeinitFlag, UpgradeState}; pub(super) use super::server_config::{self as server_config, ServerConfig}; pub(super) use super::server_web_socket::ServerWebSocket; pub(super) use super::static_route::StaticRoute; -// ─── RequestCtx trait ──────────────────────────────────────────────────────── -// NOTE: Stable Rust has no inherent -// associated types, so the per-monomorphization request handle type is -// surfaced via this local trait. Only `IS_MUX` is consumed for control flow; -// `Req` is erased to `c_void` to match `super::request_context::Req`. The -// response handle is a separate generic (`R: RespLike`) at each dispatch -// entry point: the MUX instantiation serves both `h2::Response` and -// `h3::Response`. -trait RequestCtx: super::any_request_context::CtxKind { - type Req: ReqLike; - const IS_MUX: bool; -} -impl RequestCtx - for NewRequestContext -where - NewRequestContext: super::any_request_context::CtxKind, -{ - type Req = uws_sys::Request; - const IS_MUX: bool = false; -} -impl RequestCtx - for NewRequestContext -where - NewRequestContext: super::any_request_context::CtxKind, -{ - type Req = uws_sys::h3::Request; - const IS_MUX: bool = true; -} - -/// Field/method surface needed on the generic `Ctx` so the bodies of -/// `handle_request_for` / `prepare_js_request_context_for` / `on_saved_request` -/// can be written without naming the concrete `RequestContext<_, SSL, DBG, MUX>` -/// type. Implemented via blanket impl below for every `NewRequestContext<..>`. -trait RequestCtxOps: RequestCtx { - type Server; - fn create_in( - slot: *mut Self, - server: &Self::Server, - req: &mut Self::Req, - resp: uws::AnyResponse, - should_deinit_context: Option, - method: Option, - ); - fn on_response(&self, server: &Self::Server, request_value: JSValue, response_value: JSValue); - fn deinit(&self); - fn should_render_missing(&self) -> bool; - fn render_missing(&self); - fn to_async(&self, req: &mut Self::Req, request_object: &mut Request); - fn ctx_method(&self) -> http::Method; - fn set_defer_deinit(&self, flag: Option); - fn set_request_body(&self, body: Option); - #[allow( - clippy::mut_from_ref, - reason = "the body slot is a separate pooled allocation, not a field of *self (R-2)" - )] - fn request_body_mut(&self) -> Option<&mut BodyValue>; - fn set_signal(&self, sig: *mut AbortSignal); - fn set_request_weakref(&self, req: *mut Request); - fn clear_req(&self); - fn set_is_web_browser_navigation(&self, v: bool); - fn set_request_body_content_len(&self, len: usize); - fn set_is_transfer_encoding(&self, v: bool); - fn set_is_waiting_for_request_body(&self, v: bool); - fn arm_on_data(&self, resp: uws::AnyResponse); - // body-streaming callback hooks (type-erased, stored on `Body::PendingValue`). - // `this` must be a live `*mut Self::RequestCtx` cast to `*mut c_void`. - fn on_start_buffering_callback(this: NonNull); - fn on_start_streaming_request_body_callback(this: NonNull) -> WebCore::DrainResult; - fn on_request_body_readable_stream_available( - this: NonNull, - global_this: &JSGlobalObject, - readable: WebCore::ReadableStream, - ); -} - -impl RequestCtxOps - for NewRequestContext -where - Self: RequestCtx, - ThisServer: super::ServerLike + 'static, -{ - type Server = ThisServer; - #[inline] - fn create_in( - slot: *mut Self, - server: &ThisServer, - req: &mut Self::Req, - any_resp: uws::AnyResponse, - should_deinit_context: Option, - method: Option, - ) { - // SAFETY: `slot` points at a fresh HiveArray pool entry; treat as - // MaybeUninit for in-place construction — `&mut` scoped to this call. - Self::create( - unsafe { &mut *slot.cast::>() }, - server, - std::ptr::from_mut(req).cast(), - any_resp, - should_deinit_context, - method, - ); - } - #[inline] - fn on_response(&self, server: &ThisServer, rq: JSValue, rv: JSValue) { - Self::on_response(self, server, rq, rv) - } - #[inline] - fn deinit(&self) { - Self::deinit(self) - } - #[inline] - fn should_render_missing(&self) -> bool { - Self::should_render_missing(self) - } - #[inline] - fn render_missing(&self) { - Self::render_missing(self) - } - #[inline] - fn to_async(&self, req: &mut Self::Req, ro: &mut Request) { - Self::to_async(self, std::ptr::from_mut(req).cast(), ro) - } - #[inline] - fn ctx_method(&self) -> http::Method { - self.method - } - #[inline] - fn set_defer_deinit(&self, flag: Option) { - self.defer_deinit_until_callback_completes.set(flag) - } - #[inline] - fn set_request_body(&self, body: Option) { - self.request_body.set(body) - } - #[inline] - fn request_body_mut(&self) -> Option<&mut BodyValue> { - // SAFETY: R-2 invariant — slot shared with `Request.body`, never - // `&mut`-borrowed concurrently (single-threaded event loop). - self.request_body - .get() - .as_ref() - .map(|h| unsafe { &mut (*h.as_ptr()).value }) - } - #[inline] - fn set_signal(&self, sig: *mut AbortSignal) { - // `AbortSignal::new` returns a raw +1 ref to a C++-refcounted opaque; - // `RequestContext.signal` stores it as `Option>` - // and pairs the unref in RequestContext cleanup (`shim::signal_release`, - // which drops both the pending-activity count and the intrusive ref). - self.signal.set(NonNull::new(sig)); - } - #[inline] - fn set_request_weakref(&self, req: *mut Request) { - // SAFETY: `req` is a freshly-boxed Request (live for the request - // duration) still carrying its `heap::into_raw` provenance. - self.request_weakref - .set(unsafe { bun_ptr::WeakPtr::::init_ref(req) }); - } - #[inline] - fn clear_req(&self) { - self.req.set(None) - } - #[inline] - fn set_is_web_browser_navigation(&self, v: bool) { - self.flags.set_is_web_browser_navigation(v) - } - #[inline] - fn set_request_body_content_len(&self, len: usize) { - self.request_body_content_len.set(len) - } - #[inline] - fn set_is_transfer_encoding(&self, v: bool) { - self.flags.set_is_transfer_encoding(v) - } - #[inline] - fn set_is_waiting_for_request_body(&self, v: bool) { - self.flags.set_is_waiting_for_request_body(v) - } - #[inline] - fn arm_on_data(&self, resp: uws::AnyResponse) { - fn handler( - ctx: *mut NewRequestContext, - chunk: &[u8], - last: bool, - ) where - S: super::ServerLike + 'static, - { - NewRequestContext::::on_buffered_body_chunk(ctx, chunk, last); - } - resp.on_data(handler::, self.as_ctx_ptr()); - } - #[inline] - fn on_start_buffering_callback(this: NonNull) { - Self::on_start_buffering_callback(this) - } - #[inline] - fn on_start_streaming_request_body_callback(this: NonNull) -> WebCore::DrainResult { - Self::on_start_streaming_request_body_callback(this) - } - #[inline] - fn on_request_body_readable_stream_available( - this: NonNull, - global_this: &JSGlobalObject, - readable: WebCore::ReadableStream, - ) { - Self::on_request_body_readable_stream_available(this, global_this, readable) - } -} - -// NOTE: local request/response traits so generic `Ctx::Req` / `R: RespLike` -// call sites can dispatch to any uWS HTTP/1, HTTP/2 or HTTP/3 handle type without -// touching `bun_uws_sys`. Only the surface `prepare_js_request_context_for` -// actually needs is exposed. -trait ReqLike { - fn header(&mut self, name: &[u8]) -> Option<&[u8]>; - /// Whether the transport frames the body with a Transfer-Encoding header. - /// This is the parser's verdict, not a lookup of the first header field. - fn has_transfer_encoding(&mut self) -> bool; - fn method(&mut self) -> &[u8]; - fn url(&mut self) -> &[u8]; - fn set_yield(&mut self, y: bool); -} -impl ReqLike for uws_sys::Request { - #[inline] - fn header(&mut self, name: &[u8]) -> Option<&[u8]> { - uws_sys::Request::header(self, name) - } - #[inline] - fn has_transfer_encoding(&mut self) -> bool { - uws_sys::Request::has_transfer_encoding(self) - } - #[inline] - fn method(&mut self) -> &[u8] { - uws_sys::Request::method(self) - } - #[inline] - fn url(&mut self) -> &[u8] { - uws_sys::Request::url(self) - } - #[inline] - fn set_yield(&mut self, y: bool) { - uws_sys::Request::set_yield(self, y) - } -} -impl ReqLike for uws_sys::h3::Request { - #[inline] - fn header(&mut self, name: &[u8]) -> Option<&[u8]> { - uws_sys::h3::Request::header(self, name) - } - /// HTTP/3 has no transfer codings (RFC 9114 4.2): the body ends at the - /// QUIC stream FIN, and a request that carries the header is rejected - /// before this is consulted. - #[inline] - fn has_transfer_encoding(&mut self) -> bool { - false - } - #[inline] - fn method(&mut self) -> &[u8] { - uws_sys::h3::Request::method(self) - } - #[inline] - fn url(&mut self) -> &[u8] { - uws_sys::h3::Request::url(self) - } - #[inline] - fn set_yield(&mut self, y: bool) { - uws_sys::h3::Request::set_yield(self, y) - } -} - -pub(super) trait RespLike { - const IS_MUX: bool; - fn write_status(&mut self, status: &[u8]); - fn end_without_body(&mut self, close_connection: bool); - fn timeout(&mut self, seconds: u8); - fn on_timeout_warn(&mut self, ud: *mut c_void); - fn to_any_response(&mut self) -> uws::AnyResponse; - /// HTTP/2 only: END_STREAM on the HEADERS frame, or `content-length: 0`. - fn request_body_ended(&mut self) -> bool; -} -impl RespLike for uws_sys::NewAppResponse { - const IS_MUX: bool = false; - #[inline] - fn request_body_ended(&mut self) -> bool { - false - } - #[inline] - fn write_status(&mut self, s: &[u8]) { - uws_sys::NewAppResponse::::write_status(self, s) - } - #[inline] - fn end_without_body(&mut self, c: bool) { - uws_sys::NewAppResponse::::end_without_body(self, c) - } - #[inline] - fn timeout(&mut self, s: u8) { - uws_sys::NewAppResponse::::timeout(self, s) - } - #[inline] - fn on_timeout_warn(&mut self, ud: *mut c_void) { - // The dev-mode idle-timeout warning ignores both args; the user-data - // pointer is an opaque sentinel (any non-null value satisfies uWS). - uws_sys::NewAppResponse::::on_timeout( - self, - |_: *mut c_void, _: &mut uws_sys::NewAppResponse| on_timeout_for_idle_warn(), - ud, - ); - } - #[inline] - fn to_any_response(&mut self) -> uws::AnyResponse { - // SAFETY: NewAppResponse/NewAppResponse are the only two - // monomorphizations; cast through the matching `From` arm. The const - // bool is checked at compile time so only one branch is reachable. - if SSL { - uws::AnyResponse::from( - std::ptr::from_mut::(self).cast::>(), - ) - } else { - uws::AnyResponse::from( - std::ptr::from_mut::(self).cast::>(), - ) - } - } -} -impl RespLike for uws_sys::h3::Response { - const IS_MUX: bool = true; - /// The QUIC FIN is only seen by a read after dispatch. - #[inline] - fn request_body_ended(&mut self) -> bool { - false - } - #[inline] - fn write_status(&mut self, s: &[u8]) { - uws_sys::h3::Response::write_status(self, s) - } - #[inline] - fn end_without_body(&mut self, c: bool) { - uws_sys::h3::Response::end_without_body(self, c) - } - #[inline] - fn timeout(&mut self, s: u8) { - uws_sys::h3::Response::timeout(self, s) - } - #[inline] - fn on_timeout_warn(&mut self, ud: *mut c_void) { - uws_sys::h3::Response::on_timeout( - self, - |_: &mut c_void, _: &mut uws_sys::h3::Response| on_timeout_for_idle_warn(), - ud, - ); - } - #[inline] - fn to_any_response(&mut self) -> uws::AnyResponse { - uws::AnyResponse::from(std::ptr::from_mut::(self)) - } -} -impl RespLike for uws_sys::h2::Response { - const IS_MUX: bool = true; - #[inline] - fn request_body_ended(&mut self) -> bool { - uws_sys::h2::Response::request_body_ended(self) - } - #[inline] - fn write_status(&mut self, s: &[u8]) { - uws_sys::h2::Response::write_status(self, s) - } - #[inline] - fn end_without_body(&mut self, c: bool) { - uws_sys::h2::Response::end_without_body(self, c) - } - #[inline] - fn timeout(&mut self, s: u8) { - uws_sys::h2::Response::timeout(self, s) - } - #[inline] - fn on_timeout_warn(&mut self, ud: *mut c_void) { - uws_sys::h2::Response::on_timeout( - self, - |_: &mut c_void, _: &mut uws_sys::h2::Response| on_timeout_for_idle_warn(), - ud, - ); - } - #[inline] - fn to_any_response(&mut self) -> uws::AnyResponse { - uws::AnyResponse::from(std::ptr::from_mut::(self)) - } -} - /// Answer a request that arrived after `finalize()` set the wrapper's /// `JsRef` to `Finalized` (idle keep-alive sockets aren't counted in /// `pending_requests`, so `self` can outlive the wrapper between the /// finalizer and the next-tick `schedule_deinit`). 503 instead of /// dispatching into a dead handler shadow. One helper so every dispatch /// trampoline gets the same guard. H1 closes the connection; H2/H3 end only -/// this stream (`!R::IS_MUX`) so sibling streams on the same connection +/// this stream so sibling streams on the same connection /// survive — same per-protocol close treatment as the other reject fast /// paths. #[inline] -pub(super) fn respond_stopped_503(resp: &mut R) { +pub(super) fn respond_stopped_503(resp: uws::AnyResponse) { resp.write_status(b"503 Service Unavailable"); - resp.end_without_body(!R::IS_MUX); + resp.end_without_body(matches!( + resp, + uws::AnyResponse::SSL(_) | uws::AnyResponse::TCP(_) + )); } /// RFC 6455 §4.1: |Sec-WebSocket-Key| is the base64 encoding of a 16-byte @@ -483,11 +97,6 @@ fn is_valid_sec_websocket_key(key: &[u8]) -> bool { .all(|&c| c.is_ascii_alphanumeric() || c == b'+' || c == b'/') } -type ServerRequestContext = - NewRequestContext, SSL, DEBUG, false>; -type ServerMuxRequestContext = - NewRequestContext, SSL, DEBUG, true>; - // ─── BunInfo (moved from bun_core::Global) ─────────────────────────────────── // `generate()` builds the JSON AST by hand: an `E.Object` with // `bun_version` (string) + `platform` (nested `E.Object` of `os`/`arch`/ @@ -1249,7 +858,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() { @@ -1285,95 +894,79 @@ impl UpgradeOptions { } } -/// Per-process latch for the dev-mode idle-timeout warning. The -/// warning is gated on `DEBUG && !silent` and only fires once globally, so a -/// single shared `AtomicBool` matches user-visible behavior. -#[inline] -fn did_send_idletimeout_warning_once() -> &'static core::sync::atomic::AtomicBool { - static FLAG: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); - &FLAG +/// Set once the development-mode idle timeout warning was printed, by any server of the process. +static IDLE_TIMEOUT_WARNED: core::sync::atomic::AtomicBool = + core::sync::atomic::AtomicBool::new(false); + +/// uWS skips a timeout handler whose user data is null. The handler ignores it. +fn idle_timeout_warning_user_data() -> *mut c_void { + IDLE_TIMEOUT_WARNED.as_ptr().cast::() } -/// Emits the once-only dev-mode -/// warning. Factored out as a free fn so the `RespLike::on_timeout_warn` -/// closures (which cannot name `NewServer`) can call it. -fn on_timeout_for_idle_warn() { - if !did_send_idletimeout_warning_once().swap(true, core::sync::atomic::Ordering::Relaxed) - && !crate::cli::Command::get().debug.silent +fn warn_about_idle_timeout_once() { + if !crate::cli::Command::get().debug.silent + && !IDLE_TIMEOUT_WARNED.swap(true, core::sync::atomic::Ordering::Relaxed) { - bun_core::pretty_errorln!( - "[Bun.serve]: request timed out after 10 seconds. Pass `idleTimeout` to configure." + bun_core::warn!( + "Bun.serve() timed out a request after 10 seconds. Pass `idleTimeout` to configure." ); - Output::flush(); } } // ─── NewServer ─────────────────────────────────────────────────────────────── // ─── NewServer (canonical type lives in mod.rs) ────────────────────────────── -// NOTE (unification): the struct, `ServerFlags`, `UserRoute`, +// NOTE (unification): the struct, `UserRoute`, // `CreateJsRequest`, `PreparedRequest`, `SavedRequest`, `SavedRequestUnion`, // `ServerAllConnectionsClosedTask`, `AnyServer` and the four type aliases are // defined once in `super` (mod.rs). This file contributes additional inherent // methods on the same type — there is no separate Phase-A struct. pub(super) use super::{ - CreateJsRequest, DebugHTTPSServer, DebugHTTPServer, HTTPSServer, HTTPServer, NewServer, - ServerFlags, UserRoute, + CreateJsRequest, DetachRequestOnDrop, NewServer, PreparedRequest, ServerRequestContext, + UserRoute, }; -/// Generic over the -/// per-transport `RequestContext` so the same body serves HTTP/1 and HTTP/3. -/// `super::PreparedRequest` is the HTTP/1-concrete instantiation -/// used by the bake/saved-request path; the generic form here is only reached -/// from the `_for` dispatch helpers below. -pub(crate) struct PreparedRequestFor { - pub(crate) js_request: JSValue, - pub(crate) request_object: *mut Request, - pub ctx: *mut Ctx, -} - -// `WebSocketUpgradeServer` so `ServerWebSocket::behavior::` and -// `app.ws(...)` accept `*mut Self` / `*mut UserRoute<..>` as the upgrade ctx. impl uws_sys::web_socket::WebSocketUpgradeServer for NewServer -where - // NOTE: see the bounded `impl NewServer` below for why these are - // spelled out — `on_web_socket_upgrade` lives in that impl. - NewRequestContext: super::request_context::RequestContextHostFns, - NewRequestContext: super::request_context::RequestContextHostFns, { + /// `this` is the user data registered with `app.ws(..)`: a `*mut UserRoute` for `id == 1`, a `*mut Self` for `id == 0`. unsafe fn on_websocket_upgrade( this: *mut Self, res: *mut uws_sys::NewAppResponse, req: &mut uws_sys::Request, - context: &mut WebSocketUpgradeContext, + upgrade_ctx: &mut WebSocketUpgradeContext, id: usize, ) { - // S008: `Response` is a ZST opaque — safe `*mut → &mut` deref. - // SAFETY: forwarded raw — `this` is only dereferenced after the `id` - // dispatch inside `on_web_socket_upgrade`. - unsafe { - Self::on_web_socket_upgrade(this, bun_opaque::opaque_deref_mut(res), req, context, id) - }; + let (req, resp) = ( + uws::AnyRequest::H1(req), + super::any_response_from::(res), + ); + if id == 1 { + // SAFETY: fn contract. No `&Self` was made from the pointer. + let user_route = unsafe { &*this.cast::>() }; + Self::on_user_route_request::(user_route, req, resp, Some(upgrade_ctx)); + return; + } + debug_assert!(id == 0); + // SAFETY: fn contract. + if unsafe { &*this }.config.get().on_request.is_empty() { + // Only a handler can call `server.upgrade()`. + resp.write_status(b"403 Forbidden"); + resp.end_without_body(true); + return; + } + Self::on_request::(this, req, resp, Some(upgrade_ctx)); } } impl NewServer { /// Construct the cross-module `super::AnyServer` back-reference. Routes /// (StaticRoute/FileRoute/HTMLBundle) store this so they can call back - /// into `on_pending_request` / `on_static_request_complete`. + /// into `on_pending_request` / `on_request_complete`. #[inline] fn as_any_server(&self) -> super::AnyServer { super::AnyServer::from(std::ptr::from_ref::(self)) } - /// Shared `&VirtualMachine` accessor. - #[inline(always)] - fn vm_ref(&self) -> &jsc::virtual_machine::VirtualMachine { - // `vm` is a `BackRef` (per-thread singleton, set in - // `init()`); safe `Deref` projection. - self.vm.get() - } - /// Shared `&JSGlobalObject` accessor (struct stores it as `*const`). #[inline(always)] fn global(&self) -> GlobalRef { @@ -1416,14 +1009,129 @@ impl NewServer { } } } + + /// node:http 'clientError'. `callback(socketHandle, errorCode, rawPacket)`. + pub(crate) fn set_on_client_error(&self, global: &JSGlobalObject, callback: JSValue) { + let (Some(server_js), Some(app)) = (self.js_value_for_dispatch(), self.app.get()) else { + return; + }; + let mut on_clienterror = callback; + super::wrap_handler_slot( + &mut on_clienterror, + server_js, + global, + Self::js_gc_on_client_error_set, + ); + self.on_clienterror.set(on_clienterror); + extern "C" fn thunk( + user_data: *mut c_void, + _ssl: c_int, + socket: *mut uws_sys::us_socket_t, + error_code: u8, + raw_packet: *mut u8, + raw_packet_len: c_int, + ) { + // SAFETY: `user_data` is the server registered below, which outlives its connections. Shared, because the callback re-enters JS. + let this = unsafe { &*user_data.cast::>() }; + let packet: &[u8] = if raw_packet_len > 0 { + // SAFETY: uWS guarantees `raw_packet` points to `raw_packet_len` readable bytes when `raw_packet_len > 0`. + unsafe { bun_core::ffi::slice(raw_packet, raw_packet_len as usize) } + } else { + &[] + }; + crate::dispatch::fold(this.on_client_error_callback( + bun_opaque::opaque_deref_mut(socket), + error_code, + packet, + )); + } + bun_opaque::opaque_deref_mut(app) + .on_client_error(thunk::, self.as_ctx_ptr().cast::()); + } + + pub(crate) fn on_client_error_callback( + &self, + socket: &mut uws::Socket, + error_code: u8, + raw_packet: &[u8], + ) -> JsResult<()> { + if self.js_value_for_dispatch().is_none() { + return Ok(()); + } + let callback = self.on_clienterror.get(); + if callback.is_empty() { + return Ok(()); + } + { + let is_ssl = SSL; + let global = self.global(); + let node_socket = jsc::from_js_host_call(&global, || { + Bun__getOrCreateNodeHTTPServerSocket( + is_ssl, + std::ptr::from_mut(socket).cast::(), + &global, + self.node_socket_config_ptr(), + ) + })?; + if node_socket.is_undefined_or_null() { + return Ok(()); + } + + let error_code_value = JSValue::js_number(error_code as f64); + let raw_packet_value = ArrayBuffer::create_buffer(&global, raw_packet)?; + // 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, error_code_value, raw_packet_value], + )?; + } + Ok(()) + } + + /// node:http: makes the connection's socket handle at TCP accept. node:net's `open` handler emits 'connection'. + fn on_accept(&self, socket: *mut c_void) -> JsResult<()> { + if self.js_value_for_dispatch().is_none() { + return Ok(()); + } + let global = self.global(); + // 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()) }; + jsc::from_js_host_call(&global, || { + Bun__getOrCreateNodeHTTPServerSocket( + SSL, + socket, + &global, + self.node_socket_config_ptr(), + ) + })?; + Ok(()) + } +} + +fn request_ip(config: &ServerConfig, cx: &mut JsScope<'_>, request: &Request) -> JsResult { + if config.address.is_unix() { + return Ok(JSValue::NULL); + } + let Some(info) = request.request_context.get_remote_socket_info() else { + return Ok(JSValue::NULL); + }; + crate::socket::socket_address::SocketAddress::create_dto( + cx.global(), + &info.ip, + u16::try_from(info.port).expect("int cast"), + info.is_ipv6, + ) } fn set_request_timeout<'a>( - address: &server_config::Address, heap: &JsHeap<'a>, request: JsLocal<'a>, seconds: JsLocal<'a>, -) -> bindgen::Result<'a, JSValue> { +) -> bindgen::Result<'a, ()> { let (request, seconds) = (request.to_js(), seconds.to_js()); if request.is_undefined_or_null() { @@ -1433,10 +1141,6 @@ fn set_request_timeout<'a>( )); } - if address.is_unix() { - return Ok(JSValue::NULL); - } - if !seconds.is_number() { return Err(heap.error(format_args!("timeout() requires a number"))); } @@ -1450,22 +1154,10 @@ fn set_request_timeout<'a>( return Err(heap.invalid_arguments(format_args!("timeout() requires a Request object"))); } - Ok(JSValue::UNDEFINED) + Ok(()) } -impl NewServer -where - // NOTE (const-generic dispatch): `RequestContextHostFns` (the host-fn - // export table) is blanket-impl'd per (SSL,DBG,H3) tuple in - // `RequestContext.rs` for `ThisServer: ServerLike`; restating it here lets - // method bodies name ` as RequestContextHostFns>::ON_*` - // without each method repeating the bound. - NewRequestContext: super::request_context::RequestContextHostFns, - NewRequestContext: super::request_context::RequestContextHostFns, -{ - // NOTE: there is no `getPluginsAsync` method or `AnyServer` dispatcher; - // the live HTMLBundle path goes through `get_or_load_plugins`. - +impl NewServer { /// Returns: /// - .ready if no plugin has to be loaded /// - .err if there is a cached failure. Currently, this requires restarting the entire server. @@ -1563,27 +1255,8 @@ where Ok(send_status_to_js(status, bytes.len(), "publish")) } - pub(crate) fn request_ip( - &self, - cx: &mut JsScope<'_>, - request: JsLocal<'_>, - ) -> JsResult { - let request = request.to_js().as_class_ref::().ok_or_else(|| { - cx.global() - .throw_invalid_arguments(format_args!("Expected Request object")) - })?; - if self.config.get().address.is_unix() { - return Ok(JSValue::NULL); - } - let Some(info) = request.request_context.get_remote_socket_info() else { - return Ok(JSValue::NULL); - }; - crate::socket::socket_address::SocketAddress::create_dto( - &self.global(), - &info.ip, - u16::try_from(info.port).expect("int cast"), - info.is_ipv6, - ) + pub(crate) fn request_ip(&self, cx: &mut JsScope<'_>, request: &Request) -> JsResult { + request_ip(self.config.get(), cx, request) } pub(crate) fn timeout<'a>( @@ -1591,8 +1264,8 @@ where heap: &JsHeap<'a>, request: JsLocal<'a>, seconds: JsLocal<'a>, - ) -> bindgen::Result<'a, JSValue> { - set_request_timeout(&self.config.get().address, heap, request, seconds) + ) -> bindgen::Result<'a, ()> { + set_request_timeout(heap, request, seconds) } pub(crate) fn upgrade( @@ -1613,22 +1286,7 @@ where ))); } - if self.flags.get().contains(ServerFlags::TERMINATED) { - return Ok(false); - } - - // `deinit_if_we_can` only clears `handler.server` once every - // connection has closed, so this is defensive for the `Finalized` - // window between the wrapper's `finalize()` and the next-tick - // `schedule_deinit`: accepting an upgrade there would create a - // `ServerWebSocket` whose open/close accounting is skipped. - if self - .config - .get() - .websocket - .as_ref() - .is_some_and(|ws| ws.handler.server.is_none()) - { + if self.terminated.get() { return Ok(false); } @@ -1654,7 +1312,7 @@ where let Some(upgrader_ptr) = request .request_context - .get::>() + .get::>() else { return Ok(false); }; @@ -1821,13 +1479,16 @@ where /// transfers to `self.config`; the caller's `new_config` is left in a /// valid-but-emptied state and its `Drop` frees whatever was *not* taken. /// Any `Some(ws)` is adopted unconditionally, as on the initial serve. - pub(crate) fn on_reload_from_zig( + pub(crate) fn apply_reload( &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: `reload` is only reachable while the server is running // (`self.app` set in `listen()`). self.app_mut().clear_routes(); @@ -1854,7 +1515,7 @@ where .with_mut(|config| config.on_request = new_config.on_request); } // Swap on any change, *including* clearing to ZERO when the reload - // config omits the handler, so subsequent `on_web_socket_upgrade` / + // config omits the handler, so subsequent `on_websocket_upgrade` / // `set_routes` stop routing through the node:http path. // // Never the other direction: a server that was not created as a @@ -1905,11 +1566,31 @@ where } }); - // NOTE: `Vec` impls `Drop`, so - // a move-assign frees the old `static_routes`. - self.config.with_mut(|config| { - config.static_routes = core::mem::take(&mut new_config.static_routes); - config.negative_routes = core::mem::take(&mut new_config.negative_routes); + // 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.get().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 { + 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.with_mut(|config| { + config.static_routes = core::mem::take(&mut new_config.static_routes); + config.negative_routes = core::mem::take(&mut new_config.negative_routes); }); if new_config.had_routes_object { @@ -1933,17 +1614,40 @@ 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( + &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.get().is_some() + || !self.config.get().development.is_hmr_enabled() + || self.h2_app.get().is_some() + || self.h3_app.get().is_some() + || !self.has_listener() + || !self + .config + .with_mut(|config| config.take_dev_server_options_from(new_config, global)) + { + return Ok(()); + } + self.init_dev_server().inspect_err(|_| { + self.config + .with_mut(|config| config.drop_dev_server_options()); + }) } - pub(crate) fn reload_static_routes(&self) -> Result { + pub(crate) fn reload_static_routes(&self) { if self.app.get().is_none() { // Static routes will get cleaned up when the server is stopped - return Ok(false); + return; } - self.config.with_mut(|config| { - *config = config.clone_for_reloading_static_routes()?; - Ok::<(), crate::Error>(()) - })?; + self.config + .with_mut(|config| config.normalize_static_routes_list()); self.app_mut().clear_routes(); for_each_mux_app!(self, |mux| { mux.clear_routes(); @@ -1954,7 +1658,6 @@ where Self::js_gc_route_list_set(server_js_value, &self.global(), route_list_value); } } - Ok(true) } pub(crate) fn reload<'a>( @@ -1971,18 +1674,17 @@ where server_config::FromJSOptions { allow_bake_config: false, may_start_development_server: false, - is_fetch_required: true, previous_fetch: !self.config.get().on_request.is_empty_or_undefined_or_null(), previous_routes: !self.user_routes.get().is_empty(), }, )?; - // `on_reload_from_zig` moves `new_config.websocket` into the unscanned + // `apply_reload` moves `new_config.websocket` into the unscanned // `self.config` heap box before `write_ws_handler_slots` roots the 7 // 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.apply_reload(&mut new_config, global)?; Ok(this_value) } @@ -2007,15 +1709,27 @@ where } let mut headers: Option = None; - let mut method = Method::GET; + let mut method = OwnedMethod::from(Method::GET); let input = input.to_js(); let mut body = BodyValue::Null; // TODO: set Host header // TODO: set User-Agent header // TODO: unify with fetch() implementation. - let request: *mut Request = if input.is_string() { - let url_utf8 = input.to_utf8(global)?; + let request_arg = input + .is_object() + .then(|| input.as_class_ref::()) + .flatten(); + let url_arg = if input.is_string() { + Some(input.to_utf8(global)?) + } else if input.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(input, global)?; + (href.tag() != bun_core::Tag::Dead).then(|| href.into_utf8()) + } else { + None + }; + let request: *mut Request = if let Some(url_utf8) = url_arg { let temp_url_str = url_utf8.slice(); if temp_url_str.is_empty() { @@ -2036,7 +1750,8 @@ where // duping it. let owned_url_buf: std::borrow::Cow<'_, [u8]> = if url.hostname.is_empty() { std::borrow::Cow::Owned( - strings::append(&self.base_url_string_for_joining, url.pathname).into_vec(), + strings::append(self.base_url_string_for_joining.get(), url.pathname) + .into_vec(), ) } else { std::borrow::Cow::Borrowed(temp_url_str) @@ -2046,21 +1761,11 @@ where let init = init.to_js(); if init.is_object() { if let Some(method_) = init.fast_get(global, jsc::BuiltinName::Method)? { - let slice_ = method_.to_utf8(global)?; - method = Method::which(slice_.slice()).unwrap_or(method); + method = method_jsc::request_method_from_js(global, method_)?; } if let Some(headers_) = init.fast_get(global, jsc::BuiltinName::Headers)? { - if let Some(headers__) = FetchHeaders::cast_(headers_, global.vm()) { - // Copy the headers like `new Request(url, { headers })` does. - // S008: `FetchHeaders` is an opaque ZST FFI handle — safe deref. - headers = bun_opaque::opaque_deref_mut(headers__.as_ptr()) - .clone_this(global)? - // SAFETY: `clone_this` returns a +1 ref. - .map(|p| unsafe { HeadersRef::adopt(p) }); - } else { - headers = HeadersRef::create_from_js(global, headers_)?; - } + headers = HeadersRef::from_init_value(global, headers_)?; } if let Some(body__) = init.fast_get(global, jsc::BuiltinName::Body)? { @@ -2083,11 +1788,7 @@ where crate::webcore::body::hive_alloc(body), method, )) - } else if let Some(request_) = input - .is_object() - .then(|| input.as_class_ref::()) - .flatten() - { + } else if let Some(request_) = request_arg { request_.clone(&global.js_thread_of_caller())? } else { let fetch_error = Fetch::fetch_type_error_string(input); @@ -2101,8 +1802,9 @@ where // SAFETY: `request` was just allocated via `heap::alloc`; its ref // transfers to the JS wrapper inside `to_js`. let request_value = unsafe { Request::to_js(request, &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( @@ -2138,17 +1840,12 @@ where } pub(crate) fn close_idle_connections(&self, _: &mut JsScope<'_>) -> usize { - if self.app.get().is_none() || self.deinit_running.get() { + if self.app.get().is_none() || self.phase_held.get() { return 0; } - // Each close reaches `on_connection_filter(-2)` synchronously; hold - // the guard so the nested call skips the idle pass; this call then runs - // it. One-shot sweep (Node semantics): busy connections are spared - // and are NOT marked to close later. - self.deinit_running.set(true); - let closed = self.app_mut().close_idle_connections(false); - self.deinit_running.set(false); - self.deinit_if_we_can(); + // One sweep, as in Node: busy connections are not marked to close later. + let closed = self.while_closing(|| self.app_mut().close_idle_connections(false)); + self.apply_phase(); closed } @@ -2157,29 +1854,23 @@ where cx: &mut JsScope<'_>, close_active_connections: JsLocal<'_>, ) -> JSValue { - let rc = self.get_all_closed_promise(cx.global()); - - let close_active_connections = close_active_connections.to_js(); - let abrupt = close_active_connections.is_boolean() && close_active_connections.to_boolean(); - // `!deinit_running`: a `server.stop()` reached from a close callback - // that an outer `stop()`'s drain fired would re-enter `stop_listening` - // under the outer frame. - if self.has_listener() - || (abrupt - && !self.flags.get().contains(ServerFlags::TERMINATED) - && !self.deinit_running.get()) - { - self.stop(abrupt); - } - - rc + let all_closed = self.get_all_closed_promise(cx.global()); + self.stop_from_js(close_active_connections.to_js() == JSValue::TRUE); + all_closed } pub(crate) fn dispose(&self, _: &mut JsScope<'_>) { - if self.has_listener() - || (!self.flags.get().contains(ServerFlags::TERMINATED) && !self.deinit_running.get()) - { - self.stop(true); + self.stop_from_js(true); + } + + fn stop_from_js(&self, abrupt: bool) { + // Under `phase_held`, an outer `stop()` or `closeIdleConnections()` is walking the sockets an abrupt stop would close. + if self.has_listener() || (abrupt && !self.terminated.get() && !self.phase_held.get()) { + self.stop(if abrupt { + Connections::Close + } else { + Connections::Keep + }); } } @@ -2215,7 +1906,7 @@ where } pub(crate) fn get_pending_web_sockets(&self) -> i32 { - (self.active_sockets_count() as u32 & 0x7FFF_FFFF) as i32 + (self.active_websocket_count.get() & 0x7FFF_FFFF) as i32 } pub(crate) fn get_address(&self, cx: &mut JsScope<'_>) -> JsResult { @@ -2268,10 +1959,7 @@ where pub(crate) fn get_url(&self, cx: &mut JsScope<'_>) -> JsResult { let global = cx.global(); - let url = self - .get_url_as_string() - .map_err(|_| global.throw_out_of_memory())?; - bun_string_jsc::to_jsdomurl(&url, global) + bun_string_jsc::to_jsdomurl(&self.get_url_as_string(), global) } pub(crate) fn get_hostname(&self, cx: &mut JsScope<'_>) -> JsResult { @@ -2314,9 +2002,10 @@ where pub(crate) fn finalize(&self) { httplog!("finalize"); self.js_value.with_mut(|js_value| js_value.finalize()); - // HTTP/3 connections are not counted, so a drained server can have a request pending. - self.hold_open_ref(); - self.deinit_if_we_can(); + debug_assert!( + self.open_ref.get().is_some() != self.is_drained(), + "the server refs itself exactly until it is drained" + ); } pub(crate) fn get_all_closed_promise(&self, global: &JSGlobalObject) -> JSValue { @@ -2331,29 +2020,43 @@ where self.all_closed_promise.get().value() } - // `notify_inspector_server_stopped` lives in the unbounded impl block - // above so the unbounded `deinit()` (mod.rs) can call it. - /// Route handler for the HTTP/2 and HTTP/3 apps (`R` = `uws::H2::Response` /// or `uws::H3::Response`); both hand us the same decoded-header request. - pub(super) fn on_mux_request(&self, req: &mut uws::H3::Request, resp: &mut R) { + pub(super) fn on_mux_request(&self, req: &mut uws::H3::Request, resp: &mut R) + where + uws::AnyResponse: From<*mut R>, + { if self.config.get().on_request.is_empty() { return Self::on_mux_404(self, req, resp); } - self.on_request_for::, _>(req, resp); + Self::on_request::( + self.as_ctx_ptr(), + uws::AnyRequest::H3(req), + uws::AnyResponse::from(std::ptr::from_mut(resp)), + None, + ); } - pub(super) fn on_mux_user_route_request( + pub(super) fn on_mux_user_route_request( user_route: &UserRoute, req: &mut uws::H3::Request, resp: &mut R, - ) { - Self::on_user_route_request_for::, _>( - user_route, req, resp, + ) where + uws::AnyResponse: From<*mut R>, + { + Self::on_user_route_request::( + user_route, + uws::AnyRequest::H3(req), + uws::AnyResponse::from(std::ptr::from_mut(resp)), + None, ); } - pub(super) fn on_mux_404(_this: &Self, _req: &mut uws::H3::Request, resp: &mut R) { + pub(super) fn on_mux_404(_this: &Self, _req: &mut uws::H3::Request, resp: &mut R) + where + uws::AnyResponse: From<*mut R>, + { + let resp = uws::AnyResponse::from(std::ptr::from_mut(resp)); resp.write_status(b"404 Not Found"); resp.end_without_body(false); } @@ -2408,308 +2111,266 @@ where resp.write_header_int(b"Age", 0); let buffer = writer.ctx.written(); resp.end(buffer, false); - self.pending_requests.set(self.pending_requests.get() - 1); + self.on_request_complete(); } - // `on_chrome_dev_tools_json_request` is defined once below (next to - // `on404`); a second copy here was a concurrent-port duplicate and has - // been removed. - - fn on_user_route_request_for, R: RespLike>( + /// Calls a handler of the `routes` option. `upgrade_ctx`: the request is a WebSocket handshake `server.upgrade()` can accept. + pub(super) fn on_user_route_request( user_route: &UserRoute, - req: &mut Ctx::Req, - resp: &mut R, + req: uws::AnyRequest, + resp: uws::AnyResponse, + upgrade_ctx: Option<&mut WebSocketUpgradeContext>, ) { - debug_assert!(!user_route.server.is_null()); - // SAFETY: `UserRoute.server` is a back-pointer to the owning server, - // which outlives every route it registered. - let server = unsafe { &*user_route.server }; - let index = user_route.id; - + let this = user_route.server; + debug_assert!(!this.is_null()); + // SAFETY: the owning server outlives every route it registered. + let server = unsafe { &*this }; let Some(server_js) = server.js_value_for_dispatch() else { respond_stopped_503(resp); return; }; let should_deinit_context = core::cell::Cell::new(false); - let Some(mut prepared) = server.prepare_js_request_context_for::( + let Some(mut prepared) = Self::prepare_js_request_context::( + this, req, resp, Some(bun_ptr::BackRef::new(&should_deinit_context)), CreateJsRequest::No, user_route.route.method.specific(), + upgrade_ctx, ) else { return; }; let _entered = server.vm().enter_event_loop_scope_without_checkpoint(); - let server_request_list = Self::js_route_list_get_cached(server_js).unwrap(); - let call_route = if Ctx::IS_MUX { + // The handler and the render of what it returns continue the script that made the server. + let _context = server.vm().enter_context(server.context.get()); + let global = server.global_this(); + let route_list = + Self::js_route_list_get_cached(server_js).expect("routeList cached value missing"); + let call_route = if MUX { Bun__ServerRouteList__callRouteH3 } else { Bun__ServerRouteList__callRoute }; - // S008: `JSGlobalObject` is an `opaque_ffi!` ZST — safe deref. - let global = bun_opaque::opaque_deref(server.global_this); - let response_value = match jsc::from_js_host_call(global, || { + let response_value = jsc::from_js_host_call(global, || { call_route( global, - index, + user_route.id, prepared.request_object, server_js, - server_request_list, + route_list, &mut prepared.js_request, - std::ptr::from_mut(req).cast::(), + req.as_ptr(), ) - }) { - Ok(v) => v, - Err(err) => global.take_exception(err), - }; - - server.handle_request_for::(&should_deinit_context, &prepared, req, response_value); - } - - fn handle_request_for>( - &self, - should_deinit_context: &core::cell::Cell, - prepared: &PreparedRequestFor, - req: &mut Ctx::Req, - response_value: JSValue, - ) { - let request_object_ptr: *mut Request = prepared.request_object; - let detach_ptr = request_object_ptr; - scopeguard::defer! { - // uWS request will not live longer than this function - // SAFETY: request_object outlives this stack frame (boxed on the request). - unsafe { (*detach_ptr).request_context.detach_request() }; - } - - // SAFETY: `prepared.ctx` was allocated by `prepare_js_request_context_for` - // for this frame and cannot be freed while `defer_deinit_...` is set (set - // by the caller until cleared below). - let ctx = unsafe { &*prepared.ctx }; - RequestCtxOps::on_response(ctx, self, prepared.js_request, response_value); - // Reference in the stack here in case it is not for whatever reason - prepared.js_request.ensure_still_alive(); - - RequestCtxOps::set_defer_deinit(ctx, None); - - if should_deinit_context.get() { - RequestCtxOps::deinit(ctx); - return; - } - - if RequestCtxOps::should_render_missing(ctx) { - RequestCtxOps::render_missing(ctx); - return; - } + }) + .unwrap_or_else(|err| global.take_exception(err)); - // The request is asynchronous, and all information from `req` must be copied - // since the provided uws.Request will be re-used for future requests (stack allocated). - // SAFETY: `request_object_ptr` is the live heap `Request` (see above). - RequestCtxOps::to_async(ctx, req, unsafe { &mut *request_object_ptr }); + Self::handle_request(this, &should_deinit_context, &prepared, req, response_value); } - fn on_request_for, R: RespLike>( - &self, - req: &mut Ctx::Req, - resp: &mut R, + /// Calls the `fetch` handler. `upgrade_ctx` as in [`Self::on_user_route_request`]. + pub(super) fn on_request( + this: *mut Self, + req: uws::AnyRequest, + resp: uws::AnyResponse, + upgrade_ctx: Option<&mut WebSocketUpgradeContext>, ) { - let Some(js_value) = self.js_value_for_dispatch() else { + // SAFETY: `this` is the live server registered as the route's user data. + let server = unsafe { &*this }; + let Some(server_js) = server.js_value_for_dispatch() else { respond_stopped_503(resp); return; }; + let should_deinit_context = core::cell::Cell::new(false); - let Some(prepared) = self.prepare_js_request_context_for::( + let Some(prepared) = Self::prepare_js_request_context::( + this, req, resp, Some(bun_ptr::BackRef::new(&should_deinit_context)), CreateJsRequest::Yes, None, + upgrade_ctx, ) else { return; }; - let _entered = self.vm().enter_event_loop_scope_without_checkpoint(); - let on_request_fn = self.config.get().on_request; - debug_assert!(!on_request_fn.is_empty()); - - let global = self.global_this(); + let _entered = server.vm().enter_event_loop_scope_without_checkpoint(); + // As in `on_user_route_request`. + let _context = server.vm().enter_context(server.context.get()); + let on_request = server.config.get().on_request; + debug_assert!(!on_request.is_empty()); + let global = server.global_this(); let response_value = - match on_request_fn.call(global, js_value, &[prepared.js_request, js_value]) { + match on_request.call(global, server_js, &[prepared.js_request, server_js]) { Ok(v) => v, Err(err) => global.take_exception(err), }; - self.handle_request_for::(&should_deinit_context, &prepared, req, response_value); + Self::handle_request(this, &should_deinit_context, &prepared, req, response_value); } - fn prepare_js_request_context_for, R: RespLike>( - &self, - req: &mut Ctx::Req, - resp: &mut R, + /// Hands what the handler returned to the `RequestContext`, then frees it or makes the request asynchronous. + fn handle_request( + this: *mut Self, + should_deinit_context: &core::cell::Cell, + prepared: &PreparedRequest, + req: uws::AnyRequest, + response_value: JSValue, + ) { + // SAFETY: `ctx.request_weakref` keeps `request_object` alive. The uWS request does not outlive this function. + let _detach_guard = unsafe { DetachRequestOnDrop::new(prepared.request_object) }; + + // SAFETY: `ctx` can't be freed while `defer_deinit_until_callback_completes` is set, and `this` is the live server. + let (ctx, server) = unsafe { (&*prepared.ctx, &*this) }; + ctx.on_response(server, prepared.js_request, response_value); + prepared.js_request.ensure_still_alive(); + + ctx.defer_deinit_until_callback_completes.set(None); + + if should_deinit_context.get() { + ctx.deinit(); + return; + } + + if ctx.should_render_missing() { + ctx.render_missing(); + return; + } + + // uWS reuses `req` for the next request, so an asynchronous one copies what it needs. + ctx.to_async( + req.as_ptr(), + // SAFETY: live (see above), and no other borrow of the Request is outstanding. + unsafe { &mut *prepared.request_object }, + ); + } + + fn should_warn_about_idle_timeout(&self) -> bool { + DEBUG + && !self.config.get().has_idle_timeout + && !IDLE_TIMEOUT_WARNED.load(core::sync::atomic::Ordering::Relaxed) + && !crate::cli::Command::get().debug.silent + } + + /// Makes the `RequestContext` and the `Request` of a request that JavaScript answers. `None`: it was answered here. + pub(super) fn prepare_js_request_context( + this: *mut Self, + mut req: uws::AnyRequest, + resp: uws::AnyResponse, should_deinit_context: Option, create_js_request: CreateJsRequest, method: Option, - ) -> Option> { + upgrade_ctx: Option<&mut WebSocketUpgradeContext>, + ) -> Option> { + debug_assert!(match resp { + uws::AnyResponse::SSL(_) => SSL && !MUX, + uws::AnyResponse::TCP(_) => !SSL && !MUX, + uws::AnyResponse::H2(_) | uws::AnyResponse::H3(_) => MUX, + }); jsc::mark_binding!(); + // SAFETY: `this` is the live heap server registered as the callback's user data. `create` stores the raw pointer, not this borrow. + let server = unsafe { &*this }; - // We need to register the handler immediately since uSockets will not buffer. - // - // We first validate the self-reported request body length so that - // we avoid needing to worry as much about what memory to free. - // RFC 9114 §4.2: an HTTP/3 message containing transfer-encoding is - // malformed (HTTP/2 rejects it with RST_STREAM before dispatch). - if Ctx::IS_MUX { - if ReqLike::header(req, b"transfer-encoding").is_some() { - RespLike::write_status(resp, b"400 Bad Request"); - RespLike::end_without_body(resp, false); - return None; - } - } - - // Resolve once, reuse for both `has_request_body()` and the forward to - // `Ctx::create`. - let method = method.or_else(|| http::Method::which(ReqLike::method(req))); + let method = method.or_else(|| http::Method::which(req.method())); - let request_body_length: Option = 'request_body_length: { + let request_body_length: Option = if method.unwrap_or(http::Method::OPTIONS).has_request_body() { - let len: usize = 'brk: { - if let Some(content_length) = ReqLike::header(req, b"content-length") { - break 'brk bun_http_types::parse_content_length(content_length); - } - 0 - }; - - // Abort the request very early. For H2/H3 a per-request error - // is a stream error; close_connection would take down every - // sibling stream on the connection. - if len > self.config.get().max_request_body_size { - RespLike::write_status(resp, b"413 Request Entity Too Large"); - RespLike::end_without_body(resp, !Ctx::IS_MUX); + // uWS enforces `maxRequestBodySize` from here on. It has answered 413 if the Content-Length is already over it. + if !resp.accept_request_body() { return None; } + Some(req.content_length().unwrap_or(0) as usize) + } else { + None + }; - break 'request_body_length Some(len); - } - None - }; + if !MUX { + // Earlier responses of this read leave before JavaScript runs, and this one is sent when it completes. + resp.send_corked(); + resp.send_when_complete(); + } - self.on_pending_request(); + server.on_pending_request(); + req.set_yield(false); - ReqLike::set_yield(req, false); - RespLike::timeout(resp, self.config.get().idle_timeout); - - // Since we do timeouts by default, we should tell the user when - // this happens - but limit it to only warn once. - if self.should_add_timeout_handler_for_warning() { - // We need to pass it a pointer, any pointer should do. - // SAFETY: the user-data pointer is an opaque sentinel — `on_timeout_for_idle_warn` - // ignores it and reads the static directly. `AtomicBool::as_ptr` yields a `*mut` - // with interior-mutability provenance, so no `&T as *const _ as *mut _` cast is needed. - RespLike::on_timeout_warn( - resp, - did_send_idletimeout_warning_once() - .as_ptr() - .cast::(), + if server.should_warn_about_idle_timeout() { + resp.on_timeout( + |_: *mut c_void, _| warn_about_idle_timeout_once(), + idle_timeout_warning_user_data(), ); } - let any_resp = RespLike::to_any_response(resp); - // SAFETY: both allocators hand out `*mut RequestContext<_, SSL, DEBUG, _>`; the - // const-bool MUX parameter only affects associated consts/types, not layout, so - // reinterpreting the slot pointer as the caller's `Ctx` monomorphization is sound. - // - // `claim()` reserves the slot as a `HiveSlot`; `create_in` does - // `MaybeUninit::write` placement-new through the slot's stable - // address, after which `assume_init()` consumes the token. - // `RequestContext` carries the heaviest drop glue in the codebase, so - // a panic inside `create_in` now releases the slot via - // `HiveSlot::drop` without running `RequestContext::drop` on garbage. - let ctx_slot: *mut Ctx = unsafe { - if Ctx::IS_MUX { - debug_assert!( - !self.mux_request_pool.get().is_null(), - "HTTP/2 or HTTP/3 request dispatched but mux_request_pool was never allocated" - ); - let slot = (*self.mux_request_pool.get()).claim(); - Ctx::create_in( - slot.addr().as_ptr().cast(), - self, - req, - any_resp, - should_deinit_context, - method, - ); - // SAFETY: `create_in` fully initialized the slot via `MaybeUninit::write`. - slot.assume_init().as_ptr().cast() - } else { - let slot = self.request_pool.claim(); - Ctx::create_in( - slot.addr().as_ptr().cast(), - self, - req, - any_resp, - should_deinit_context, - method, - ); - // SAFETY: `create_in` fully initialized the slot via `MaybeUninit::write`. - slot.assume_init().as_ptr().cast() - } + // SAFETY: the pool is the VM's and outlives the server. `create` initializes the claimed + // slot, and a panic inside it releases the slot without dropping its contents. + let ctx_ptr: *mut ServerRequestContext = unsafe { + let slot = (*server.request_pool_for::()).claim(); + ServerRequestContext::::create( + &mut *slot + .addr() + .as_ptr() + .cast::>>(), + server, + req.as_ptr(), + resp, + should_deinit_context, + method, + ); + slot.assume_init().as_ptr() }; - // SAFETY: ctx_slot was just initialized by create_in. - let ctx = unsafe { &*ctx_slot }; + // SAFETY: initialized by `create`. + let ctx = unsafe { &*ctx_ptr }; + if let Some(upgrade_ctx) = upgrade_ctx { + ctx.upgrade_context + .set(UpgradeState::Pending(NonNull::from(upgrade_ctx))); + } - self.vm() + server + .vm() .jsc_vm() - .deprecated_report_extra_memory(core::mem::size_of::()); + .deprecated_report_extra_memory(core::mem::size_of::< + ServerRequestContext, + >()); - // Pooled body slot, ref_count = 1. let body_hive = crate::webcore::body::hive_alloc(BodyValue::Null); - // The ctx and Request each own a +1 on the - // same slot. Paired drop in `RequestContext::deinit` / `Request::destroy`. - ctx.set_request_body(Some(body_hive.clone())); + // SAFETY: the slot stays live: `ctx.request_body` and `Request.body` each hold a ref on it. + let body_value: *mut BodyValue = + unsafe { core::ptr::addr_of_mut!((*body_hive.as_ptr()).value) }; + ctx.request_body.set(Some(body_hive.clone())); - let _context = self.vm().enter_context(self.context.get()); - let signal = AbortSignal::new(&self.global()); - ctx.set_signal(signal); + let global = server.global_this(); + let _context = server.vm().enter_context(server.context.get()); + let signal = AbortSignal::new(global); + ctx.signal.set(NonNull::new(signal)); // S008: `AbortSignal` is an `opaque_ffi!` ZST — safe deref. bun_opaque::opaque_deref_mut(signal).pending_activity_ref(); - // The Request's own ref. - let signal_for_req = bun_opaque::opaque_deref_mut(signal).ref_(); + // The JS wrapper frees it, or `ctx.request_weakref` does for `CreateJsRequest::No`. let request_object_ptr: *mut Request = Request::new(Request::init( - ctx.ctx_method(), - AnyRequestContext::init(std::ptr::from_ref::(ctx)), + ctx.method, + AnyRequestContext::init(ctx_ptr), SSL, - Some(signal_for_req), + Some(bun_opaque::opaque_deref_mut(signal).ref_()), body_hive, )); - // Leak so the ctx (which outlives this stack frame) can hold the - // pointer; Request is freed via ctx.deinit's request_weakref. The weak - // handle takes the raw pointer, not a reborrow of `request_object`: - // writes through `request_object` below would otherwise invalidate it. - ctx.set_request_weakref(request_object_ptr); - // SAFETY: freshly leaked; shared view — the headers/url population - // below takes `&Request`. - let request_object: &Request = unsafe { &*request_object_ptr }; - - // The lazy `getRequest()` path that backs Request.url / .headers - // is `*uws.Request`-typed; for HTTP/2 and HTTP/3 we populate both - // eagerly so the rest of the pipeline never needs to know which - // transport delivered the bytes. - if Ctx::IS_MUX { + // SAFETY: freshly leaked from `heap::into_raw`, so it carries the allocation's provenance. + ctx.request_weakref + .set(unsafe { bun_ptr::WeakPtr::::init_ref(request_object_ptr) }); + + // `Request.url` and `.headers` read a `uws::Request` lazily. HTTP/2 and HTTP/3 have none, so both are filled in now. + if MUX { + // SAFETY: freshly leaked. What follows takes `&Request`. + let request_object: &Request = unsafe { &*request_object_ptr }; // SAFETY: create_from_h3 returns a +1-ref FetchHeaders; adopt into RAII wrapper. request_object.set_fetch_headers(Some(unsafe { crate::webcore::response::HeadersRef::adopt(FetchHeaders::create_from_h3( - std::ptr::from_mut(req).cast::(), + req.as_ptr(), )) })); - // NOTE: `ReqLike::{url,header}` both borrow `&mut req`; the - // returned slices alias the same uWS-owned header buffer. Format - // the `https://{host}` prefix while `host` is borrowed so the - // second `&mut req` borrow for `url` is unconflicted. - let prefix: Option> = ReqLike::header(req, b"host") + let prefix: Option> = req + .header(b"host") .filter(|host| Request::is_valid_host_header(host)) .map(|host| { let fmt = bun_fmt::HostFormatter { @@ -2721,7 +2382,7 @@ where let _ = write!(&mut s, "{}://{}", if SSL { "https" } else { "http" }, fmt); s }); - let path = ReqLike::url(req); + let path = req.url(); if !path.is_empty() && path[0] == b'/' { if let Some(mut s) = prefix { s.extend_from_slice(path); @@ -2740,57 +2401,58 @@ where } else { request_object.url.set(BunString::clone_utf8(path)); } - ctx.clear_req(); + ctx.req.set(None); } if DEBUG { - ctx.set_is_web_browser_navigation('brk: { - if let Some(fetch_dest) = ReqLike::header(req, b"sec-fetch-dest") { - if fetch_dest == b"document" { - break 'brk true; - } - } - false - }); + ctx.flags.set_is_web_browser_navigation( + req.header(b"sec-fetch-dest") + .is_some_and(|fetch_dest| fetch_dest == b"document"), + ); } if let Some(req_len) = request_body_length { - ctx.set_request_body_content_len(req_len); - let is_te = ReqLike::has_transfer_encoding(req); - ctx.set_is_transfer_encoding(is_te); + ctx.request_body_content_len.set(req_len); + let is_transfer_encoding = req.has_transfer_encoding(); + ctx.flags.set_is_transfer_encoding(is_transfer_encoding); // HTTP/2 and HTTP/3 frame the body by END_STREAM or QUIC FIN, not Content-Length. - if req_len > 0 || is_te || (Ctx::IS_MUX && !RespLike::request_body_ended(resp)) { - // we defer pre-allocating the body until we receive the first chunk - // that way if the client is lying about how big the body is or the client aborts - // we don't waste memory - if let Some(body) = ctx.request_body_mut() { - *body = BodyValue::Locked(crate::webcore::body::PendingValue { - task: Some(NonNull::new(ctx_slot).unwrap().cast::()), - global: self.global_this, - on_start_buffering: Some(Ctx::on_start_buffering_callback), - on_start_streaming: Some(Ctx::on_start_streaming_request_body_callback), + if req_len > 0 || is_transfer_encoding || (MUX && !resp.request_body_ended()) { + // The buffer is allocated at the first chunk, so a client that lies about the length or aborts wastes no memory. + // SAFETY: `body_value` is the hive payload initialized above. + unsafe { + *body_value = BodyValue::Locked(crate::webcore::body::PendingValue { + task: Some(NonNull::new(ctx_ptr).unwrap().cast::()), + global: std::ptr::from_ref(global), + on_start_buffering: Some( + ServerRequestContext::::on_start_buffering_callback, + ), + on_start_streaming: Some( + ServerRequestContext::::on_start_streaming_request_body_callback, + ), on_readable_stream_available: Some( - Ctx::on_request_body_readable_stream_available, + ServerRequestContext::::on_request_body_readable_stream_available, ), producer: crate::webcore::streams::SourceHandle::ServerRequestBody( - AnyRequestContext::init(ctx_slot), + AnyRequestContext::init(ctx_ptr), ), ..Default::default() }); } - ctx.set_is_waiting_for_request_body(true); - ctx.arm_on_data(any_resp); + ctx.flags.set_is_waiting_for_request_body(true); + // uSockets does not buffer, so the handler is registered before JavaScript runs. + resp.on_data( + ServerRequestContext::::on_buffered_body_chunk, + ctx_ptr, + ); } } - Some(PreparedRequestFor { + Some(PreparedRequest { js_request: match create_js_request { // SAFETY: from `Request::new` above; the wrapper adopts its ref. - CreateJsRequest::Yes => unsafe { - Request::to_js(request_object_ptr, &self.global()) - }, + CreateJsRequest::Yes => unsafe { Request::to_js(request_object_ptr, global) }, CreateJsRequest::Bake => { - match Request::to_js_for_bake(request_object_ptr, &self.global()) { + match Request::to_js_for_bake(request_object_ptr, global) { Ok(v) => v, Err(JsError::OutOfMemory) => bun_core::out_of_memory(), Err(_) => return None, @@ -2799,209 +2461,10 @@ where CreateJsRequest::No => JSValue::ZERO, }, request_object: request_object_ptr, - ctx: ctx_slot, + ctx: ctx_ptr, }) } - fn upgrade_web_socket_user_route( - this: &UserRoute, - resp: &mut uws_sys::NewAppResponse, - req: &mut uws::Request, - upgrade_ctx: &mut WebSocketUpgradeContext, - method: Option, - ) { - // BACKREF: `UserRoute.server` is set at construction from the owning - // `NewServer` (which outlives every `UserRoute` in its `user_routes` - // vec); non-null by invariant. - debug_assert!(!this.server.is_null()); - // SAFETY: `UserRoute.server` is the owning `NewServer`, set at - // construction and non-null while the route is registered. - let server = unsafe { &*this.server }; - let index = this.id; - - let Some(server_js) = server.js_value_for_dispatch() else { - respond_stopped_503(resp); - return; - }; - - let should_deinit_context = core::cell::Cell::new(false); - let Some(mut prepared) = server.prepare_js_request_context( - req, - resp, - Some(bun_ptr::BackRef::new(&should_deinit_context)), - CreateJsRequest::No, - method, - ) else { - return; - }; - // SAFETY: `prepared.ctx` is the freshly-allocated RequestContext slot. - unsafe { - (*prepared.ctx) - .upgrade_context - .set(UpgradeState::Pending(NonNull::from(upgrade_ctx))) - }; - let _entered = server.vm().enter_event_loop_scope_without_checkpoint(); - let server_request_list = Self::js_route_list_get_cached(server_js).unwrap(); - // S008: `JSGlobalObject` is an `opaque_ffi!` ZST — safe deref. - let global = bun_opaque::opaque_deref(server.global_this); - let response_value = match jsc::from_js_host_call(global, || { - Bun__ServerRouteList__callRoute( - global, - index, - prepared.request_object, - server_js, - server_request_list, - &mut prepared.js_request, - std::ptr::from_mut(req).cast::(), - ) - }) { - Ok(v) => v, - Err(err) => global.take_exception(err), - }; - - server.handle_request(&should_deinit_context, &prepared, req, response_value); - } - - /// # Safety - /// `this` is the raw user-data pointer registered with `app.ws(...)` cast - /// to `*mut Self`. Its **actual pointee type depends on `id`**: `id == 1` - /// registers a `*mut UserRoute` (mod.rs per-route ws), `id == 0` - /// registers `*mut Self` (mod.rs `/*` fallback). The receiver is therefore - /// kept raw and only dereferenced *after* dispatching on `id`, so no - /// wrong-typed `&mut Self` reference is ever materialized (which would be - /// instant UB regardless of whether it is read). - pub(crate) unsafe fn on_web_socket_upgrade( - this: *mut Self, - resp: &mut uws_sys::NewAppResponse, - req: &mut uws::Request, - upgrade_ctx: &mut WebSocketUpgradeContext, - id: usize, - ) { - jsc::mark_binding!(); - if id == 1 { - // SAFETY: for `id == 1` the registered user-data IS - // `*mut UserRoute` (mod.rs `app.ws(path, ud, 1, ..)`); - // live for the request's duration. Raw-ptr cast only — no - // intermediate `&mut Self` was ever created; shared suffices (the - // route entry is only read). - let user_route = unsafe { &*this.cast::>() }; - Self::upgrade_web_socket_user_route(user_route, resp, req, upgrade_ctx, None); - return; - } - // Access `this` as *ThisServer only if id is 0 - debug_assert!(id == 0); - // SAFETY: for `id == 0` the registered user-data IS `*mut Self` - // (mod.rs `app.ws("/*", self_ptr, 0, ..)`); live for the request's - // duration. Shared — the `on_request` call below re-enters JS, so no - // `&mut` may span it (pending-request accounting is a `Cell`). - let this = unsafe { &*this }; - let Some(server_js) = this.js_value_for_dispatch() else { - respond_stopped_503(resp); - return; - }; - if this.config.get().on_request.is_empty() { - // require fetch method to be set otherwise we dont know what route to call - // this should be the fallback in case no route is provided to upgrade - resp.write_status(b"403 Forbidden"); - resp.end_without_body(true); - return; - } - // Same as `prepare_js_request_context`, which this path does not use. - resp.send_corked(); - resp.send_when_complete(); - let _entered = this.vm().enter_event_loop_scope_without_checkpoint(); - this.on_pending_request(); - req.set_yield(false); - // `claim()` reserves a fresh slot whose `Drop` releases it on panic before init. - let ctx_slot = this.request_pool.claim(); - let should_deinit_context = core::cell::Cell::new(false); - as RequestCtxOps>::create_in( - ctx_slot.addr().as_ptr(), - this, - req, - RespLike::to_any_response(resp), - Some(bun_ptr::BackRef::new(&should_deinit_context)), - None, - ); - // SAFETY: `create_in` fully initialized the slot via `MaybeUninit::write`. - let ctx = unsafe { &*ctx_slot.assume_init().as_ptr() }; - - // Pooled body slot, ref_count = 1. - let body_hive = crate::webcore::body::hive_alloc(BodyValue::Null); - // The ctx and Request each own a +1 on the - // same slot. Paired drop in `RequestContext::deinit` / `Request::destroy`. - ctx.request_body.set(Some(body_hive.clone())); - - let _context = this.vm().enter_context(this.context.get()); - let signal = AbortSignal::new(&this.global()); - // The - // RequestContext owns one ref so aborts during the WS-upgrade fallback - // fetch path propagate. - ctx.signal.set(NonNull::new(signal)); - // S008: `AbortSignal` is an `opaque_ffi!` ZST — safe deref. - bun_opaque::opaque_deref_mut(signal).pending_activity_ref(); - // The Request's own ref. - let signal_for_req = bun_opaque::opaque_deref_mut(signal).ref_(); - let request_object_ptr: *mut Request = Request::new(Request::init( - ctx.method, - AnyRequestContext::init(std::ptr::from_ref(ctx)), - SSL, - Some(signal_for_req), - body_hive, - )); - ctx.upgrade_context - .set(UpgradeState::Pending(NonNull::from(upgrade_ctx))); - // Leaked so the ctx (which outlives this stack frame) can hold the - // pointer; freed via ctx.deinit's request_weakref. Everything below - // goes through this raw pointer rather than a `&mut Request` reborrow: - // the weak handle and the deferred `detach_request` both alias it. - // SAFETY: freshly leaked, so it carries the allocation's provenance. - ctx.request_weakref - .set(unsafe { bun_ptr::WeakPtr::::init_ref(request_object_ptr) }); - - // We keep the Request object alive for the duration of the request so that we can remove the pointer to the UWS request object. - let global = this.global(); - // SAFETY: from `Request::new` above; the wrapper adopts its ref. - let args = [ - unsafe { Request::to_js(request_object_ptr, &global) }, - server_js, - ]; - args[0].ensure_still_alive(); - - let response_value = match this.config.get().on_request.call(&global, server_js, &args) { - Ok(v) => v, - Err(err) => global.take_exception(err), - }; - // Its own copy, so the closure's capture does not pin `request_object_ptr`. - let detach_ptr = request_object_ptr; - scopeguard::defer! { - // uWS request will not live longer than this function - // SAFETY: see request_object_ptr above. - unsafe { (*detach_ptr).request_context.detach_request() }; - } - - ctx.on_response(this, args[0], response_value); - - ctx.defer_deinit_until_callback_completes.set(None); - - if should_deinit_context.get() { - ctx.deinit(); - return; - } - - if ctx.should_render_missing() { - ctx.render_missing(); - return; - } - - ctx.to_async( - std::ptr::from_mut::(req).cast::(), - // SAFETY: `request_object_ptr` is live (the ctx's weakref owns it) - // and no other borrow of the Request is outstanding here. - unsafe { &mut *request_object_ptr }, - ); - } - // https://chromium.googlesource.com/devtools/devtools-frontend/+/main/docs/ecosystem/automatic_workspace_folders.md pub(super) fn on_chrome_dev_tools_json_request( &self, @@ -3051,7 +2514,7 @@ where let first_hash_segment: [u8; 8] = 'brk: { let mut buffer = paths::path_buffer_pool::get(); // No entry module (`bun bake`): the hash of the project root below tells servers apart. - let main = self.vm_ref().main().unwrap_or_default(); + let main = self.vm().main().unwrap_or_default(); let len = main.len().min(buffer.len()); break 'brk hash(strings::copy_lowercase(&main[..len], &mut buffer[..len])) .to_ne_bytes(); @@ -3094,352 +2557,62 @@ where resp.end(&json_string, resp.should_close_connection()); } - pub(crate) fn on_client_error_callback( + /// `table` is node:net's handler table for accepted sockets, `protos` is `ALPNProtocols` in wire format, `data` is each handle's first `data`. Called once per listen(). + pub(crate) fn set_node_socket_handlers( &self, - socket: &mut uws::Socket, - error_code: u8, - raw_packet: &[u8], + global: &JSGlobalObject, + table: JSValue, + protos: Option>, + data: JSValue, ) -> JsResult<()> { - if self.js_value_for_dispatch().is_none() { - return Ok(()); - } - let callback = self.on_clienterror.get(); - if callback.is_empty() { + let (Some(server_js), Some(app)) = (self.js_value_for_dispatch(), self.app.get()) else { return Ok(()); - } - { - let is_ssl = SSL; - let global = self.global(); - let node_socket = jsc::from_js_host_call(&global, || { - Bun__getOrCreateNodeHTTPServerSocket( - is_ssl, - std::ptr::from_mut(socket).cast::(), - &global, - ) - })?; - if node_socket.is_undefined_or_null() { - return Ok(()); + }; + let handlers = + crate::socket::Handlers::from_js(global, table, crate::socket::SocketMode::Server)?; + extern "C" fn on_filter( + socket: *mut uws_sys::us_socket_t, + event: i32, + user_data: *mut c_void, + ) { + // `2` is the accept, before a TLS handshake. Plain TCP waits for `1`: uWS counts the connection as idle from there, so a listener's `server.close()` closes it. + if event == if SSL { 2 } else { 1 } { + // SAFETY: `user_data` is the server registered below, which outlives its connections. Shared, because the handler re-enters JS. + let this = unsafe { &*user_data.cast::>() }; + crate::dispatch::fold(this.on_accept(socket.cast::())); } - - let error_code_value = JSValue::js_number(error_code as f64); - let raw_packet_value = ArrayBuffer::create_buffer(&global, raw_packet)?; - // 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, - &[ - JSValue::from(is_ssl), - node_socket, - error_code_value, - raw_packet_value, - ], - )?; } + // Once per native server: `bun --hot` gets here again, and the filter reads the config at call time. + if self.node_socket_config.get().is_none() { + bun_opaque::opaque_deref_mut(app) + .filter(on_filter::, self.as_ctx_ptr().cast::()); + } + Self::js_gc_socket_handlers_set(server_js, global, handlers.cell()); + Self::js_gc_socket_data_set(server_js, global, data); + self.node_socket_config.set(Some(Box::new( + crate::socket::node_http_socket::AcceptedSocketConfig { + handlers, + protos, + data, + }, + ))); 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<()> { - if self.js_value_for_dispatch().is_none() { - return Ok(()); - } - let callback = self.on_connection.get(); - if callback.is_empty() { - 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() { - 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])?; - Ok(()) + /// Read while a connection's handle is made, and not kept. Null before `set_node_socket_handlers`. + fn node_socket_config_ptr(&self) -> *const c_void { + self.node_socket_config + .get() + .as_deref() + .map_or(core::ptr::null(), |config| { + core::ptr::from_ref(config).cast() + }) } // `js_gc_route_list_set` / `ptr_to_js` live on the unbounded // `impl NewServer` in mod.rs; do not redefine them here. } -// ─── Exported fns ──────────────────────────────────────────────────────────── -fn server_set_on_client_error( - global: &JSGlobalObject, - server: JSValue, - callback: JSValue, -) -> JsResult { - if !server.is_object() { - return Err(global.throw(format_args!( - "Failed to set clientError: The 'this' value is not a Server." - ))); - } - - if !callback.is_function() { - return Err(global.throw(format_args!( - "Failed to set clientError: The provided value is not a function." - ))); - } - - macro_rules! handle { - ($T:ty) => { - if let Some(this) = server.as_class_ref::<$T>() { - if let Some(app) = this.app.get() { - let mut on_clienterror = callback; - super::wrap_handler_slot( - &mut on_clienterror, - server, - global, - <$T>::js_gc_on_client_error_set, - ); - this.on_clienterror.set(on_clienterror); - // uws_sys::App::on_client_error takes the raw C-ABI handler shape; - // wrap our typed callback in an extern "C" thunk that slices raw_packet. - extern "C" fn thunk( - user_data: *mut c_void, - _ssl: c_int, - socket: *mut uws_sys::us_socket_t, - error_code: u8, - raw_packet: *mut u8, - raw_packet_len: c_int, - ) { - // SAFETY: user_data is the `*mut Self` registered below; socket is a live - // uWS socket; raw_packet/raw_packet_len describe a valid (possibly empty) - // buffer. Shared — the callback re-enters JS. - let this = unsafe { &*user_data.cast::<$T>() }; - let packet: &[u8] = if raw_packet_len > 0 { - // SAFETY: uWS guarantees `raw_packet` points to `raw_packet_len` - // readable bytes when `raw_packet_len > 0`. - unsafe { bun_core::ffi::slice(raw_packet, raw_packet_len as usize) } - } else { - &[] - }; - // S008: `us_socket_t` is an `opaque_ffi!` ZST — safe deref. - crate::dispatch::fold(this.on_client_error_callback( - bun_opaque::opaque_deref_mut(socket), - error_code, - packet, - )); - } - // S008: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. - bun_opaque::opaque_deref_mut(app) - .on_client_error(thunk, this.as_ctx_ptr().cast::()); - } - return Ok(JSValue::UNDEFINED); - } - }; - } - handle!(HTTPServer); - handle!(HTTPSServer); - handle!(DebugHTTPServer); - handle!(DebugHTTPSServer); - debug_assert!(false); - Ok(JSValue::UNDEFINED) -} - -fn server_set_on_connection( - global: &JSGlobalObject, - server: JSValue, - callback: JSValue, -) -> JsResult { - if !server.is_object() { - return Err(global.throw(format_args!( - "Failed to set onConnection: The 'this' value is not a Server." - ))); - } - - if !callback.is_function() { - return Err(global.throw(format_args!( - "Failed to set onConnection: The provided value is not a function." - ))); - } - - macro_rules! handle { - ($T:ty) => { - if let Some(this) = server.as_class_ref::<$T>() { - if let Some(app) = this.app.get() { - // The filter is registered once per native server: `bun --hot` gets here again, and the thunk reads the slot at call time. - let first = this.on_connection.get().is_empty(); - let mut on_connection = callback; - super::wrap_handler_slot( - &mut on_connection, - server, - global, - <$T>::js_gc_on_connection_set, - ); - this.on_connection.set(on_connection); - // 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::())); - } - if first { - // S008: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. - bun_opaque::opaque_deref_mut(app) - .filter(thunk, this.as_ctx_ptr().cast::()); - } - } - return Ok(JSValue::UNDEFINED); - } - }; - } - handle!(HTTPServer); - handle!(HTTPSServer); - handle!(DebugHTTPServer); - handle!(DebugHTTPSServer); - debug_assert!(false); - Ok(JSValue::UNDEFINED) -} - -fn server_set_app_flags( - global: &JSGlobalObject, - server: JSValue, - require_host_header: bool, - use_strict_method_validation: bool, - lenient_http_flags: u8, - http_allow_half_open: bool, -) -> JsResult { - if !server.is_object() { - return Err(global.throw(format_args!( - "Failed to set requireHostHeader: The 'this' value is not a Server." - ))); - } - - let Some(server) = super::AnyServer::from_js(server) else { - return Err(global.throw(format_args!( - "Failed to set timeout: The 'this' value is not a Server." - ))); - }; - server.set_flags( - require_host_header, - use_strict_method_validation, - lenient_http_flags, - http_allow_half_open, - ); - Ok(JSValue::UNDEFINED) -} - -fn server_set_max_http_header_size( - global: &JSGlobalObject, - server: JSValue, - max_header_size: u64, -) -> JsResult { - let Some(server) = super::AnyServer::from_js(server) else { - return Err(global.throw(format_args!( - "Failed to set maxHeaderSize: The 'this' value is not a Server." - ))); - }; - server.set_max_http_header_size(max_header_size); - Ok(JSValue::UNDEFINED) -} - -fn server_set_max_headers_count( - global: &JSGlobalObject, - server: JSValue, - max_headers_count: u32, -) -> JsResult { - let Some(server) = super::AnyServer::from_js(server) else { - return Err(global.throw(format_args!( - "Failed to set maxHeadersCount: The 'this' value is not a Server." - ))); - }; - server.set_max_headers_count(max_headers_count); - Ok(JSValue::UNDEFINED) -} - -// `host_fn.wrap{3,4}` C-ABI shims: each forwards through `to_js_host_call` -// (= `host_fn::to_js_host_fn_result`) so a `JsError` becomes `.zero` with the -// exception left on the global. Signatures match the C++ callers in -// `node:http`/`node:https` (`bindings/NodeHTTP.cpp`). -// -// NOTE: these are plain `extern "C"` (NOT `#[bun_jsc::host_call]` / sysv64). -// The C++ declarations in -// NodeHTTP.cpp are bare `extern "C"` with no `SYSV_ABI`, so on Windows the -// caller uses Win64 ABI. Using `host_call` here forced sysv64 on the Rust -// side, scrambling the `server` argument and tripping the `is_object()` guard. -#[unsafe(export_name = "Server__setAppFlags")] -extern "C" fn server_set_app_flags_shim( - global: &JSGlobalObject, - server: JSValue, - require_host_header: bool, - use_strict_method_validation: bool, - lenient_http_flags: u8, - http_allow_half_open: bool, -) -> JSValue { - host_fn::to_js_host_fn_result( - global, - server_set_app_flags( - global, - server, - require_host_header, - use_strict_method_validation, - lenient_http_flags, - http_allow_half_open, - ), - ) -} - -#[unsafe(export_name = "Server__setOnClientError")] -extern "C" fn server_set_on_client_error_shim( - global: &JSGlobalObject, - server: JSValue, - callback: JSValue, -) -> JSValue { - host_fn::to_js_host_fn_result(global, server_set_on_client_error(global, server, callback)) -} - -#[unsafe(export_name = "Server__setOnConnection")] -extern "C" fn server_set_on_connection_shim( - global: &JSGlobalObject, - server: JSValue, - callback: JSValue, -) -> JSValue { - host_fn::to_js_host_fn_result(global, server_set_on_connection(global, server, callback)) -} - -#[unsafe(export_name = "Server__setMaxHTTPHeaderSize")] -extern "C" fn server_set_max_http_header_size_shim( - global: &JSGlobalObject, - server: JSValue, - max_header_size: u64, -) -> JSValue { - host_fn::to_js_host_fn_result( - global, - server_set_max_http_header_size(global, server, max_header_size), - ) -} - -#[unsafe(export_name = "Server__setMaxHeadersCount")] -extern "C" fn server_set_max_headers_count_shim( - global: &JSGlobalObject, - server: JSValue, - max_headers_count: u32, -) -> JSValue { - host_fn::to_js_host_fn_result( - global, - server_set_max_headers_count(global, server, max_headers_count), - ) -} - // ─── Externs ───────────────────────────────────────────────────────────────── // C++-implemented (bindings/BunServer.cpp). Declared here (not `bun_jsc`) // because the signatures name `bun_runtime` types (`NodeHTTPResponse`, @@ -3453,19 +2626,20 @@ unsafe extern "C" { // `&JSGlobalObject` encodes non-null/aligned; `socket` is the opaque live // `uws::Socket*` handed to `on_client_error_callback` / - // `on_connection_callback` by the uws dispatcher. Returns the + // `on_accept` by the uws dispatcher. Returns the // JSNodeHTTPServerSocket already attached to the raw socket, creating one // if the connection has not produced a parsed request yet. safe fn Bun__getOrCreateNodeHTTPServerSocket( is_ssl: bool, socket: *mut c_void, global: &JSGlobalObject, + socket_handlers: *const 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). - pub(super) safe fn Bun__ServerRouteList__callRoute( + safe fn Bun__ServerRouteList__callRoute( global: &JSGlobalObject, index: u32, request_ptr: *mut Request, diff --git a/src/runtime/socket/FdPipe.rs b/src/runtime/socket/FdPipe.rs new file mode 100644 index 000000000000..8389e72d577c --- /dev/null +++ b/src/runtime/socket/FdPipe.rs @@ -0,0 +1,220 @@ +//! The transport of `new net.Socket({ fd, writable: true })` over a pipe, a terminal, or a socket that is only written to. + +use core::cell::Cell; + +use bun_jsc::JsResult; +use bun_jsc::virtual_machine::VirtualMachine; +use bun_ptr::ThisPtr; +use bun_sys::{self as sys, Fd, FdExt as _}; +#[cfg(not(windows))] +use bun_uws::udp::raw; +use bun_uws::{SocketTCP, Transport}; + +use super::TCPSocket; +use crate::dispatch::fold; + +/// Writes with `write(2)` at once, in the descriptor's own blocking mode, so what a program wrote before `process.exit()` is out. It keeps no bytes: its socket queues what a full pipe refused. +pub(crate) struct FdPipe { + handle: bun_uws::TransportHandle, + fd: Fd, + /// `None` once closed. `connect_finish` took the connection's ref on it, and `on_close` releases that ref. + socket: Cell>>, + is_shutdown: Cell, + rejected_send_error: Cell, + /// The task that closes and frees `self` is in the queue. + is_queued: Cell, + /// In no uSockets group, so the context that opened it closes it through this when it stops. + abort_handle: bun_jsc::AbortHandle, + #[cfg(not(windows))] + poll: Cell>, +} + +bun_jsc::impl_abort_handle_owner!(FdPipe, abort_handle, |this, _cause| { + // SAFETY: trait contract: `this` is live (armed, so `Drop` has not run). + unsafe { &*this }.close() +}); + +impl FdPipe { + /// Makes `fd` the transport of `socket` and reports it open. `Err`: another socket of this process owns `fd`, and `socket` has had no event. + pub(crate) fn open( + socket: ThisPtr, + fd: Fd, + context: &bun_jsc::ScriptExecutionContext, + ) -> Result, sys::SystemErrno> { + // uSockets keeps its table of owned descriptors on POSIX only. + #[cfg(not(windows))] + if Self::closes(fd) && raw::bsd_adopt_socket(fd.native(), 0) != 0 { + return Err(sys::SystemErrno::from_raw(sys::last_error() as u16)); + } + let this = bun_core::heap::into_raw(Box::new(Self { + handle: Default::default(), + fd, + socket: Cell::new(Some(socket)), + is_shutdown: Cell::new(false), + rejected_send_error: Cell::new(0), + is_queued: Cell::new(false), + abort_handle: bun_jsc::AbortHandle::for_owner::(), + #[cfg(not(windows))] + poll: Cell::new(None), + })); + // SAFETY: fully initialised and heap-pinned; dropping the handle disarms it. + unsafe { bun_jsc::AbortHandle::arm_owner(this, context) }; + // SAFETY: just allocated, and freed only by the task that `close` queues. + let pipe = unsafe { &*this }; + pipe.handle.set(this); + let handle = SocketTCP::from_transport(&raw const pipe.handle); + socket.socket.set(handle); + Ok(TCPSocket::on_open(socket, handle)) + } + + /// Other streams share stdin, stdout and stderr, so they stay open and nobody owns them. + fn closes(fd: Fd) -> bool { + !fd.is_stdio() + } + + /// Returns true if `on_poll` will report that the descriptor takes bytes again. + #[cfg(not(windows))] + fn wait_until_writable(&self) -> bool { + use bun_io::posix_event_loop::{FilePoll, Flags, Owner, PollTag}; + // kqueue doesn't reliably report a FIFO's readiness on macOS, and the write could wait forever. + if !cfg!(target_os = "linux") { + return false; + } + let ctx = bun_io::js_vm_ctx(); + let poll = self.poll.get().unwrap_or_else(|| { + let owner = Owner::new(PollTag::FdPipe, core::ptr::from_ref(self).cast_mut().cast()); + FilePoll::init(ctx, self.fd, Default::default(), owner) + }); + self.poll.set(Some(poll)); + // SAFETY: `poll` is this pipe's live hive slot, no other borrow of it is live, and the loop is this thread's. + unsafe { (*poll).register(&*ctx.loop_(), Flags::Writable, true) }.is_ok() + } + + #[cfg(windows)] + fn wait_until_writable(&self) -> bool { + false + } + + #[cfg(not(windows))] + pub(crate) fn on_poll(&self) { + if let Some(socket) = self.socket.get() { + fold(TCPSocket::on_writable( + socket, + SocketTCP::from_transport(&raw const self.handle), + )); + } + } + + fn enqueue(&self) { + if !self.is_queued.replace(true) { + VirtualMachine::get() + .event_loop_mut() + .enqueue_task(bun_jsc::Task::init(core::ptr::from_ref(self).cast_mut())); + } + } + + /// SAFETY: `this` is the queued pointer. The call frees it. + pub(crate) unsafe fn run_event(this: *mut Self) { + // SAFETY: fn contract. + unsafe { &*this }.close(); + // SAFETY: fn contract; a closed pipe's socket holds no handle to it. + drop(unsafe { bun_core::heap::take(this) }); + } +} + +impl Transport for FdPipe { + fn is_closed(&self) -> bool { + self.socket.get().is_none() + } + + fn is_shutdown(&self) -> bool { + self.is_shutdown.get() + } + + fn write(&self, data: &[u8]) -> (i32, i32) { + let mut written = 0; + let mut send_error = 0; + while written < data.len() { + match sys::write(self.fd, &data[written..]) { + Ok(n) => written += n, + Err(err) if err.is_retry() && self.wait_until_writable() => break, + Err(err) => { + send_error = + -super::socket_body::uv_status(sys::SystemErrno::from_raw(err.errno)); + break; + } + } + } + (i32::try_from(written).expect("int cast"), send_error) + } + + fn raw_write(&self, data: &[u8]) -> i32 { + self.write(data).0 + } + + fn shutdown(&self) { + self.is_shutdown.set(true); + } + + /// Touches nothing of `self` after `on_close`, whose handler can turn the event loop and so free `self`. + fn close(&self) { + let Some(socket) = self.socket.take() else { + return; + }; + #[cfg(not(windows))] + if let Some(poll) = self.poll.take() { + // SAFETY: this pipe's live hive slot, given back here. + unsafe { (*poll).deinit() }; + } + if Self::closes(self.fd) { + // Before the close: afterwards the number can already be another thread's. + #[cfg(not(windows))] + raw::bsd_disown_socket(self.fd.native()); + // The program may have closed the descriptor itself. + let _ = self.fd.close_allowing_bad_file_descriptor(None); + } + self.enqueue(); + let handle = SocketTCP::from_transport(&raw const self.handle); + fold(TCPSocket::on_close( + socket, + handle, + self.rejected_send_error.get(), + None, + )); + } + + fn pause_stream(&self) -> bool { + true + } + + fn resume_stream(&self) -> bool { + true + } + + fn fd(&self) -> Fd { + self.fd + } + + fn reject_send(&self, error: i32) { + self.rejected_send_error.set(error); + self.is_shutdown.set(true); + self.enqueue(); + } + + fn rejected_send_error(&self) -> i32 { + self.rejected_send_error.get() + } +} + +impl bun_event_loop::Taskable for FdPipe { + const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::FdPipe; + /// What the task does runs no script by then. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract. + unsafe { Self::run_event(this) } + } + /// A socket's own hop. Its handlers carry their context. + unsafe fn context(_: *const Self) -> bun_event_loop::ContextId { + bun_event_loop::ContextId::NONE + } +} diff --git a/src/runtime/socket/Handlers.rs b/src/runtime/socket/Handlers.rs index 6ee94b4b85be..25ac892b2eca 100644 --- a/src/runtime/socket/Handlers.rs +++ b/src/runtime/socket/Handlers.rs @@ -13,10 +13,11 @@ use bun_sys::Fd; use bun_uws as uws; use super::Listener as SocketListener; +use super::SSLConfig; use super::SocketMode; use super::js_socket_handlers::{CALLBACK_COUNT, JSSocketHandlers}; use super::listener::ListenerType; -use super::{SSLConfig, SSLConfigFromJs}; +use super::ssl_config::ParsedTls; // ─── local shims (upstream-crate gaps) ────────────────────────────────────── unsafe extern "C" { @@ -151,6 +152,14 @@ impl Handlers { pub(crate) fn on_alpn_callback(&self) -> JSValue { self.cell.on_alpn_callback() } + pub(crate) fn on_write_complete(&self) -> JSValue { + self.cell.on_write_complete() + } + + /// The context of the script that gave these handlers. + pub(crate) fn context(&self) -> bun_jsc::ContextId { + self.context + } /// Drops the `open` callback for every holder of this `Handlers` — a client /// socket does this after its first TLS handshake so renegotiations do not @@ -297,6 +306,22 @@ impl Handlers { Ok(()) } + /// Calls `writeComplete(socket, status)`. What it throws is an uncaught exception, as in Node. + pub(crate) fn call_write_complete(&self, this_value: JSValue, status: core::ffi::c_int) { + let callback = self.on_write_complete(); + if callback.is_empty() { + return; + } + let global = self.global_object; + global.bun_vm().event_loop_mut().run_callback( + self.context, + callback, + &global, + this_value, + &[this_value, JSValue::js_number_from_int32(status)], + ); + } + pub(crate) fn from_js( global_object: &JSGlobalObject, opts: JSValue, @@ -366,6 +391,7 @@ impl Handlers { let on_keylog = validated_callback!(on_keylog, "onKeylog"); let on_server_name = validated_callback!(on_server_name, "onServerName"); let on_alpn_callback = validated_callback!(on_alpn_callback, "onALPNCallback"); + let on_write_complete = validated_callback!(on_write_complete, "onWriteComplete"); if on_data.is_empty() && on_writable.is_empty() { return Err(global_object.throw_invalid_arguments(format_args!( @@ -387,6 +413,7 @@ impl Handlers { on_keylog, on_server_name, on_alpn_callback, + on_write_complete, ]) } @@ -415,6 +442,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 @@ -448,6 +482,8 @@ pub(crate) struct SocketConfig { pub(crate) hostname_or_unix: Utf8Bytes<'static>, pub(crate) port: Option, pub(crate) fd: Option, + pub(crate) fd_is_write_only: bool, + pub(crate) local_binding: Option<(Box<[u8]>, u16)>, pub(crate) ssl: Option, pub(crate) handlers: Rc, pub(crate) default_data: JSValue, @@ -457,6 +493,8 @@ pub(crate) struct SocketConfig { pub(crate) ipv6_only: bool, /// node:net's `pauseOnConnect`: the socket, or every accepted one, opens paused. pub(crate) pause_on_connect: bool, + /// node:net sets the private `$defaultTimeout` to false, because `net.Socket` keeps its own timer: an accepted socket then starts without the 120 second timeout. + pub(crate) default_timeout: bool, } impl SocketConfig { @@ -471,9 +509,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; } @@ -492,16 +529,18 @@ impl SocketConfig { ) -> JsResult { let mut result: SocketConfig = 'blk: { let ssl: Option = match &generated.tls { - SocketOptionsTLS::None => None, - SocketOptionsTLS::Boolean(b) => { - if *b { - Some(super::tls_true_defaults(vm, mode.is_server())) - } else { - None - } + SocketOptionsTLS::None | SocketOptionsTLS::Boolean(false) => None, + SocketOptionsTLS::Boolean(true) => { + Some(vm.tls_config(Default::default(), mode.is_server())) } SocketOptionsTLS::Object(ssl) => { - SSLConfig::from_generated(vm, global, ssl, mode.is_server())? + let parsed = ParsedTls::from_generated(vm, global, ssl, mode.is_server())?; + // `Bun.listen({ tls: {} })` is plaintext. A client that passed an object asked for TLS. + if mode.is_server() { + parsed.or_none() + } else { + Some(parsed.or_defaults(vm, false)) + } } }; break 'blk SocketConfig { @@ -509,13 +548,15 @@ impl SocketConfig { port: None, fd: generated.fd.map(|v| { #[cfg(windows)] - { - Fd::from_system(v as u32 as usize as *mut core::ffi::c_void) - } - #[cfg(not(windows))] - { - Fd::from_uv(v) + if mode.is_server() || generated.fd_is_raw_socket { + return Fd::from_system(v as u32 as usize as *mut core::ffi::c_void); } + Fd::from_uv(v) + }), + fd_is_write_only: generated.fd_is_write_only, + local_binding: generated.local_address.map(|address| { + let port = generated.local_port.unwrap_or(0.0).clamp(0.0, 65535.0) as u16; + (address.into_utf8().into_vec().into_boxed_slice(), port) }), ssl, handlers: Handlers::from_generated(global, &generated.handlers, mode)?, @@ -524,29 +565,20 @@ impl SocketConfig { } else { generated.data.to_js() }, - exclusive: false, - allow_half_open: false, - reuse_port: false, - ipv6_only: false, - pause_on_connect: false, + exclusive: generated.exclusive, + allow_half_open: generated.allow_half_open, + reuse_port: generated.reuse_port, + ipv6_only: generated.ipv6_only, + pause_on_connect: generated.pause_on_connect, + default_timeout: true, }; }; // On any `?` below, `result` drops and releases what it owns — no // manual error-path cleanup needed. - result.exclusive = generated.exclusive; - result.allow_half_open = generated.allow_half_open; - result.reuse_port = generated.reuse_port; - result.ipv6_only = generated.ipv6_only; - result.pause_on_connect = generated.pause_on_connect; - if result.fd.is_some() { // If a user passes a file descriptor then prefer it over hostname or unix } else if let Some(unix) = generated.unix_ { - if unix.length() == 0 { - return Err(global - .throw_invalid_arguments(format_args!("Expected a non-empty \"unix\" path"))); - } result.hostname_or_unix = unix.into_utf8(); let slice = result.hostname_or_unix.slice(); if slice.starts_with(b"file://") @@ -593,8 +625,12 @@ impl SocketConfig { global_object: &JSGlobalObject, mode: SocketMode, ) -> JsResult { - SocketOptions::from_js(global_object, opts, |generated| { + let mut config = SocketOptions::from_js(global_object, opts, |generated| { Self::from_generated(vm, global_object, generated, mode) - }) + })?; + config.default_timeout = opts + .fast_get(global_object, bun_jsc::BuiltinName::defaultTimeout)? + .is_none_or(|value| value.to_boolean()); + Ok(config) } } diff --git a/src/runtime/socket/JSSocketHandlers.rs b/src/runtime/socket/JSSocketHandlers.rs index 35edd9cb2c01..f91b9fa97e6b 100644 --- a/src/runtime/socket/JSSocketHandlers.rs +++ b/src/runtime/socket/JSSocketHandlers.rs @@ -10,7 +10,7 @@ use bun_jsc::{JSGlobalObject, JSValue, Strong}; unsafe extern "C" { - /// Allocates the cell with the 13 callback fields populated barrier-free + /// Allocates the cell with the 14 callback fields populated barrier-free /// (the cell is not yet GC-visible); the promise field starts `undefined`. safe fn Bun__SocketHandlers__create( global: &JSGlobalObject, @@ -25,7 +25,7 @@ unsafe extern "C" { index: u32, value: JSValue, ); - /// Overwrites all 13 callback fields on a live cell with one trailing + /// Overwrites all 14 callback fields on a live cell with one trailing /// write barrier. safe fn Bun__SocketHandlers__setCallbacks( global: &JSGlobalObject, @@ -54,6 +54,7 @@ enum Field { Keylog, ServerName, AlpnCallback, + WriteComplete, /// Not a callback: the pending `Bun.connect` promise, cleared once settled. Promise, } @@ -123,6 +124,7 @@ impl JSSocketHandlers { on_keylog => Keylog, on_server_name => ServerName, on_alpn_callback => AlpnCallback, + on_write_complete => WriteComplete, } /// Replaces every callback on a live cell. `JSValue::ZERO` entries clear @@ -136,6 +138,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.bind.ts b/src/runtime/socket/Listener.bind.ts index 8a30593fe4a8..d5d9d5b64fe7 100644 --- a/src/runtime/socket/Listener.bind.ts +++ b/src/runtime/socket/Listener.bind.ts @@ -1,7 +1,7 @@ import * as b from "../../codegen/bindgen-lib.ts"; -import { tlsOptionNames } from "../../codegen/builtins/host/socket-options.ts"; import * as table from "../../codegen/builtins/lib.ts"; import { SecureContext } from "../api/bun/SecureContext.bind.ts"; +import { DebugHTTPSServer, HTTPSServer } from "../server/server.bind.ts"; export default b.module({ rustPath: "crate::socket::listener" }); @@ -32,30 +32,14 @@ const fromOptions = { args: { options: b.any }, capability: "js", throws: true } export const connect = b.fn({ ...fromOptions, returns: b.any, rustPath: "crate::socket::Listener::connect" }); export const listen = b.fn({ ...fromOptions, returns: Listener, rustPath: "crate::socket::Listener::listen" }); -/** `tls` is a `SecureContext`, or what `SSLConfig` takes, which is read under its names and copied. */ -export const addServerName = b.fn({ - args: { listener: Listener, hostname: b.string.utf8, tls: b.any }, - capability: "js", - throws: true, - rustPath: "crate::socket::Listener::add_server_name", - uses: { - listener: table.retainedByNativeCode, - tls: [ - table.retained, - table.reachableValuesStoredIn(table.nativeMemory), - table.runsTransitively( - "OwnKeys", - "OwnValues", - "PrototypeChain", - "Iterate", - "ToPrimitive", - table.named(...tlsOptionNames), - ), - ], - }, +/** What accepts the connections of a `tls.Server` (its `_handle`) or an `https.Server`. A cluster worker's `_handle` is neither: JS wraps its connections. */ +export const TLSListener = b.union({ + listener: Listener, + https: HTTPSServer, + debugHttps: DebugHTTPSServer, + ignored: b.any, }); - -/** `handle` is the `_handle` of a `tls.Server`. A cluster worker's is no `Listener`: JS wraps its connections. */ -export const setSecureContext = b.fn({ - args: { handle: Listener.otherAsNull, context: SecureContext.otherAsNull }, +export const addServerName = b.fn({ + args: { listener: TLSListener, hostname: b.string.utf8, context: SecureContext }, }); +export const setSecureContext = b.fn({ args: { listener: TLSListener, context: SecureContext } }); diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 1c0c0bc6c94f..502c5bbb8bdd 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -6,33 +6,34 @@ use core::mem::size_of; use std::rc::Rc; use bun_boringssl_sys as boring_sys; -use bun_core::{EncodedSlice, String as BunString}; +use bun_core::EncodedSlice; use bun_io::KeepAlive; use bun_jsc::EncodedSliceJsc as _; use bun_jsc::bindgen::{JsHeap, JsLocal, JsPayload as _, JsScope, JsWrapper}; -use bun_jsc::bun_string_jsc; use bun_jsc::strong::Optional as Strong; use bun_jsc::virtual_machine::VirtualMachine; -use bun_jsc::{self as jsc, JSGlobalObject, JSValue, JsCell, JsRef, JsResult, StringJsc as _}; +use bun_jsc::{self as jsc, JSGlobalObject, JSValue, JsCell, JsRef, JsResult}; use bun_sys::{self, Fd}; use bun_uws as uws; use bun_uws_sys as uws_sys; +use crate::api::SSLContextCache::SSLContextCache; use crate::api::bun_secure_context::SecureContext; -use crate::generated_bindgen::listener::js_Listener as js; +use crate::generated_bindgen::listener::{TLSListener, js_Listener as js}; +use crate::socket::SSLConfig; +use crate::socket::uws_jsc::{ListenApi, ListenTarget, throw_listen_error}; use crate::socket::{ Handlers, NewSocket, SocketConfig, SocketFlags, SocketMode, TCPSocket, TLSSocket, }; -use crate::socket::{SSLConfig, SSLConfigFromJs}; #[cfg(windows)] -use crate::socket::WindowsNamedPipeContext; +use crate::socket::WindowsNamedPipe; +#[cfg(windows)] +use crate::socket::uws_jsc::throw_listen_errno; #[cfg(windows)] use core::ptr::NonNull; -#[cfg(windows)] -use bun_boringssl as boringssl; #[cfg(windows)] use bun_core::strings; #[cfg(windows)] @@ -46,22 +47,8 @@ use bun_sys::windows::libuv as uv; bun_output::define_scoped_log!(log, Listener, visible); -/// Runs `f` against this thread's `SSL_CTX` cache. Takes a callback rather than -/// handing out a `&'static mut`, which two callers could hold at once. -#[inline] -fn with_ssl_ctx_cache( - f: impl FnOnce(&mut crate::api::SSLContextCache::SSLContextCache) -> R, -) -> R { - let state = crate::jsc_hooks::runtime_state(); - debug_assert!( - !state.is_null(), - "runtime_state() before init_runtime_state" - ); - // SAFETY: `state` is the per-thread `RuntimeState` boxed in - // `init_runtime_state`, address-stable until VM teardown, and only the JS - // thread reaches here — so this `&mut` is unique for `f`'s duration. - f(unsafe { &mut (*state).ssl_ctx_cache }) -} +/// `HotMapEntry::tag` of a `Listener`. `crate::server::AnyServerTag` owns 0..=3. +const HOT_MAP_TAG_LISTENER: u8 = 4; pub(crate) struct Listener { pub(crate) handlers: Rc, @@ -82,6 +69,11 @@ 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, + default_timeout: 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. @@ -90,13 +82,31 @@ 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>>, + /// 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| { +bun_jsc::impl_abort_handle_owner!(Listener, abort_handle, |this, cause| { + let connections = match cause { + bun_jsc::AbortCause::ContextStopped(bun_jsc::StopReason::VmTeardown) => { + Connections::CloseAsThreadEnds + } + _ => Connections::Close, + }; // SAFETY: trait contract — `this` is live (armed ⇒ not finalized). - Listener::do_stop(unsafe { &*this }, true) + Listener::do_stop(unsafe { &*this }, connections) }); +/// What stopping a listener or a server does to the connections it accepted. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Connections { + Keep, + Close, + CloseAsThreadEnds, +} + #[derive(Clone, Copy, Default)] pub(crate) enum ListenerType { Uws(*mut uws_sys::ListenSocket), @@ -139,23 +149,13 @@ fn prebuilt_ssl_ctx( let Some(tls_js) = opts.get_truthy(global, "tls")? else { return Ok(None); }; - if !tls_js.is_object() { - return Ok(None); - } - let Some(sc_js) = tls_js.get_truthy(global, "secureContext")? else { + let Some(ctx) = SecureContext::ctx_from_options(global, tls_js)? else { return Ok(None); }; - let Some(sc) = sc_js.as_class_ref::() else { - return Err(global.throw_invalid_argument_type_value( - b"secureContext", - b"SecureContext", - sc_js, - )); - }; // The context is served as it is: its trust store would not be the `ca` written next to `requestCert`. if is_server && config.ssl.as_ref().is_some_and(|ssl| ssl.request_cert != 0) - && !crate::socket::server_ctx_requests_cert(Some(sc.ctx.as_ptr())) + && !crate::socket::server_ctx_requests_cert(Some(ctx.as_ptr())) { return Err(global .err( @@ -167,9 +167,9 @@ fn prebuilt_ssl_ctx( .throw()); } if config.ssl.is_none() { - config.ssl = Some(crate::socket::tls_true_defaults(vm, is_server)); + config.ssl = Some(vm.tls_config(Default::default(), is_server)); } - Ok(Some(sc.ctx.clone())) + Ok(Some(ctx)) } #[derive(Clone)] @@ -180,6 +180,40 @@ pub(crate) enum UnixOrHost { } impl Listener { + fn new( + vm: &VirtualMachine, + context: bun_jsc::ContextId, + config: &mut SocketConfig, + connection: UnixOrHost, + secure_ctx: Option, + ) -> Listener { + Listener { + handlers: Rc::clone(&config.handlers), + listener: Cell::new(ListenerType::None), + poll_ref: JsCell::new(KeepAlive::init()), + connection: JsCell::new(connection), + group: JsCell::new(uws::SocketGroup::default()), + secure_ctx: JsCell::new(secure_ctx), + ssl: config.ssl.is_some(), + protos: config.ssl.as_mut().and_then(|s| s.take_protos()), + reject_unauthorized: crate::socket::resolve_reject_unauthorized( + vm, + config.ssl.as_ref(), + true, + ), + pause_on_connect: config.pause_on_connect, + default_timeout: config.default_timeout, + allow_half_open: config.allow_half_open, + socket_flags: config.socket_flags(), + strong_data: JsCell::new(Strong::empty()), + this_value: JsCell::new(JsRef::empty()), + abort_handle: bun_jsc::AbortHandle::for_owner::(), + context, + hot_key: JsCell::new(None), + hot_run: Cell::new(0), + } + } + pub(crate) fn reload(&self, cx: &mut JsScope<'_>, options: JsLocal<'_>) -> JsResult<()> { let global = cx.global(); let opts = options.to_js(); @@ -213,7 +247,7 @@ impl Listener { log!("listen"); let cx = &cx.js_thread(); let opts = options.to_js(); - if opts.is_empty_or_undefined_or_null() || opts.is_boolean() || !opts.is_object() { + if !opts.is_object() { return Err(cx .global() .throw_invalid_arguments(format_args!("Expected object"))); @@ -242,7 +276,70 @@ impl Listener { let prebuilt_ctx = prebuilt_ssl_ctx(vm, cx.global(), opts, &mut socket_config, true)?; 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); + let hot_run = VirtualMachine::get().hot_reload_counter; + let stale = 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 `Drop` remove the entry, so a registered listener is live. + .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); + if let Some(previous) = stale { + // 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.default_timeout == socket_config.default_timeout + && previous.context == cx.context().id(); + if let Some(this_value) = previous.this_value.get().try_get().filter(|_| unchanged) { + previous + .handlers + .copy_callbacks_from(cx.global(), &socket_config.handlers); + 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); + } + }); + previous.hot_run.set(hot_run); + return Ok(this_value); + } + } + // Frees the address. The stale listener's connections stay open and run the new handlers. + let stop_stale = |handlers: &Handlers| { + if let Some(previous) = stale { + previous.handlers.copy_callbacks_from(cx.global(), handlers); + Listener::do_stop(previous, Connections::Keep); + } + }; + + let secure_ctx = match (prebuilt_ctx, socket_config.ssl.as_ref()) { + (None, Some(ssl_cfg)) => { + let mut create_err = uws::create_bun_socket_error_t::none; + let ctx = SSLContextCache::get_or_create_for_server( + &ssl_cfg.as_usockets(), + &mut create_err, + ); + if ctx.is_none() { + return Err(cx.global().throw_value( + crate::socket::uws_jsc::create_bun_socket_error_to_js( + create_err, + cx.global(), + ), + )); + } + ctx + } + (prebuilt_ctx, _) => prebuilt_ctx, + }; #[cfg(windows)] if port.is_none() { @@ -268,37 +365,17 @@ impl Listener { vm.event_loop_ref().ensure_waker(); - let handlers = Rc::clone(&socket_config.handlers); - let protos_taken = socket_config.ssl.as_mut().and_then(|s| s.take_protos()); let default_data = socket_config.default_data; - let ssl_cfg_taken = socket_config.ssl.take(); - - let this_value = Listener { - handlers, - connection: JsCell::new(connection), - ssl: ssl_enabled, - listener: Cell::new(ListenerType::None), - protos: protos_taken, - reject_unauthorized: crate::socket::resolve_reject_unauthorized( - vm, - ssl_cfg_taken.as_ref(), - true, - ), - pause_on_connect, - 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()), - abort_handle: bun_jsc::AbortHandle::for_owner::(), - context: cx.context().id(), - } - .into_js_wrapper(cx.global()); + let context = cx.context().id(); + let this_value = + Listener::new(vm, context, &mut socket_config, connection, secure_ctx) + .into_js_wrapper(cx.global()); let js_value = this_value.to_js(); let this: *mut Listener = this_value.payload_ptr().as_ptr(); // SAFETY: the payload of `this_value`; every field touched below // is `Cell`/`JsCell` or `&self`, so a shared borrow suffices. let this_ref = unsafe { &*this }; + stop_stale(&this_ref.handlers); // TODO: server_name is not supported on named pipes, I belive its , lets wait for // someone to ask for it @@ -308,7 +385,6 @@ impl Listener { cx.global(), &pipe_buf[..pipe_len], 511, - ssl_cfg_taken.as_ref(), this, ) { Ok(named_pipe) => { @@ -318,37 +394,18 @@ impl Listener { )); } Err(e) => { - // Surface coded syscall failures the way node:net - // does (EADDRINUSE vs EACCES need different caller - // handling) rather than an invalid-arguments TypeError. - if let ListenPipeError::Sys(sys_err, uv_errno) = &e { - // get_error_code_tag_name does not reject EUNKNOWN / - // UV_EAI_* (>=3000); neither is a node-style code, so - // route those through the generic error below. - if let Some((name, se)) = sys_err.get_error_code_tag_name() { - if se != bun_sys::SystemErrno::EUNKNOWN && (se as u16) < 3000 { - let err = jsc::SystemError { - errno: *uv_errno, - code: bun_core::String::static_(name).into(), - message: bun_core::String::clone_utf8( - format!( - "listen {}: {}", - name, - bstr::BStr::new(&pipe_buf[..pipe_len]) - ) - .as_bytes(), - ) - .into(), - syscall: bun_core::String::static_("listen").into(), - path: bun_core::String::clone_utf8(&pipe_buf[..pipe_len]) - .into(), - ..Default::default() - }; - return Err(cx - .global() - .throw_value(err.to_error_instance(cx.global()))); - } - } + // EUNKNOWN and UV_EAI_* (>= 3000) aren't codes Node reports for a listen(). + if let ListenPipeError::Sys(sys_err) = &e + && let Some((_, errno)) = sys_err.get_error_code_tag_name() + && errno != bun_sys::SystemErrno::EUNKNOWN + && (errno as u16) < 3000 + { + return Err(throw_listen_errno( + cx.global(), + ListenApi::BunListen, + &ListenTarget::Unix(&pipe_buf[..pipe_len]), + errno, + )); } let detail = match &e { ListenPipeError::Other(err) => err.name(), @@ -380,6 +437,7 @@ impl Listener { this_ref.poll_ref.with_mut(|p| p.ref_(bun_io::js_vm_ctx())); // SAFETY: its address does not change; leaves its context in `Drop`. unsafe { bun_jsc::AbortHandle::arm_owner(this, cx.context()) }; + this_ref.register_for_hot_reload(hot_key); return Ok(js_value); } } @@ -389,38 +447,16 @@ impl Listener { // Make the object up front so the embedded `group` has its final // address before we hand it to listen() (it's linked into the loop's // intrusive list). - let handlers = Rc::clone(&socket_config.handlers); - let protos_taken = socket_config.ssl.as_mut().and_then(|s| s.take_protos()); let default_data = socket_config.default_data; let hostname_owned: Box<[u8]> = core::mem::take(&mut socket_config.hostname_or_unix) .into_vec() .into_boxed_slice(); let fd_opt = socket_config.fd; - let ssl_cfg_taken = socket_config.ssl.take(); - - let this_value = Listener { - handlers, - // Placeholder until `this_ref.connection` is written below. - // Cannot `mem::zeroed()` a Rust enum (UB). - connection: JsCell::new(UnixOrHost::Fd(Fd::invalid())), - ssl: ssl_enabled, - protos: protos_taken, - reject_unauthorized: crate::socket::resolve_reject_unauthorized( - vm, - ssl_cfg_taken.as_ref(), - true, - ), - pause_on_connect, - 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()), - abort_handle: bun_jsc::AbortHandle::for_owner::(), - context: cx.context().id(), - } - .into_js_wrapper(cx.global()); + // `connection` is a placeholder until the bind below has the port. + let placeholder = UnixOrHost::Fd(Fd::invalid()); + let context = cx.context().id(); + let this_value = Listener::new(vm, context, &mut socket_config, placeholder, secure_ctx) + .into_js_wrapper(cx.global()); let js_value = this_value.to_js(); let this: *mut Listener = this_value.payload_ptr().as_ptr(); // SAFETY: the payload of `this_value`; every field touched through this @@ -439,23 +475,8 @@ impl Listener { size_of::(), ); - if prebuilt_ctx.is_some() { - this_ref.secure_ctx.set(prebuilt_ctx); - } else if let Some(ssl_cfg) = ssl_cfg_taken.as_ref() { - let mut create_err = uws::create_bun_socket_error_t::none; - let ctx_opts = ssl_cfg.as_usockets(); - match ctx_opts.create_ssl_context_with_digest(&ctx_opts.digest(), &mut create_err) { - Some(ctx) => this_ref.secure_ctx.set(Some(ctx)), - None => { - return Err(cx.global().throw_value( - crate::socket::uws_jsc::create_bun_socket_error_to_js( - create_err, - cx.global(), - ), - )); - } - } - } + // Only the bind can fail from here, so a run with a broken certificate leaves the stale listener serving. + stop_stale(&this_ref.handlers); let kind: uws::SocketKind = if ssl_enabled { uws::SocketKind::BunListenerTls } else { @@ -478,7 +499,7 @@ impl Listener { let secure_ctx_ptr: Option<*mut uws::SslCtx> = this_ref.secure_ctx.get().as_ref().map(|p| p.as_ptr()); - let mut errno: c_int = 0; + let mut failure = uws_sys::app::ListenError::default(); let listen_socket: *mut uws_sys::ListenSocket = match &mut connection { UnixOrHost::Host { host, port } => { let hostz = bun_core::ZBox::from_bytes(&host[..]); @@ -491,7 +512,8 @@ impl Listener { *port as c_int, socket_flags, size_of::<*mut c_void>() as c_int, - &mut errno, + &mut failure.error, + &mut failure.dns_error, ) }); if !ls.is_null() { @@ -509,7 +531,7 @@ impl Listener { u, socket_flags, size_of::<*mut c_void>() as c_int, - &mut errno, + &mut failure.error, ) }), UnixOrHost::Fd(fd) => { @@ -522,60 +544,27 @@ impl Listener { 511, socket_flags, size_of::<*mut c_void>() as c_int, - &mut errno, + &mut failure.error, ) }) } }; if listen_socket.is_null() { - // Note: reshaped for borrowck — extract hostname bytes for error formatting - let hostname_bytes: &[u8] = match &connection { - UnixOrHost::Host { host, .. } => host, - UnixOrHost::Unix(u) => u, - UnixOrHost::Fd(_) => b"", + log!("Failed to listen {} {}", failure.error, failure.dns_error); + let target = match &connection { + UnixOrHost::Host { host, port } => ListenTarget::Tcp { + hostname: Some(&host[..]), + port: *port, + }, + UnixOrHost::Unix(path) => ListenTarget::Unix(&path[..]), + UnixOrHost::Fd(_) => ListenTarget::Fd, }; - let err = cx.global().create_error_instance(format_args!( - "Failed to listen at {}", - bstr::BStr::new(hostname_bytes) + return Err(throw_listen_error( + cx.global(), + ListenApi::BunListen, + &target, + failure, )); - log!("Failed to listen {}", errno); - let mapped = bun_sys::SystemErrno::init(errno as i64); - let errno = if mapped == Some(bun_sys::SystemErrno::ENAMETOOLONG) - || (matches!(connection, UnixOrHost::Fd(_)) - && matches!( - mapped, - Some(bun_sys::SystemErrno::ENOTSOCK) - | Some(bun_sys::SystemErrno::EBADF) - | Some(bun_sys::SystemErrno::EOPNOTSUPP) - )) { - bun_sys::SystemErrno::EINVAL as c_int - } else { - errno - }; - if errno != 0 { - err.put( - cx.global(), - b"syscall", - BunString::static_("listen").to_js(cx.global())?, - ); - err.put(cx.global(), b"errno", JSValue::js_number(errno as f64)); - err.put( - cx.global(), - b"address", - bun_string_jsc::create_utf8_for_js(cx.global(), hostname_bytes)?, - ); - 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) { - err.put( - cx.global(), - b"code", - BunString::static_(<&'static str>::from(str_)).to_js(cx.global())?, - ); - } - } - return Err(cx.global().throw_value(err)); } this_ref.connection.with_mut(|c| *c = connection); @@ -586,21 +575,6 @@ impl Listener { .set(Strong::create(default_data, cx.global())); } - 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. - 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); - } - } - // The listener holds the handlers cell in a visited slot; every // accepted socket shares the same cell. js::handlers_set_cached(js_value, cx.global(), this_ref.handlers.cell()); @@ -613,11 +587,40 @@ impl Listener { this_ref.poll_ref.with_mut(|p| p.ref_(bun_io::js_vm_ctx())); // SAFETY: its address does not change; leaves its context in `Drop`. unsafe { bun_jsc::AbortHandle::arm_owner(this, cx.context()) }; + this_ref.register_for_hot_reload(hot_key); Ok(js_value) } - // `OWNED_PROTOS` stays unset: accepted sockets clone the listener's `protos`. + /// 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)); + self.hot_run.set(VirtualMachine::get().hot_reload_counter); + } + + 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); + } + } + fn accepted_socket_flags(&self) -> SocketFlags { let mut flags = SocketFlags::empty(); // Also what the context in use was built with: `tls.secureContext` need not come with the options again. @@ -627,6 +630,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 } @@ -634,39 +638,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), - 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. @@ -682,28 +655,10 @@ impl Listener { 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(socket), - protos: JsCell::new(listener.protos.clone()), - // `protos` is `Option>` so each accepted socket clones - // the listener's slice; one small allocation per accept. - 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), - native_callback: JsCell::new(crate::socket::NativeCallbacks::None), - twin: JsCell::new(None), - verify_error: JsCell::new(None), - latest_session: Cell::new(None), - }); + let this_socket = NewSocket::::new(Some(Rc::clone(&listener.handlers))); + this_socket.socket.set(socket); + this_socket.protos.set(listener.protos.clone()); + this_socket.flags.set(listener.accepted_socket_flags()); let s = this_socket; s.ref_(); // Each accepted socket holds the event loop on its own (same as a @@ -728,91 +683,36 @@ impl Listener { uws_sys::SocketKind::BunSocketTcp }); } - socket.set_timeout(120); - this_socket - } - - pub(crate) fn add_server_name<'a>( - cx: &mut JsScope<'a>, - this: &Self, - server_name_bytes: &[u8], - tls: JsLocal<'a>, - ) -> JsResult<()> { - let global = cx.global(); - let tls = tls.to_js(); - if !this.ssl { - return Err( - global.throw_invalid_arguments(format_args!("addServerName requires SSL support")) - ); - } - if server_name_bytes.is_empty() { - return Err( - global.throw_invalid_arguments(format_args!("hostname pattern cannot be empty")) - ); + // Whether or not there is a `timeout` handler yet: `reload()` and `--hot` can bring one. + if listener.default_timeout { + this_socket.timeout_seconds.set(120); + socket.set_timeout(120); } - // No ClientHello can carry a NUL, so this pattern matches nothing, as in Node. - if bun_core::strings::contains_char(server_name_bytes, 0) { - return Ok(()); - } - let server_name_z = bun_core::ZBox::from_bytes(server_name_bytes); - let server_name = server_name_z.as_zstr().as_cstr(); - - let ListenerType::Uws(ls) = this.listener.get() else { - return Ok(()); - }; - - // Both real callers (node:tls addContext, node:net) pass a native - // SecureContext; enforcement policy stays server-level, like Node's. - // The dict branch is defensive for the internal binding's raw form. - let sni_ctx: boring_sys::OwnedSslCtx = if let Some(sc) = tls.as_class_ref::() - { - sc.ctx.clone() - } else if let Some(ssl_config) = { - // SAFETY: per-thread VM; valid for program lifetime. - let vm = VirtualMachine::get().as_mut(); - SSLConfig::from_js(vm, global, tls, true)? - } { - // Note: `cfg` cleanup handled by Drop on SSLConfig - let mut create_err = uws::create_bun_socket_error_t::none; - match with_ssl_ctx_cache(|cache| cache.get_or_create(&ssl_config, &mut create_err)) { - Some(ctx) => ctx, - None => { - if create_err != uws::create_bun_socket_error_t::none { - return Err(global.throw_value( - crate::socket::uws_jsc::create_bun_socket_error_to_js( - create_err, global, - ), - )); - } - let code = boring_sys::ERR_get_error(); - return Err( - global.throw_value(crate::crypto::boringssl_jsc::err_to_js(global, code)) - ); - } - } - } else { - return Ok(()); - }; - - super::socket_body::install_sni_alpn_selector(sni_ctx.as_ptr()); - - // The C SNI tree SSL_CTX_up_ref()s; ours drops here. - // S008: `ListenSocket` is an `opaque_ffi!` ZST — safe deref. - bun_opaque::opaque_deref_mut(ls).add_server_name(server_name, sni_ctx.as_ptr()); - - Ok(()) + this_socket } pub(crate) fn dispose(&self, _: &mut JsScope<'_>) { - self.do_stop(true); + self.do_stop(Connections::Close); } pub(crate) fn stop(&self, _: &mut JsScope<'_>, close_active_connections: JsLocal<'_>) { log!("close"); - self.do_stop(close_active_connections.to_js() == JSValue::TRUE); + self.do_stop(if close_active_connections.to_js() == JSValue::TRUE { + Connections::Close + } else { + Connections::Keep + }); } - fn do_stop(&self, force_close: bool) { + fn do_stop(&self, on_stop: Connections) { + let force_close = on_stop != Connections::Keep; + let close_all = || { + self.group.with_mut(|g| match on_stop { + Connections::CloseAsThreadEnds => g.close_all_as_thread_ends(), + _ => g.close_all(), + }) + }; + self.unregister_for_hot_reload(); let connections = self.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`). @@ -822,7 +722,7 @@ impl Listener { if matches!(self.listener.get(), ListenerType::None) { // Stopped gracefully earlier; what is still connected goes now. if force_close && connections != 0 { - self.group.with_mut(|g| g.close_all()); + close_all(); } return; } @@ -842,7 +742,7 @@ impl Listener { self.strong_data .with_mut(|s| s.clear_without_deallocation()); } else if force_close { - self.group.with_mut(|g| g.close_all()); + close_all(); } match listener { @@ -948,7 +848,7 @@ impl Listener { if cx.context().is_stopped() { return Ok(jsc::JSPromise::create(cx.global()).to_js()); } - if opts.is_empty_or_undefined_or_null() || opts.is_boolean() || !opts.is_object() { + if !opts.is_object() { return Err(cx .global() .throw_invalid_arguments(format_args!("Expected options object"))); @@ -965,333 +865,42 @@ impl Listener { let port = socket_config.port; let mut owned_ssl_ctx = prebuilt_ssl_ctx(vm, cx.global(), opts, &mut socket_config, false)?; + // Without a SecureContext, the context is built from the config, for a socket and for a named pipe alike. + if owned_ssl_ctx.is_none() + && let Some(ssl_cfg) = socket_config.ssl.as_ref() + { + // Identical configs share one `SSL_CTX*`. servername and ALPNProtocols are applied per SSL, so they aren't compared. + let mut create_err = uws::create_bun_socket_error_t::none; + owned_ssl_ctx = SSLContextCache::get_or_create(ssl_cfg, &mut create_err); + if owned_ssl_ctx.is_none() { + return Err(cx.global().throw_value( + crate::socket::uws_jsc::create_bun_socket_error_to_js(create_err, cx.global()), + )); + } + } let ssl_enabled = socket_config.ssl.is_some(); let default_data = socket_config.default_data; vm.event_loop_ref().ensure_waker(); - let connection: UnixOrHost = 'blk: { - if let Some(fd_) = opts.get_truthy(cx.global(), "fd")? { - if fd_.is_number() { - #[cfg(windows)] - let fd = if opts - .get_truthy(cx.global(), "fdIsRawSocket")? - .is_some_and(|v| v.to_boolean()) - { - Fd::from_system(fd_.to_int32() as u32 as usize as *mut c_void) - } else { - Fd::from_uv(fd_.to_int32()) - }; - #[cfg(not(windows))] - let fd = Fd::from_uv(fd_.to_int32()); - break 'blk UnixOrHost::Fd(fd); - } - } - // Move the hostname bytes into `host`; `socket_config` drops the - // emptied slice. + let connection: UnixOrHost = if let Some(fd) = socket_config.fd { + UnixOrHost::Fd(fd) + } else { let host: Box<[u8]> = core::mem::take(&mut socket_config.hostname_or_unix) .into_vec() .into_boxed_slice(); - if let Some(port_) = port { - UnixOrHost::Host { host, port: port_ } - } else { - UnixOrHost::Unix(host) + match port { + Some(port) => UnixOrHost::Host { host, port }, + None => UnixOrHost::Unix(host), } }; - // `connection` Box drops on error path - - // `localAddress`/`localPort`: bind the socket to this address before - // connecting. node:net validates localAddress as a literal IP and - // localPort as a number before they reach us. - let local_binding: Option<(Box<[u8]>, u16)> = 'lb: { - let Some(local_addr_js) = opts.get_truthy(cx.global(), "localAddress")? else { - break 'lb None; - }; - if !local_addr_js.is_string() { - break 'lb None; - } - let local_addr_slice = local_addr_js.to_utf8(cx.global())?; - let local_addr_bytes = local_addr_slice.slice(); - if local_addr_bytes.is_empty() { - break 'lb None; - } - let local_port: u16 = match opts.get_truthy(cx.global(), "localPort")? { - Some(p) if p.is_number() => p.to_int32().clamp(0, 65535) as u16, - _ => 0, - }; - Some((local_addr_bytes.to_vec().into_boxed_slice(), local_port)) - }; - - #[cfg(windows)] - let mut connection = connection; - #[cfg(windows)] - { - use crate::socket::windows_named_pipe_context::SocketType as PipeSocketType; - use bun_sys::FdExt as _; + let local_binding = socket_config.local_binding.take(); - let mut buf = bun_paths::path_buffer_pool::get(); - // Note: reshaped for borrowck — `normalize_pipe_name` borrows - // `buf` for the returned slice; store length and re-borrow after the - // `connection` match drops. - let mut pipe_name_len: Option = None; - let is_named_pipe = match &mut connection { - // we check if the path is a named pipe otherwise we try to connect using AF_UNIX - UnixOrHost::Unix(slice) => match normalize_pipe_name(slice, buf.as_mut_slice()) { - Some(name) => { - pipe_name_len = Some(name.len()); - true - } - None => false, - }, - UnixOrHost::Fd(fd) if fd.kind() == bun_core::FdKind::System => false, - UnixOrHost::Fd(fd) => { - let uvfd = fd.uv(); - let fd_type = uv::uv_guess_handle(uvfd); - if fd_type == uv::HandleType::NamedPipe { - true - } else if fd_type == uv::HandleType::Unknown { - // is not a libuv fd, check if it's a named pipe - let osfd: uv::uv_os_fd_t = uvfd as usize as uv::uv_os_fd_t; - if bun_sys::windows::GetFileType(osfd) == bun_sys::windows::FILE_TYPE_PIPE { - // yay its a named pipe lets make it a libuv fd - *fd = Fd::from_system(osfd) - .make_lib_uv_owned() - .unwrap_or_else(|_| panic!("failed to allocate file descriptor")); - true - } else { - false - } - } else { - false - } - } - _ => false, - }; - if is_named_pipe { - default_data.ensure_still_alive(); - - let mut ssl_taken = socket_config.ssl.take(); - - let promise = jsc::JSPromise::create(cx.global()); - let promise_value = promise.to_js(); - handlers.set_promise(cx.global(), promise_value); - - if ssl_enabled { - let tls: bun_ptr::ThisPtr = if let Some(prev_ptr) = prev_maybe_tls { - // SAFETY: caller passes a live TLSSocket, owned by its JS wrapper. - let prev = unsafe { bun_ptr::ThisPtr::new(prev_ptr) }; - debug_assert!(!prev.this_value.get().is_empty()); - prev.set_handlers(cx.global(), Some(Rc::clone(&handlers))); - debug_assert!(matches!( - prev.socket.get().socket, - uws::InternalSocket::Detached - )); - // Free old resources before reassignment to prevent memory leaks - // when sockets are reused for reconnection (common with MongoDB driver) - prev.connection.set(Some(connection)); - prev.local_binding.set(local_binding.clone()); - if prev.flags.get().contains(SocketFlags::OWNED_PROTOS) { - prev.protos.set(None); - } - prev.protos - .set(ssl_taken.as_mut().and_then(|s| s.take_protos())); - prev.server_name - .set(ssl_taken.as_mut().and_then(|s| s.take_server_name())); - prev - } else { - TLSSocket::new(TLSSocket { - ref_count: bun_ptr::RefCount::init(), - handlers: JsCell::new(Some(Rc::clone(&handlers))), - socket: Cell::new(uws::NewSocketHandler::::DETACHED), - connection: JsCell::new(Some(connection)), - local_binding: JsCell::new(local_binding.clone()), - protos: JsCell::new(ssl_taken.as_mut().and_then(|s| s.take_protos())), - server_name: JsCell::new( - ssl_taken.as_mut().and_then(|s| s.take_server_name()), - ), - owned_ssl_ctx: JsCell::new(None), - flags: Cell::new(SocketFlags::default()), - this_value: JsCell::new(jsc::JsRef::empty()), - poll_ref: JsCell::new(KeepAlive::init()), - ref_pollref_on_connect: Cell::new(true), - buffered_data_for_node_net: Default::default(), - bytes_written: 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 tls_ref = tls; - tls_ref.reset_client_tls_flags(crate::socket::resolve_reject_unauthorized( - vm, - ssl_taken.as_ref(), - false, - )); - tls_ref.update_flags(|f| { - f.set( - SocketFlags::PAUSE_ON_CONNECT, - socket_config.pause_on_connect, - ) - }); - TLSSocket::data_set_cached( - tls_ref.this_value_for_connect(cx.global()), - cx.global(), - default_data, - ); - tls_ref.poll_ref.with_mut(|p| p.ref_(bun_io::js_vm_ctx())); - tls_ref.ref_(); - - let ctx_for_pipe = owned_ssl_ctx.take(); - // Note: re-borrow connection from the socket field — `connection` - // was moved into `tls` above. - let named_pipe_result = match tls_ref.connection.get().as_ref().unwrap() { - UnixOrHost::Unix(_) => WindowsNamedPipeContext::connect( - cx, - &buf[..pipe_name_len.unwrap()], - ssl_taken.take(), - ctx_for_pipe, - PipeSocketType::Tls(tls_ref), - ), - UnixOrHost::Fd(fd) => WindowsNamedPipeContext::open( - cx, - *fd, - ssl_taken.take(), - ctx_for_pipe, - PipeSocketType::Tls(tls_ref), - ), - _ => unreachable!(), - }; - let named_pipe = match named_pipe_result { - Ok(p) => p, - Err(_) => { - // The context's guard already ran `handle_connect_error` - // on the still-detached socket, which releases nothing. - // Balance the attempt `tls_ref.ref_()` above. - TLSSocket::deref(&tls_ref); - return Ok(promise_value); - } - }; - tls_ref.socket.set(uws::NewSocketHandler { - socket: uws::InternalSocket::Pipe(named_pipe.cast()), - }); - } else { - let tcp: bun_ptr::ThisPtr = if let Some(prev_ptr) = prev_maybe_tcp { - // SAFETY: caller passes a live TCPSocket, owned by its JS wrapper. - let prev = unsafe { bun_ptr::ThisPtr::new(prev_ptr) }; - debug_assert!(!prev.this_value.get().is_empty()); - prev.set_handlers(cx.global(), Some(Rc::clone(&handlers))); - debug_assert!(matches!( - prev.socket.get().socket, - uws::InternalSocket::Detached - )); - // Adopt `connection` (heap-owned for .unix) so the socket's - // deinit frees it; matches the TLS arm above and the - // non-pipe arm below. Previously `.connection = null` - // dropped the duped pipe-path bytes on the floor. - prev.connection.set(Some(connection)); - prev.local_binding.set(local_binding.clone()); - debug_assert!(prev.protos.get().is_none()); - debug_assert!(prev.server_name.get().is_none()); - prev - } else { - TCPSocket::new(TCPSocket { - ref_count: bun_ptr::RefCount::init(), - handlers: JsCell::new(Some(Rc::clone(&handlers))), - socket: Cell::new(uws::NewSocketHandler::::DETACHED), - connection: JsCell::new(Some(connection)), - local_binding: JsCell::new(local_binding.clone()), - protos: JsCell::new(None), - server_name: JsCell::new(None), - owned_ssl_ctx: JsCell::new(None), - flags: Cell::new(SocketFlags::default()), - this_value: JsCell::new(jsc::JsRef::empty()), - poll_ref: JsCell::new(KeepAlive::init()), - ref_pollref_on_connect: Cell::new(true), - buffered_data_for_node_net: Default::default(), - bytes_written: 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 tcp_ref = tcp; - tcp_ref.update_flags(|f| { - f.set( - SocketFlags::PAUSE_ON_CONNECT, - socket_config.pause_on_connect, - ) - }); - tcp_ref.ref_(); - TCPSocket::data_set_cached( - tcp_ref.this_value_for_connect(cx.global()), - cx.global(), - default_data, - ); - tcp_ref.poll_ref.with_mut(|p| p.ref_(bun_io::js_vm_ctx())); - - let named_pipe_result = match tcp_ref.connection.get().as_ref().unwrap() { - UnixOrHost::Unix(_) => WindowsNamedPipeContext::connect( - cx, - &buf[..pipe_name_len.unwrap()], - None, - None, - PipeSocketType::Tcp(tcp_ref), - ), - UnixOrHost::Fd(fd) => WindowsNamedPipeContext::open( - cx, - *fd, - None, - None, - PipeSocketType::Tcp(tcp_ref), - ), - _ => unreachable!(), - }; - let named_pipe = match named_pipe_result { - Ok(p) => p, - Err(_) => { - // Balance the attempt `tcp_ref.ref_()` above; see the TLS arm. - TCPSocket::deref(&tcp_ref); - return Ok(promise_value); - } - }; - tcp_ref.socket.set(uws::NewSocketHandler { - socket: uws::InternalSocket::Pipe(named_pipe.cast()), - }); - } - return Ok(promise_value); - } - } - - // A SecureContext's ctx was already cloned above; build the SSL_CTX from - // SSLConfig only if no SecureContext was passed. doConnect hands - // `socket.owned_ssl_ctx` to the per-VM connect group. - if ssl_enabled && owned_ssl_ctx.is_none() { - if let Some(ssl_cfg) = socket_config.ssl.as_ref() { - // Per-VM weak `SSLContextCache`: identical configs (including the - // common `tls:true` / `{servername}`-only / `{ALPNProtocols}`-only - // cases — those fields aren't in the digest because they're - // applied per-SSL, not per-CTX) share one `SSL_CTX*`. The - // `requires_custom_request_ctx` gate is gone; the cache makes the - // default-vs-custom distinction by content. - let mut create_err = uws::create_bun_socket_error_t::none; - owned_ssl_ctx = - with_ssl_ctx_cache(|cache| cache.get_or_create(ssl_cfg, &mut create_err)); - if owned_ssl_ctx.is_none() { - return Err(cx.global().throw_value( - crate::socket::uws_jsc::create_bun_socket_error_to_js( - create_err, - cx.global(), - ), - )); - } - } - } default_data.ensure_still_alive(); let allow_half_open = socket_config.allow_half_open; let pause_on_connect = socket_config.pause_on_connect; + let fd_is_write_only = socket_config.fd_is_write_only; let mut ssl_taken = socket_config.ssl.take(); let promise = jsc::JSPromise::create(cx.global()); @@ -1312,6 +921,7 @@ impl Listener { default_data, allow_half_open, pause_on_connect, + fd_is_write_only, port, promise_value, ) @@ -1327,6 +937,7 @@ impl Listener { default_data, allow_half_open, pause_on_connect, + fd_is_write_only, port, promise_value, ) @@ -1345,42 +956,14 @@ impl Listener { } let mut buf = [0u8; 64]; - let mut text_buf = [0u8; 512]; // S008: `ListenSocket` is an `opaque_ffi!` ZST — safe deref. let socket_ref = bun_opaque::opaque_deref_mut(socket); - let address_bytes: &[u8] = match socket_ref.get_local_address(&mut buf) { - Ok(b) => b, - Err(_) => return Ok(()), - }; - let is_ipv6 = match address_bytes.len() { - 4 => false, - 16 => true, - _ => return Ok(()), - }; - // Format with `SocketAddrV{4,6}` so `format_ip`'s strip logic sees the - // expected `addr:port` / `[addr]:port` shape. - let formatted: &[u8] = match address_bytes.len() { - 4 => bun_core::fmt::format_ip( - &std::net::SocketAddrV4::new( - std::net::Ipv4Addr::from(<[u8; 4]>::try_from(address_bytes).unwrap()), - 0, - ), - &mut text_buf, - ) - .unwrap(), - 16 => bun_core::fmt::format_ip( - &std::net::SocketAddrV6::new( - std::net::Ipv6Addr::from(<[u8; 16]>::try_from(address_bytes).unwrap()), - 0, - 0, - 0, - ), - &mut text_buf, - ) - .unwrap(), - _ => return Ok(()), - }; - let address_js = EncodedSlice::latin1(formatted).to_js(global); + let address_bytes = socket_ref.get_local_address(&mut buf).ok(); + let is_ipv6 = address_bytes.is_some_and(|bytes| bytes.len() == 16); + let address_js = super::socket_body::ip_address_to_js(global, address_bytes)?; + if address_js.is_undefined() { + return Ok(()); + } let port_js = match socket_ref.get_local_port() { Some(p) => JSValue::js_number(p as f64), None => JSValue::UNDEFINED, @@ -1396,6 +979,7 @@ impl Drop for Listener { // Closing the sockets reaches this struct again through their handlers, so no `&mut` spans it. let this = &*self; log!("finalize"); + this.unregister_for_hot_reload(); this.abort_handle.leave(); match this.listener.replace(ListenerType::None) { ListenerType::Uws(socket) => { @@ -1442,6 +1026,7 @@ fn connect_finish( default_data: JSValue, allow_half_open: bool, pause_on_connect: bool, + fd_is_write_only: bool, port: Option, promise_value: JSValue, ) -> JsResult { @@ -1454,46 +1039,26 @@ fn connect_finish( // still-connecting socket. Close the previous native socket before // reusing this wrapper so `do_connect` does not alias two native // sockets onto one ext slot. - prev.detach_for_reconnect(); + if !prev.detach_for_reconnect() { + return Ok(JSValue::UNDEFINED); + } // Dropping the previous `Rc` here is safe even mid-callback: a `Scope` // from a `data`/`close` handler that synchronously re-entered `connect` // still holds its own reference. prev.set_handlers(cx.global(), Some(handlers)); debug_assert!(prev.socket.get().is_detached()); - // Free old resources before reassignment to prevent memory leaks - // when sockets are reused for reconnection (common with MongoDB driver) - prev.connection.set(Some(connection)); - prev.local_binding.set(local_binding); - if prev.flags.get().contains(SocketFlags::OWNED_PROTOS) { - prev.protos.set(None); // drop old Box - } - prev.protos.set(ssl.as_mut().and_then(|s| s.take_protos())); - prev.server_name - .set(ssl.as_mut().and_then(|s| s.take_server_name())); - prev.owned_ssl_ctx.set(owned_ssl_ctx); prev } else { - NewSocket::::new(NewSocket:: { - ref_count: bun_ptr::RefCount::init(), - handlers: JsCell::new(Some(handlers)), - socket: Cell::new(uws::NewSocketHandler::::DETACHED), - connection: JsCell::new(Some(connection)), - local_binding: JsCell::new(local_binding), - protos: JsCell::new(ssl.as_mut().and_then(|s| s.take_protos())), - server_name: JsCell::new(ssl.as_mut().and_then(|s| s.take_server_name())), - owned_ssl_ctx: JsCell::new(owned_ssl_ctx), - flags: Cell::new(SocketFlags::default()), - this_value: JsCell::new(jsc::JsRef::empty()), - poll_ref: JsCell::new(KeepAlive::init()), - ref_pollref_on_connect: Cell::new(true), - buffered_data_for_node_net: Default::default(), - bytes_written: 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), - }) + NewSocket::::new(Some(handlers)) }; + socket.connection.set(Some(connection)); + socket.local_binding.set(local_binding); + socket + .protos + .set(ssl.as_mut().and_then(|s| s.take_protos())); + socket + .server_name + .set(ssl.as_mut().and_then(|s| s.take_server_name())); // Either the caller's JS-owned socket (reconnect) or the fresh one above. let socket_ref = socket; socket_ref.ref_(); @@ -1506,71 +1071,36 @@ 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); + f.set(SocketFlags::WRITE_ONLY_FD, fd_is_write_only); }); // Held for the connect attempt regardless of `ref_pollref_on_connect`; `on_open` applies that. socket_ref .poll_ref .with_mut(|p| p.ref_(bun_io::js_vm_ctx())); - // Note: `do_connect` reads `self.connection` directly so no second - // borrow is needed here. - // An already-open fd socket runs `on_open` synchronously; what settling - // the connect promise there left pending is not a connect failure. - let opened_err = match socket_ref.do_connect(cx.context()) { - Ok(()) => None, - Err(crate::Error::Js(err)) => Some(err), - Err(_) => { - // Winsock sets the Win32 last-error, not the CRT `_errno()` that - // `last_errno()` reads. + let opened = match socket_ref.do_connect(cx, owned_ssl_ctx) { + Ok(opened) => opened, + Err(errno) => { + let is_unix = port.is_none(); + // Winsock's AF_UNIX connect doesn't report a missing path as ENOENT, which is what Node reports. #[cfg(windows)] - let os_errno = { - let mut e = bun_sys::last_error() as c_int; - // Node reports ENOENT for a missing pipe path; Winsock's AF_UNIX - // connect error does not, so probe. - if port.is_none() { - if let Some(UnixOrHost::Unix(path)) = socket_ref.connection.get() { - if !bun_sys::exists(path) { - e = bun_sys::SystemErrno::ENOENT as c_int; - } - } + let errno = match socket_ref.connection.get() { + Some(UnixOrHost::Unix(path)) if is_unix && !bun_sys::exists(path) => { + bun_sys::SystemErrno::ENOENT } - e + _ => errno, }; - #[cfg(not(windows))] - let os_errno = bun_sys::last_errno(); - let errno = if port.is_none() { - // Preserve the real errno from the failed connect(2) on a unix path: - // connecting to an existing non-socket file is ENOTSOCK, a - // permission-denied path is EACCES, a missing one is ENOENT. - if os_errno == bun_sys::SystemErrno::ENAMETOOLONG as c_int { - // libuv reports UV_EINVAL for a pipe path it cannot express. - bun_sys::SystemErrno::EINVAL as c_int - } else if os_errno != 0 { - os_errno - } else { - bun_sys::SystemErrno::ENOENT as c_int - } - } else { - // A synchronous TCP connect failure is almost always the local - // bind() (localAddress/localPort) failing - preserve the errnos a - // bind() meaningfully produces (EADDRINUSE: port busy, - // EADDRNOTAVAIL: address not local, EACCES: privileged port, - // EINVAL: address family mismatch); everything else stays - // ECONNREFUSED. Mirrors handle_connect_error's whitelist. - if os_errno == bun_sys::SystemErrno::EADDRINUSE as c_int - || 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 - { - os_errno - } else { - bun_sys::SystemErrno::ECONNREFUSED as c_int - } + let errno = match errno { + // libuv reports UV_EINVAL for a pipe path it cannot express. + bun_sys::SystemErrno::ENAMETOOLONG if is_unix => bun_sys::SystemErrno::EINVAL, + bun_sys::SystemErrno::SUCCESS if is_unix => bun_sys::SystemErrno::ENOENT, + bun_sys::SystemErrno::SUCCESS => bun_sys::SystemErrno::ECONNREFUSED, + errno => errno, }; { let this = socket; - let handled = NewSocket::::handle_connect_error(this, errno, 0); + let handled = NewSocket::::handle_connect_error(this, errno); // Balance the unconditional `socket_ref.ref_()` above. NewSocket::deref(&this); // A `connectError` handler that threw on this synchronous failure @@ -1581,36 +1111,63 @@ fn connect_finish( } }; - // What settling the connect promise in `on_open` left pending (allocation - // failure, a terminating VM). - if let Some(err) = opened_err { - return Err(err); - } + // What settling the connect promise in `on_open` left pending: an allocation failure, a terminating VM. + opened?; Ok(promise_value) } -/// `tls.Server#setSecureContext()` while listening. Accepted sockets keep their context. -pub(crate) fn set_secure_context(handle: Option<&Listener>, context: Option<&SecureContext>) { - let (Some(this), Some(secure_context)) = (handle, context) else { +/// `None` unless `listener` is listening on a socket, with TLS. +fn tls_listen_socket<'a>(listener: &TLSListener<'a>) -> Option<&'a mut uws_sys::ListenSocket> { + let listen_socket = match listener { + TLSListener::Listener(this) => match this.listener.get() { + ListenerType::Uws(listen_socket) if this.ssl => listen_socket, + _ => return None, + }, + TLSListener::Https(server) => server.listener.get()?.cast(), + TLSListener::DebugHttps(server) => server.listener.get()?.cast(), + TLSListener::Ignored(_) => return None, + }; + Some(bun_opaque::opaque_deref_mut(listen_socket)) +} + +/// `addContext()` of a `tls.Server` or an `https.Server` that is listening. +pub(crate) fn add_server_name(listener: TLSListener<'_>, 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; + } + let Some(listen_socket) = tls_listen_socket(&listener) else { return; }; - if !this.ssl { + let hostname = bun_core::ZBox::from_bytes(hostname); + listen_socket.add_server_name(hostname.as_zstr().as_cstr(), context.ctx.as_ptr()); +} + +/// `setSecureContext()` of a `tls.Server` or an `https.Server` that is listening. Accepted sockets keep their context. +pub(crate) fn set_secure_context(listener: TLSListener<'_>, context: &SecureContext) { + if let Some(listen_socket) = tls_listen_socket(&listener) { + listen_socket.set_default_ssl_ctx(&context.ctx); + } + let TLSListener::Listener(this) = listener else { return; + }; + if this.ssl && !matches!(this.listener.get(), ListenerType::None) { + this.secure_ctx.set(Some(context.ctx.clone())); } - let ctx = secure_context.ctx.clone(); - match this.listener.get() { - ListenerType::Uws(ls) => { - // S008: `ListenSocket` is an `opaque_ffi!` ZST — safe deref. - bun_opaque::opaque_deref_mut(ls).set_default_ssl_ctx(&ctx); - this.secure_ctx.set(Some(ctx)); - } - #[cfg(windows)] - ListenerType::NamedPipe(pipe) => { - // SAFETY: the pipe context is live while `this.listener` holds it. - unsafe { pipe.as_ref() }.ctx.set(Some(ctx)); - } - ListenerType::None => {} +} + +/// 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)] @@ -1640,6 +1197,48 @@ fn normalize_pipe_name<'a>(pipe_name: &[u8], buffer: &'a mut [u8]) -> Option<&'a Some(&buffer[0..pipe_name.len()]) } +/// What a connection dials if it's a named pipe. +#[cfg(windows)] +pub(crate) enum NamedPipe<'a> { + Path(&'a [u8]), + Fd(Fd), +} + +/// Writes a pipe's normalized name to `buffer`. +#[cfg(windows)] +pub(crate) fn named_pipe<'a>( + connection: &UnixOrHost, + buffer: &'a mut [u8], +) -> Option> { + use bun_sys::FdExt as _; + match connection { + // we check if the path is a named pipe otherwise we try to connect using AF_UNIX + UnixOrHost::Unix(path) => normalize_pipe_name(path, buffer).map(NamedPipe::Path), + UnixOrHost::Fd(fd) if fd.kind() == bun_core::FdKind::System => None, + UnixOrHost::Fd(fd) => { + let uvfd = fd.uv(); + let fd_type = uv::uv_guess_handle(uvfd); + if fd_type == uv::HandleType::NamedPipe { + return Some(NamedPipe::Fd(*fd)); + } + // is not a libuv fd, check if it's a named pipe + let osfd: uv::uv_os_fd_t = uvfd as usize as uv::uv_os_fd_t; + if fd_type != uv::HandleType::Unknown + || bun_sys::windows::GetFileType(osfd) != bun_sys::windows::FILE_TYPE_PIPE + { + return None; + } + // yay its a named pipe lets make it a libuv fd + Some(NamedPipe::Fd( + Fd::from_system(osfd) + .make_lib_uv_owned() + .unwrap_or_else(|_| panic!("failed to allocate file descriptor")), + )) + } + UnixOrHost::Host { .. } => None, + } +} + #[cfg(windows)] pub(crate) struct WindowsNamedPipeListeningContext { pub(crate) uv_pipe: uv::Pipe, @@ -1653,14 +1252,11 @@ pub(crate) struct WindowsNamedPipeListeningContext { /// JSC_BORROW: process-lifetime singleton; `&'static` so call sites read /// `self.vm.is_shutting_down()` without a raw-pointer deref. pub(crate) vm: &'static VirtualMachine, - /// Every accept wraps its pipe with this context. - pub ctx: JsCell>, } -/// `c_int`: raw libuv return code so JS `err.errno` is the platform-correct UV value. #[cfg(windows)] enum ListenPipeError { - Sys(bun_sys::Error, c_int), + Sys(bun_sys::Error), Other(crate::Error), } @@ -1681,37 +1277,31 @@ impl WindowsNamedPipeListeningContext { let listener: &Listener = listener_ref.get(); // An accepted pipe is the listening script's. let entered = this_ref.vm.enter_context(listener.context); - use crate::socket::windows_named_pipe_context::SocketType as PipeSocketType; + use crate::socket::windows_named_pipe::SocketType as PipeSocketType; // Owned for the whole accept: JS below can replace the slot through setSecureContext(). - let ssl_ctx = this_ref.ctx.get().clone(); + let ssl_ctx = listener.secure_ctx.get().clone(); let socket: PipeSocketType = if ssl_ctx.is_some() { PipeSocketType::Tls(Listener::on_name_pipe_created::(listener)) } else { PipeSocketType::Tcp(Listener::on_name_pipe_created::(listener)) }; - let client = WindowsNamedPipeContext::create( - &this_ref.global_this.js_thread(entered.context()), - socket, - ); + let client = + WindowsNamedPipe::create(&this_ref.global_this.js_thread(entered.context()), socket); // SAFETY: `client` was just heap-allocated by `create()`; exclusive // here. The `&mut` to `uv_pipe` comes from the root pointer, scoped to // this call — `this_ref` (shared) never touches `uv_pipe`. - let result = unsafe { - (*client) - .named_pipe - .get_accepted_by(&mut (*this).uv_pipe, ssl_ctx.as_ref()) - }; + let result = unsafe { (*client).get_accepted_by(&mut (*this).uv_pipe, ssl_ctx.as_ref()) }; if result.is_err() { // connection dropped - // Release the only ref, which goes 1→0 → schedule_deinit → next-tick free. The + // Release the only ref, which goes 1→0 → queue_free → next-tick free. The // deferred path is required because `get_accepted_by` may have already `uv_pipe_init`'d // the client's inner handle on the loop; freeing the backing storage in-callback // before `uv_close` completes is the exact pattern libuv forbids. - // SAFETY: `client` was just allocated via `WindowsNamedPipeContext::create` + // SAFETY: `client` was just allocated via `WindowsNamedPipe::create` // with refcount==1; releasing the only ref schedules deinit. - unsafe { WindowsNamedPipeContext::deref(client) }; + unsafe { WindowsNamedPipe::deref(client) }; } } @@ -1752,7 +1342,6 @@ impl WindowsNamedPipeListeningContext { global_this: &JSGlobalObject, path: &[u8], backlog: i32, - ssl_config: Option<&SSLConfig>, listener: *mut Listener, ) -> Result<*mut WindowsNamedPipeListeningContext, ListenPipeError> { // Heap-allocate at the final address so libuv can @@ -1762,12 +1351,8 @@ impl WindowsNamedPipeListeningContext { listener: NonNull::new(listener).map(bun_ptr::BackRef::from), global_this: GlobalRef::from(global_this), vm: global_this.bun_vm(), - ctx: JsCell::new(None), })); - // Cleanup guard: once the uv pipe handle is registered with the loop it must be closed via - // uv_close; before that point we can free the struct directly. `deinit()` also - // frees the SSL context if one was created. State `.1` flips once `uv_pipe_init` - // succeeds; disarmed via `into_inner` on success. + // A pipe handle that is registered with the loop must be closed via uv_close. `.1` flips once `uv_pipe_init` succeeds. let mut cleanup = scopeguard::guard((this, false), |(this, pipe_initialized)| { if pipe_initialized { // SAFETY: pipe is registered with the loop; close → on_pipe_closed → deinit. @@ -1777,21 +1362,6 @@ impl WindowsNamedPipeListeningContext { } }); - if let Some(ssl_options) = ssl_config { - boringssl::load(); - - let ctx_opts = ssl_options.as_usockets(); - let mut err = uws::create_bun_socket_error_t::none; - // Create SSL context using uSockets to match behavior of node.js - match ctx_opts.create_ssl_context_with_digest(&ctx_opts.digest(), &mut err) { - // SAFETY: `this` was just allocated above; scoped field write. - Some(ctx) => unsafe { - (*this).ctx.set(Some(ctx)); - }, - None => return Err(ListenPipeError::Other(crate::Error::InvalidOptions)), - } - } - // SAFETY: `this` was just allocated above; `&mut uv_pipe` is scoped to // this call. let init_result = unsafe { (*this).uv_pipe.init((*this).vm.uv_loop().cast(), false) }; @@ -1832,9 +1402,8 @@ impl WindowsNamedPipeListeningContext { // EACCES (pipe namespace denied) need different caller // handling, and a generic bind failure hides that. use bun_sys::ReturnCodeExt as _; - let raw = listen_rc.int(); return Err(match listen_rc.to_error(bun_sys::Tag::listen) { - Some(err) => ListenPipeError::Sys(err, raw), + Some(err) => ListenPipeError::Sys(err), // Unreachable in practice: the uv→errno mapping is total. None => ListenPipeError::Other(crate::Error::FailedToBindPipe), }); @@ -1867,110 +1436,95 @@ impl WindowsNamedPipeListeningContext { } } -/// `us_dispatch_server_name` for a socket adopted into TLS, which has no listen -/// socket. Only the fd-adopt path registers this, not the duplex wrap. -/// -/// # Safety -/// `socket` is the live us_socket_t processing this ClientHello and `hostname` -/// is NUL-terminated for the call. JS-thread only. -#[unsafe(no_mangle)] -pub(crate) extern "C" fn us_dispatch_socket_server_name( - socket: *mut uws_sys::us_socket_t, - hostname: *const core::ffi::c_char, - abort_handshake: Option<&mut core::ffi::c_int>, -) -> *mut uws_sys::SslCtx { - dispatch_server_name(socket, hostname, abort_handshake).cast() +/// 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, } -/// 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: Option<&mut core::ffi::c_int>, -) -> *mut c_void { - if result.is_boolean() && result.to_boolean() { - if let Some(abort_handshake) = abort_handshake { - *abort_handshake = 2; +impl ServerNameAnswer { + pub(crate) 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) => Self::Context(sc.ctx.clone()), + // 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 a `ListenSocket::on_server_name` callback returns to openssl.c. + pub(crate) fn into_raw( + self, + abort_handshake: Option<&mut core::ffi::c_int>, + ) -> *mut uws_sys::SslCtx { + 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(), + Self::Refused => 1, + Self::Pending => 2, + }; if let Some(abort_handshake) = abort_handshake { - *abort_handshake = 1; + *abort_handshake = code; } - return core::ptr::null_mut(); + 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(); - } - // Anything else is not a SecureContext: Node treats this as an invalid SNI - // context and drops the connection. - if let Some(abort_handshake) = abort_handshake { - *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 a ClientHello with a server name, on an `SSL` of either engine that `on_open` gave a resolver. /// /// # 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( - _ls: *mut uws_sys::ListenSocket, - hostname: *const core::ffi::c_char, - abort_handshake: Option<&mut core::ffi::c_int>, - socket: *mut c_void, -) -> *mut c_void { - dispatch_server_name(socket.cast(), hostname, abort_handshake) -} - -fn dispatch_server_name( - socket: *mut uws_sys::us_socket_t, +/// `bun_socket` is the live `TLSSocket` that owns the `SSL`, and `hostname` is NUL-terminated for the call. JS-thread only. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn us_dispatch_server_name( + bun_socket: *mut c_void, hostname: *const core::ffi::c_char, abort_handshake: Option<&mut core::ffi::c_int>, -) -> *mut c_void { +) -> *mut uws_sys::SslCtx { jsc::mark_binding!(); - if socket.is_null() || hostname.is_null() { - return core::ptr::null_mut(); - } - let s_ref = uws_sys::us_socket_t::opaque_mut(socket); - if s_ref.kind() != uws_sys::SocketKind::BunSocketTls { + if bun_socket.is_null() { return core::ptr::null_mut(); } - let tls = match *s_ref.ext::>>() { - Some(tls) => tls, - None => return core::ptr::null_mut(), + // SAFETY: fn contract. + let (tls, name) = unsafe { + ( + bun_ptr::ThisPtr::new(bun_socket.cast::()), + core::ffi::CStr::from_ptr(hostname), + ) }; + ask_server_name(tls, name).into_raw(abort_handshake) +} + +/// Asks the `serverName` handler of a server-side socket. +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`. + // `get_handlers()` would panic. Same guard as `us_dispatch_alpn_select`. 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 // handshake corrupts `active_connections` for every subsequent accept. let handlers = tls.get_handlers(); let callback = handlers.on_server_name(); - if callback.is_empty() { - return core::ptr::null_mut(); + if callback.is_empty() + || handlers.vm.script_execution_status() != jsc::ScriptExecutionStatus::Running + { + return ServerNameAnswer::Default; } let global = handlers.global_object; // The resume handle an asynchronous SNICallback uses (`handle.resumeSNI(...)`). @@ -1984,8 +1538,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]) { @@ -2001,5 +1553,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..cd5692a128b7 100644 --- a/src/runtime/socket/SSLConfig.bind.ts +++ b/src/runtime/socket/SSLConfig.bind.ts @@ -1,5 +1,7 @@ import * as b from "../../codegen/bindgen-lib.ts"; +export default b.module({ rustPath: "crate::socket::ssl_config" }); + export const TLSOptionsSingleFile = b.union({ string: b.string, buffer: b.bufferSource.arrayBuffer, @@ -20,6 +22,16 @@ export const ALPNProtocols = b.union({ buffer: b.bufferSource.arrayBuffer, }); +/** The protocol code for one of node:tls's names, such as "TLSv1.2". 0 for any other string. */ +export const protocolVersionCode = b.fn({ args: { name: b.string.utf8 }, returns: b.i32 }); + +/** One of node:tls's names, 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 +44,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 d6ca9ac73060..28b30234062e 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -1,15 +1,4 @@ -//! DEDUP(D202): the `SSLConfig` struct, its `Clone`/`Drop`/`Default`/hash/ -//! equality/registry impls, and `as_usockets*`/`for_client_verification` were -//! duplicated (here and in `bun_http::ssl_config`). The lower-tier -//! `bun_http` copy is canonical (JSC-free, nullable NUL-terminated C-string -//! field layout); this module now re-exports it and keeps ONLY the -//! JSC-dependent constructors (`from_js` / `from_generated` / blob+path -//! readers) plus the WebSocket C-ABI exports, which need `bun_jsc` / -//! `webcore::Blob` / `node_fs` (tier-6). -//! -//! `from_js`/`from_generated` cannot be inherent `impl SSLConfig` (orphan -//! rule on a foreign type), so they're provided via the [`SSLConfigFromJs`] -//! extension trait. Import that trait to call `SSLConfig::from_js(..)`. +//! The JSC-dependent half of `bun_http::ssl_config`: parsing a `tls` object needs `bun_jsc`, `webcore::Blob` and `node_fs`. use core::ffi::c_char; @@ -115,47 +104,70 @@ fn read_from_blob( Ok(zbox_into_raw(&zbox)) } -// ────────────────────────────────────────────────────────────────────────── -// fromJS / fromGenerated — extension trait (orphan-rule workaround) -// ────────────────────────────────────────────────────────────────────────── - -/// 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. -pub(crate) trait SSLConfigFromJs: Sized { - fn from_js( - vm: &VirtualMachine, - global: &JSGlobalObject, - value: JSValue, - is_server: bool, - ) -> JsResult>; +/// 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 protocol_version_code(name.to_utf8().slice()) { + 0 => Err(global + .err( + jsc::ErrorCode::TLS_INVALID_PROTOCOL_VERSION, + format_args!("\"{name}\" is not a valid {bound} TLS protocol version"), + ) + .throw()), + code => Ok(code), + } +} - fn from_generated( - vm: &VirtualMachine, - global: &JSGlobalObject, - generated: &generated::TLSOptions, - is_server: bool, - ) -> JsResult>; +pub(crate) fn protocol_version_code(name: &[u8]) -> i32 { + match name { + b"TLSv1" => 0x0301, + b"TLSv1.1" => 0x0302, + b"TLSv1.2" => 0x0303, + b"TLSv1.3" => 0x0304, + _ => 0, + } } -impl SSLConfigFromJs for SSLConfig { - fn from_js( +/// What a `tls` object asks for. `None` inside: it sets no option, like `tls: {}`, and each API decides if that still turns TLS on. +pub(crate) struct ParsedTls(Option); + +impl ParsedTls { + /// For an API where `tls: {}` means `tls: true`. + pub(crate) fn or_defaults(self, vm: &VirtualMachine, is_server: bool) -> SSLConfig { + self.0 + .unwrap_or_else(|| vm.tls_config(Default::default(), is_server)) + } + + /// For an API where `tls: {}` means no `tls`. + pub(crate) fn or_none(self) -> Option { + self.0 + } + + pub(crate) fn from_js( vm: &VirtualMachine, global: &JSGlobalObject, value: JSValue, is_server: bool, - ) -> JsResult> { + ) -> JsResult { let generated = generated::TLSOptions::from_js(global, value)?; Self::from_generated(vm, global, &generated, is_server) } - fn from_generated( + pub(crate) fn from_generated( vm: &VirtualMachine, global: &JSGlobalObject, generated: &generated::TLSOptions, is_server: bool, - ) -> JsResult> { - let mut result = SSLConfig::zero(); + ) -> JsResult { + let mut result = bun_http::ssl_config::Builder::default(); // `result` cleanup handled by Drop on error-path `?` let mut any = false; @@ -180,14 +192,11 @@ impl SSLConfigFromJs for SSLConfig { } result.low_memory_mode = generated.low_memory_mode; - result.reject_unauthorized = generated - .reject_unauthorized - .unwrap_or_else(|| is_server || vm.get_tls_reject_unauthorized()) - as i32; + result.reject_unauthorized = generated.reject_unauthorized; 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() { @@ -258,7 +267,6 @@ impl SSLConfigFromJs for SSLConfig { } if let Some(ciphers) = generated.ciphers.as_ref() { result.ssl_ciphers = zbox_into_raw(&ciphers.to_owned_slice_z()); - result.is_using_default_ciphers = false; result.requires_custom_request_ctx = true; } @@ -270,38 +278,28 @@ impl SSLConfigFromJs for SSLConfig { || generated.client_renegotiation_window != 0; if !any { - return Ok(None); - } - // After `any`: the fallback does not make `tls: {}` a TLS configuration. - if generated.ciphers.is_none() { - apply_default_ciphers(vm, &mut result); + return Ok(Self(None)); } - Ok(Some(result)) + Ok(Self(Some(vm.tls_config(result, is_server)))) } } -/// The `SSLConfig` for the `tls: true` shorthand: every option at its -/// documented default, unlike `SSLConfig::zero()`. -pub(crate) fn tls_true_defaults(vm: &VirtualMachine, is_server: bool) -> SSLConfig { - let mut cfg = SSLConfig::zero(); - cfg.reject_unauthorized = (is_server || vm.get_tls_reject_unauthorized()) as i32; - apply_default_ciphers(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()?; - Some(bun_http::ssl_config::global_registry::intern( - tls_true_defaults(vm, false), - )) -} - -/// No `ciphers` option means `tls.DEFAULT_CIPHERS`, as in Node's `configSecureContext`. -fn apply_default_ciphers(vm: &VirtualMachine, cfg: &mut SSLConfig) { - if let Some(ciphers) = vm.tls_default_ciphers() { - cfg.set_default_ciphers(ciphers); + if !vm.tls_client_defaults_set() { + return None; } + // `NODE_TLS_REJECT_UNAUTHORIZED` can change between requests. + let reject_unauthorized = vm.get_tls_reject_unauthorized() as i32; + let cached = vm.as_mut().rare_data().http_client_tls_defaults.clone(); + if let Some(config) = cached.filter(|config| config.reject_unauthorized == reject_unauthorized) + { + return Some(config); + } + let config = + bun_http::ssl_config::global_registry::intern(vm.tls_config(Default::default(), false)); + vm.as_mut().rare_data().http_client_tls_defaults = Some(config.clone()); + Some(config) } /// Whether a new TLS socket must enforce `rejectUnauthorized`: close the @@ -459,7 +457,7 @@ fn handle_single_file( // WebSocket C-ABI exports (parseSSLConfig / freeSSLConfig) // // LAYERING: the consumer is `src/http_jsc/websocket_client/ -// WebSocketUpgradeClient.rs`, but `SSLConfig::from_js` +// WebSocketUpgradeClient.rs`, but `ParsedTls::from_js` // dereferences Blob / JSCArrayBuffer / node_fs values (tier-6) and lives in // this crate. `bun_runtime → bun_http_jsc`, so hosting the export here breaks // the cycle without an opaque stub. The boxed payload is the canonical @@ -479,16 +477,9 @@ extern "C" fn Bun__WebSocket__parseSSLConfig( // SAFETY: `bun_vm()` returns the live VM for this global; the WebSocket // constructor only runs on the JS thread with an initialized VM. let vm = global_this.bun_vm(); - // Use SSLConfig::from_js for clean and safe parsing - let config_opt = match SSLConfig::from_js(vm, global_this, tls_value, false) { - Ok(c) => c, - // Exception is already set on globalThis - Err(_) => return None, - }; - // No TLS options provided or all defaults → null - let config = config_opt?; - // Allocate on heap and return pointer (ownership transferred to caller). - Some(Box::new(config)) + // On `Err` the exception is already set on `global_this`. `tls: {}` is a WebSocket without TLS options. + let config = ParsedTls::from_js(vm, global_this, tls_value, false).ok()?; + config.or_none().map(Box::new) } /// Free an SSLConfig previously returned by `parseSSLConfig`. diff --git a/src/runtime/socket/SocketConfig.bind.ts b/src/runtime/socket/SocketConfig.bind.ts index 7182eb77acca..4c7a4db36e9b 100644 --- a/src/runtime/socket/SocketConfig.bind.ts +++ b/src/runtime/socket/SocketConfig.bind.ts @@ -22,6 +22,8 @@ export const SocketHandler = b.dictionary( keylog: { type: b.any, rustName: "on_keylog" }, serverName: { type: b.any, rustName: "on_server_name" }, alpnCallback: { type: b.any, rustName: "on_alpn_callback" }, + /** node:net only: how a `$write` that answered "queued" ended. */ + writeComplete: { type: b.any, rustName: "on_write_complete" }, binaryType: SocketHandlerBinaryType.default("buffer"), }, { fromJS: true }, @@ -50,6 +52,13 @@ export const SocketOptions = b.dictionary( rustName: "unix_", // The C++ struct has the same field, and `unix` is a predefined C macro. }, fd: b.i32.allowBigInt.optional, + /** Windows, `Bun.connect` only: `fd` is a SOCKET and not a libuv descriptor. */ + fdIsRawSocket: b.bool.loose.default(false), + /** node:net only: `fd` is a pipe, a terminal or a socket that is only written to, in its own blocking mode. */ + fdIsWriteOnly: b.bool.default(false), + /** node:net only: the literal IP and the port to bind before connecting. */ + localAddress: b.string.falsyAsNull, + localPort: b.f64.falsyAsNull, }, { fromJS: true }, ); diff --git a/src/runtime/socket/UpgradedDuplex.bind.ts b/src/runtime/socket/UpgradedDuplex.bind.ts index d34e5023d9d2..30ac6dc7b9fe 100644 --- a/src/runtime/socket/UpgradedDuplex.bind.ts +++ b/src/runtime/socket/UpgradedDuplex.bind.ts @@ -13,5 +13,4 @@ export const onReceivedData = b.fn({ args: { data: DuplexChunk }, }); export const onEnd = b.fn({ ...listener, export: "Bun__UpgradedDuplex__onEnd" }); -export const onWritable = b.fn({ ...listener, export: "Bun__UpgradedDuplex__onWritable" }); export const onCloseJS = b.fn({ ...listener, export: "Bun__UpgradedDuplex__onCloseJS" }); diff --git a/src/runtime/socket/UpgradedDuplex.rs b/src/runtime/socket/UpgradedDuplex.rs index 8acbcdfa46f7..dc210c4f1385 100644 --- a/src/runtime/socket/UpgradedDuplex.rs +++ b/src/runtime/socket/UpgradedDuplex.rs @@ -9,46 +9,66 @@ //! file descriptors), //! //! The duplex stream manages the SSL handshake, certificate validation, encryption/decryption, -//! and integrates with Bun's event loop for timeouts and async operations. It maintains +//! and queues its start and its free on Bun's event loop. It maintains //! JavaScript callbacks for handling connection events and errors. -use core::cell::Cell; -use core::ffi::{CStr, c_uint, c_void}; +use core::cell::{Cell, OnceCell}; +use core::ffi::c_void; +use core::sync::atomic::{AtomicUsize, Ordering}; use bun_jsc::bindgen::{JsLocal, JsScope}; use bun_jsc::virtual_machine::VirtualMachine; use bun_jsc::{GlobalRef, JSGlobalObject, JSValue, JsCell, JsResult, host_fn}; -use bun_uws::{us_bun_verify_error_t, uws_callback}; +use bun_ptr::{RefPtr, ThisPtr}; +use bun_uws::ssl_wrapper::{self, SSLWrapper}; +use bun_uws::{Transport, us_bun_verify_error_t}; -use super::DuplexUpgradeContext; -use super::ssl_wrapper::SSLWrapper; +use super::TLSSocket; +use crate::dispatch::fold; use crate::generated_bindgen::socket_body::js_TLSSocket; use crate::generated_bindgen::upgraded_duplex::{self as listeners, DuplexChunk}; -use crate::timer::{ElTimespec, EventLoopTimer, EventLoopTimerState, EventLoopTimerTag}; bun_output::declare_scope!(UpgradedDuplex, visible); +/// Live engines, read by `bun:internal-for-testing` leak tests. +pub(crate) static LIVE_COUNT: AtomicUsize = AtomicUsize::new(0); + +#[derive(bun_ptr::CellRefCounted)] +#[ref_count(destroy = Self::queue_free)] pub(crate) struct UpgradedDuplex { - pub wrapper: JsCell>, + /// One ref for the open connection and one per entry point on the stack ([`Self::keep_alive`]). At zero the `Close` hop frees `self`. + ref_count: Cell, + handle: bun_uws::TransportHandle, + /// The connection's ref is still held. Cleared by [`Self::take_connection_ref`]. + connection_ref: Cell, + /// The socket that hears this engine's events. `None` once it heard its close or its connect error. + tls: JsCell>>, + task_event: Cell, + /// A `task_event` hop is in the VM's queue. It holds `self` by pointer, so at most one may exist. + queued: Cell, + /// Taken by the `StartTLS` hop. + ctx: JsCell>, + is_open: Cell, + is_client: bool, + server_verify: ServerVerify, + /// No uSockets group holds this socket, so the context that upgraded it closes it through this owner when it stops. + abort_handle: bun_jsc::AbortHandle, + /// That context. + context: bun_jsc::ContextId, + /// Set by `start_tls` and freed with `self`: a `handle_traffic` frame can be on the stack when the connection closes. + wrapper: OnceCell, /// The owning `JSTLSSocket` wrapper. Its `internalFields` 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 // JSC_BORROW per LIFETIMES.tsv. - pub global: Option, - pub ssl_error: JsCell, - // JSC_BORROW per LIFETIMES.tsv. `Option` so the struct is zero-initializable - // (socket_body.rs `DuplexUpgradeContext` two-phase init builds this field as - // `zeroed()` before overwriting via `from()`). - pub vm: Option<&'static VirtualMachine>, - pub handlers: Handlers, + pub global: GlobalRef, + // JSC_BORROW per LIFETIMES.tsv. + pub vm: &'static VirtualMachine, 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, /// Transport bytes that arrived before the TLS engine existed. /// /// `upgrade_duplex_to_tls` defers `start_tls` to an event-loop task (so @@ -69,21 +89,10 @@ pub(crate) struct UpgradedDuplex { paused_origin: Cell, } -bun_event_loop::impl_timer_owner!(UpgradedDuplex; from_timer_ptr => event_loop_timer); - -#[derive(Default)] -pub(crate) struct CertError { - pub(crate) error_no: i32, - // Owned NUL-terminated copies. `None` represents the default `""`. - pub(crate) code: Option>, - pub reason: Option>, -} -// `Box` drops automatically — no explicit Drop needed. - type WrapperType = SSLWrapper<*mut UpgradedDuplex>; /// Server-side peer-certificate policy for a duplex TLS upgrade, resolved in -/// `upgrade_duplex_to_tls` and applied via `SSLWrapper::set_server_verify`. +/// `UpgradeOptions::server_verify` and applied via `SSLWrapper::set_server_verify`. /// Ignored for client upgrades. #[derive(Clone, Copy)] pub(crate) struct ServerVerify { @@ -93,31 +102,15 @@ pub(crate) struct ServerVerify { pub reject_unauthorized: bool, } -pub(crate) struct Handlers { - // BACKREF per LIFETIMES.tsv — container holding self as `.upgrade`. - pub ctx: *mut (), - pub(crate) on_open: fn(*mut ()), - pub(crate) on_handshake: fn(*mut (), bool, us_bun_verify_error_t), - pub(crate) on_data: fn(*mut (), &[u8]), - pub on_close: fn(*mut ()), - pub(crate) on_end: fn(*mut ()), - pub(crate) on_writable: fn(*mut ()), - pub(crate) on_error: fn(*mut (), JSValue), - pub(crate) on_timeout: fn(*mut ()), - /// A new resumable TLS session (serialized SSL_SESSION) - node's - /// `'session'` event on the wrapping TLSSocket. - pub(crate) on_session: fn(*mut (), &[u8]), - /// An NSS key-log line - node's `'keylog'` event. - pub(crate) on_keylog: fn(*mut (), &[u8]), - pub(crate) server_identity: - fn(*mut (), &mut bun_boringssl_sys::SSL) -> bun_boringssl::ServerIdentity, +#[derive(Clone, Copy, PartialEq, Eq)] +enum EventState { + StartTLS, + Close, } -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 internal field (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] @@ -146,12 +139,12 @@ impl UpgradedDuplex { #[inline] fn wrapper_ref(&self) -> Option<&WrapperType> { - self.wrapper.get().as_ref() + self.wrapper.get() } - /// Holds a ref on the owning context until the returned guard drops. + /// Holds a ref on `self` until the returned guard drops. /// - /// The context must not be freed while a frame of this engine is on the + /// `self` must not be freed while a frame of this engine is on the /// stack. It frees itself from a task that it queues once the connection /// has closed and nothing holds it, and a callback only has to turn the /// event loop synchronously for queued tasks to run under that frame @@ -160,74 +153,72 @@ impl UpgradedDuplex { /// the engine. An entry point that dispatches to a handler, to the TLS /// engine or to the wrapped stream holds this guard, so the free waits /// until the entry point is done. - fn keep_alive(&self) -> bun_ptr::RefPtr { - // SAFETY: `handlers.ctx` is the live context that holds `self` as `.upgrade`. - DuplexUpgradeContext::keep_alive(unsafe { bun_ptr::ThisPtr::new(self.handlers.ctx.cast()) }) + fn keep_alive(&self) -> RefPtr { + debug_assert!( + self.ref_count.get() > 0, + "UpgradedDuplex entered after its last ref was released" + ); + // SAFETY: `self` is the live heap allocation. + unsafe { RefPtr::init_ref(std::ptr::from_ref(self).cast_mut()) } } - /// A call on the wrapped stream runs JS, like a dispatch to a handler. + /// A handler or the wrapped stream runs JS, which can close the socket and turn the event loop before it returns. #[inline] fn debug_assert_kept_alive(&self) { - // SAFETY: as in `keep_alive`. - unsafe { &*self.handlers.ctx.cast::() }.debug_assert_kept_alive(); + debug_assert!( + self.ref_count.get() > u32::from(self.connection_ref.get()), + "UpgradedDuplex dispatch with no keep_alive() on the stack" + ); } - fn on_open(this: *mut Self) { - bun_output::scoped_log!(UpgradedDuplex, "onOpen"); - // SAFETY: see handler note above. - let this = unsafe { &*this }; - (this.handlers.on_open)(this.handlers.ctx); + /// `self` as the socket handle its `TLSSocket` holds. + pub(crate) fn socket(&self) -> bun_uws::SocketTLS { + bun_uws::SocketTLS::from_transport(&raw const self.handle) } - fn on_data(this: *mut Self, decoded_data: &[u8]) { - bun_output::scoped_log!(UpgradedDuplex, "onData ({})", decoded_data.len()); - // SAFETY: see handler note above. - let this = unsafe { &*this }; - (this.handlers.on_data)(this.handlers.ctx, decoded_data); - } - - fn on_session(this: *mut Self, session: &[u8]) { - bun_output::scoped_log!(UpgradedDuplex, "onSession ({})", session.len()); - // SAFETY: see handler note above. - let this = unsafe { &*this }; - (this.handlers.on_session)(this.handlers.ctx, session); + /// The socket to dispatch an event to. Every dispatch runs under an entry point's [`Self::keep_alive`]. + fn listener(&self) -> Option> { + self.debug_assert_kept_alive(); + self.tls.get().as_ref().map(RefPtr::this_ptr) } - fn on_keylog(this: *mut Self, line: &[u8]) { - bun_output::scoped_log!(UpgradedDuplex, "onKeylog ({})", line.len()); + fn on_open(this: *mut Self) { + bun_output::scoped_log!(UpgradedDuplex, "onOpen"); // SAFETY: see handler note above. let this = unsafe { &*this }; - (this.handlers.on_keylog)(this.handlers.ctx, line); + this.is_open.set(true); + if let Some(tls) = this.listener() { + fold(TLSSocket::on_open(tls, this.socket())); + } } - fn server_identity( - this: *mut Self, - ssl: &mut bun_boringssl_sys::SSL, - ) -> bun_boringssl::ServerIdentity { + fn on_data(this: *mut Self, decoded_data: &[u8]) { + bun_output::scoped_log!(UpgradedDuplex, "onData ({})", decoded_data.len()); // SAFETY: see handler note above. let this = unsafe { &*this }; - (this.handlers.server_identity)(this.handlers.ctx, ssl) + if let Some(tls) = this.listener() { + fold(TLSSocket::on_data(tls, this.socket(), decoded_data)); + } } fn on_handshake(this: *mut Self, handshake_success: bool, ssl_error: us_bun_verify_error_t) { bun_output::scoped_log!(UpgradedDuplex, "onHandshake"); // SAFETY: see handler note above. let this = unsafe { &*this }; - this.ssl_error.set(CertError { - error_no: ssl_error.error_no, - code: ssl_error - .code() - .filter(|_| ssl_error.error_no != 0) - .map(Into::into), - reason: ssl_error - .reason() - .filter(|_| ssl_error.error_no != 0) - .map(Into::into), - }); - (this.handlers.on_handshake)(this.handlers.ctx, handshake_success, ssl_error); + if let Some(tls) = this.listener() { + fold(TLSSocket::on_handshake( + tls, + this.socket(), + handshake_success as i32, + ssl_error, + )); + } // Retry writes parked during the handshake, like openssl.c's `ssl_write_wants_read`. - if handshake_success && !this.is_shutdown() { - (this.handlers.on_writable)(this.handlers.ctx); + if handshake_success + && !this.is_shutdown() + && let Some(tls) = this.listener() + { + fold(TLSSocket::on_writable(tls, this.socket())); } } @@ -237,15 +228,24 @@ impl UpgradedDuplex { unsafe { &*this }.finish_close(); } - pub(super) fn finish_close(&self) { + fn finish_close(&self) { let _keep_alive = self.keep_alive(); // Keep the wrapper (and so its visited `duplex*` slots) reachable - // across `handlers.on_close`, which downgrades the socket's own strong + // across `TLSSocket::on_close`, which downgrades the socket's own strong // self-reference and re-enters JS. let js_wrapper = self.js_wrapper; js_wrapper.ensure_still_alive(); - (self.handlers.on_close)(self.handlers.ctx); + // Taken first: `on_close` consumes this ref, and a throw from `duplex.end()` below must not reach the closed socket. + if let Some(tls) = self.tls.replace(None) { + fold(TLSSocket::on_close( + tls.into_this_ptr(), + self.socket(), + 0, + None, + )); + } + self.release_connection(); // closes the underlying duplex self.call_write_or_end(None, false); // Left paused, a net.Socket transport never reads its peer's FIN and stays open. @@ -253,28 +253,11 @@ impl UpgradedDuplex { self.resume_stream(); } - // Early teardown (struct itself is dropped later by parent). + // Early teardown (the `Close` hop drops the struct later). self.teardown(); js_wrapper.ensure_still_alive(); } - /// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L117-L125 - #[uws_callback(export = "UpgradedDuplex__pause_stream")] - pub(crate) fn pause_stream(&self) -> bool { - let _keep_alive = self.keep_alive(); - // Like a connecting fd: `on_open` forgets the owner's pause, so nothing would resume this one. - let paused = self.wrapper_ref().is_some() && self.call_origin("pause"); - self.paused_origin.set(self.paused_origin.get() || paused); - paused - } - - #[uws_callback(export = "UpgradedDuplex__resume_stream")] - pub(crate) fn resume_stream(&self) -> bool { - let _keep_alive = self.keep_alive(); - self.paused_origin.set(false); - self.call_origin("resume") - } - /// `origin[name]()`. A throw goes to `on_error`. False when the call did not complete. fn call_origin(&self, name: &str) -> bool { self.debug_assert_kept_alive(); @@ -282,9 +265,7 @@ impl UpgradedDuplex { if duplex.is_empty() { return false; } - let Some(global) = self.global else { - return false; - }; + let global = self.global; let result = match duplex.get(&global, name) { Ok(Some(method)) if method.is_callable() => method.call(&global, duplex, &[]), Ok(_) => return false, @@ -293,7 +274,7 @@ impl UpgradedDuplex { match result { Ok(_) => true, Err(err) => { - (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); + self.on_error(global.take_error(err)); false } } @@ -301,14 +282,12 @@ impl UpgradedDuplex { fn call_write_or_end(&self, data: Option<&[u8]>, msg_more: bool) { self.debug_assert_kept_alive(); - // No JS duplex to talk to: the zeroed placeholder, or the owning - // socket's finalizer abandoned it (`abandon_js_side`). + // No JS duplex to talk to: `teardown` ran, or the owning socket's finalizer abandoned it (`abandon_js_side`). let duplex = self.origin.get(); if duplex.is_empty() { return; } - // global is set in `from()` whenever origin is set. - let Some(global) = self.global else { return }; + let global = self.global; // Teardown-phase bytes (close_notify / the trailing end()) aimed at a // duplex whose write side already ended (TLS-inception teardown) only @@ -327,7 +306,7 @@ impl UpgradedDuplex { Ok(false) => {} Ok(true) => return, Err(err) => { - (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); + self.on_error(global.take_error(err)); return; } } @@ -348,7 +327,7 @@ impl UpgradedDuplex { Ok(Some(f)) if f.is_callable() => f, Ok(_) => return, Err(err) => { - (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); + self.on_error(global.take_error(err)); return; } }; @@ -357,18 +336,18 @@ impl UpgradedDuplex { let buffer = match bun_jsc::array_buffer::BinaryType::Buffer.to_js(data, &global) { Ok(b) => b, Err(err) => { - (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); + self.on_error(global.take_error(err)); return; } }; buffer.ensure_still_alive(); if let Err(err) = write_or_end.call(&global, duplex, &[buffer]) { - (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); + self.on_error(global.take_error(err)); } } else { if let Err(err) = write_or_end.call(&global, duplex, &[JSValue::NULL]) { - (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); + self.on_error(global.take_error(err)); } } } @@ -382,14 +361,13 @@ impl UpgradedDuplex { Ok(ended.is_some_and(|ended| ended.to_boolean())) } - fn internal_write(this: *mut Self, encoded_data: &[u8]) { + fn internal_write(this: *mut Self, encoded_data: &[u8]) -> usize { // SAFETY: see handler note above. unsafe { &*this }.write_encrypted(encoded_data); + encoded_data.len() } fn write_encrypted(&self, encoded_data: &[u8]) { - self.reset_timeout(); - // Possible scenarios: // Scenario 1: will not write if vm is shutting down (we cannot do anything about it) // Scenario 2: will not write if a exception is thrown (will be handled by onError) @@ -398,17 +376,8 @@ impl UpgradedDuplex { self.call_write_or_end(Some(encoded_data), true); } - #[uws_callback(export = "UpgradedDuplex__flush")] - pub(crate) fn flush(&self) { - let _keep_alive = self.keep_alive(); - if let Some(w) = self.wrapper_ref() { - let _ = w.flush(); - } - } - fn on_internal_receive_data(&self, data: &[u8]) { if let Some(w) = self.wrapper_ref() { - self.reset_timeout(); w.receive_data(data); return; } @@ -417,11 +386,8 @@ impl UpgradedDuplex { self.pending_data.with_mut(|p| p.extend_from_slice(data)); } - /// Replay bytes that arrived before the engine existed. Called by - /// `DuplexUpgradeContext::run_event` once the `StartTLS` branch has - /// finished its bookkeeping, so the replay is indistinguishable from an - /// ordinary post-start delivery. - pub(super) fn drain_pending(&self) { + /// Replays the bytes that arrived before the engine existed. [`Self::start_tls`] calls it once the engine is up. + fn drain_pending(&self) { let _keep_alive = self.keep_alive(); // Nothing to replay, or the engine never came up (the socket died // before `StartTLS`). Bail before taking so the bytes are not @@ -435,7 +401,6 @@ impl UpgradedDuplex { } // Taking ownership is load-bearing: a re-entrant `teardown()` clears `pending_data`. let staged = self.pending_data.replace(Vec::new()); - self.reset_timeout(); if let Some(w) = self.wrapper_ref() { w.receive_data(&staged); } @@ -449,59 +414,164 @@ impl UpgradedDuplex { } } - pub(crate) fn on_timeout(&self) { - bun_output::scoped_log!(UpgradedDuplex, "onTimeout"); + /// A throw from the wrapped stream, or bytes it delivered in a form TLS can't read. + fn on_error(&self, err_value: JSValue) { + if self.is_open.get() { + if let Some(tls) = self.listener() { + fold(tls.handle_error(err_value)); + return; + } + } else if let Some(tls) = self.tls.replace(None) { + return self.fail_connect(tls); + } + // The socket has closed: uncaught, like what a transport method throws in node. + let vm = self.vm; + let _ = vm + .as_mut() + .uncaught_exception(vm.global(), err_value, false); + } - let has_been_cleared = self.event_loop_timer.get().state == EventLoopTimerState::CANCELLED - || self.vm.is_none_or(|vm| { - vm.script_execution_status() != bun_jsc::ScriptExecutionStatus::Running - }); + /// Reports ECONNREFUSED to a socket that never opened. The still-queued `StartTLS` hop then finds no socket to open. + fn fail_connect(&self, tls: RefPtr) { + self.debug_assert_kept_alive(); + // Neuter the listener thunks while the wrapper is still strongly held. + self.teardown(); + // `tls.socket` is this engine, so `handle_connect_error` consumes the ref `tls` held. + fold(TLSSocket::handle_connect_error( + tls.into_this_ptr(), + bun_sys::SystemErrno::ECONNREFUSED, + )); + } - self.event_loop_timer.with_mut(|t| { - t.state = EventLoopTimerState::FIRED; - t.heap = Default::default(); - }); + /// The connection is over. Returns true the first time: the caller then releases the connection's ref. + fn take_connection_ref(&self) -> bool { + if !self.connection_ref.replace(false) { + return false; + } + // Nothing is left for the owning context to close, and its hook would take a ref. + self.abort_handle.leave(); + true + } - if has_been_cleared { - return; + /// The transport closed, or `StartTLS` could not start it. + fn release_connection(&self) { + if self.take_connection_ref() { + // SAFETY: `self` is live and the ref released is the connection's. + unsafe { Self::deref(std::ptr::from_ref(self).cast_mut()) }; + } + } + + /// The last ref is usually an entry point's guard, which drops while that entry point's `&self` is live: a hop frees `this`. + fn queue_free(this: *mut Self) { + // SAFETY: the count just reached zero; `this` is live until that hop runs. + let this = unsafe { ThisPtr::new(this) }; + debug_assert!( + !this.queued.get(), + "UpgradedDuplex released its last ref with a hop still queued" + ); + this.enqueue(EventState::Close); + } + + fn enqueue(&self, event: EventState) { + self.task_event.set(event); + // An entry that is already in the queue runs the updated `task_event`. + if !self.queued.replace(true) { + self.vm + .event_loop_mut() + .enqueue_task(bun_jsc::Task::init(std::ptr::from_ref(self).cast_mut())); } + } + /// Takes `ThisPtr` because the `Close` hop frees `this`. + pub(crate) fn run_event(this: ThisPtr) { + this.queued.set(false); + match this.task_event.get() { + EventState::StartTLS => this.start_tls(), + // SAFETY: the last ref queued this hop, and no borrow of `*this` is live. + EventState::Close => drop(unsafe { bun_core::heap::take(this.as_ptr()) }), + } + } + + /// Deferred from [`Self::upgrade`], so that `on_open` can't re-enter JS before the caller holds the handle. + fn start_tls(&self) { let _keep_alive = self.keep_alive(); - (self.handlers.on_timeout)(self.handlers.ctx); + // A pre-open error took the socket, and no engine exists whose close would release the connection's ref. + if self.tls.get().is_none() { + return self.release_connection(); + } + // The transport closed while this hop was queued: an engine started now could never handshake. + if self.pending_close.replace(false) { + return self.finish_close(); + } + let handlers = self.wrapper_handlers(); + let ctx = self.ctx.take().expect("StartTLS ran twice"); + let w = self + .wrapper + .get_or_init(|| WrapperType::init_with_ctx(ctx, self.is_client, handlers)); + w.set_server_verify( + self.server_verify.request_cert, + self.server_verify.reject_unauthorized, + ); + w.start(); + self.drain_pending(); } - pub(crate) fn from( + /// Makes `tls` a TLS socket over the JS stream `origin`. Returns the engine, which frees itself after the close. + pub(crate) fn upgrade( global: &JSGlobalObject, - js_wrapper: JSValue, + context: &bun_jsc::ScriptExecutionContext, origin: JSValue, - handlers: Handlers, - ) -> UpgradedDuplex { + tls: RefPtr, + js_wrapper: JSValue, + ctx: bun_boringssl_sys::OwnedSslCtx, + is_client: bool, + server_verify: ServerVerify, + ) -> ThisPtr { js_TLSSocket::duplex_origin_set_cached(js_wrapper, global, origin); - UpgradedDuplex { - vm: Some(global.bun_vm()), + let this = bun_core::heap::into_raw(Box::new(UpgradedDuplex { + ref_count: Cell::new(1), + handle: Default::default(), + connection_ref: Cell::new(true), + tls: JsCell::new(Some(tls)), + task_event: Cell::new(EventState::StartTLS), + queued: Cell::new(false), + ctx: JsCell::new(Some(ctx)), + is_open: Cell::new(false), + is_client, + server_verify, + abort_handle: bun_jsc::AbortHandle::for_owner::(), + context: context.id(), + vm: global.bun_vm(), js_wrapper, origin: Cell::new(origin), - global: Some(GlobalRef::from(global)), - wrapper: JsCell::new(None), - handlers, - ssl_error: JsCell::new(CertError::default()), + global: GlobalRef::from(global), + wrapper: OnceCell::new(), 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, - )), - current_timeout: Cell::new(0), pending_data: JsCell::new(Vec::new()), pending_close: Cell::new(false), pending_shutdown: Cell::new(false), paused_origin: Cell::new(false), + })); + LIVE_COUNT.fetch_add(1, Ordering::Relaxed); + // SAFETY: nothing frees `this` before the hop queued below runs. + let engine = unsafe { ThisPtr::new(this) }; + engine.handle.set(this); + if let Some(tls) = engine.tls.get() { + tls.ref_(); + tls.socket.set(engine.socket()); + // No poll ref: the stream's events drive this engine, so like node's TLSWrap over a JS stream it doesn't hold the loop open. + tls.mark_active(); } + // SAFETY: fully initialised and heap-pinned; dropping the handle disarms it. + unsafe { bun_jsc::AbortHandle::arm_owner(this, context) }; + engine.enqueue(EventState::StartTLS); + engine } 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::(); @@ -533,18 +603,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, - listeners::Bun__UpgradedDuplex__onWritable, - this_ptr, - ), - )?; - array.put_index( - global, - 3, lazy_js_handler( &self.on_close_callback, js_wrapper, @@ -558,81 +616,81 @@ impl UpgradedDuplex { Ok(array) } - fn wrapper_handlers(&self) -> super::ssl_wrapper::Handlers<*mut UpgradedDuplex> { - super::ssl_wrapper::Handlers { + fn wrapper_handlers(&self) -> ssl_wrapper::Handlers<*mut UpgradedDuplex> { + ssl_wrapper::Handlers { ctx: std::ptr::from_ref(self).cast_mut(), on_open: Self::on_open, on_handshake: Self::on_handshake, on_data: Self::on_data, on_close: Self::on_close, write: Self::internal_write, - on_session: Some(Self::on_session), - on_keylog: Some(Self::on_keylog), - server_identity: Some(Self::server_identity), } } - fn install_and_start(&self, wrapper: WrapperType, verify: ServerVerify) { - self.wrapper.set(Some(wrapper)); - let w = self.wrapper_ref().unwrap(); - w.set_server_verify(verify.request_cert, verify.reject_unauthorized); - w.start(); + /// Runs when the connection closes or fails to open, and again in `Drop`. Idempotent. + fn teardown(&self) { + bun_output::scoped_log!(UpgradedDuplex, "deinit"); + if let Some(w) = self.wrapper_ref() { + w.deinit(); + } + + // A late event of `origin` finds no function data instead of a freed `*mut Self`. + self.origin.set(JSValue::ZERO); + for cb in [ + &self.on_data_callback, + &self.on_end_callback, + &self.on_close_callback, + ] { + let value = cb.get(); + if !value.is_empty() { + host_fn::set_function_data(value, None); + cb.set(JSValue::ZERO); + } + } + self.pending_data.set(Vec::new()); + self.pending_close.set(false); + self.pending_shutdown.set(false); } +} - pub(crate) fn start_tls( - &self, - ssl_options: &crate::server::server_config::SSLConfig, - is_client: bool, - verify: ServerVerify, - ) -> Result<(), crate::Error> { - let wrapper = super::ssl_wrapper::init(ssl_options, is_client, self.wrapper_handlers())?; - self.install_and_start(wrapper, verify); - Ok(()) - } - - /// Mirrors `start_tls` but skips the - /// `SSLConfig.asUSockets() → us_ssl_ctx_from_options()` round-trip so a - /// memoised `SecureContext` can be reused on the duplex/named-pipe path. - pub(crate) fn start_tls_with_ctx( - &self, - ctx: bun_boringssl_sys::OwnedSslCtx, - is_client: bool, - verify: ServerVerify, - ) -> Result<(), crate::Error> { - let wrapper = WrapperType::init_with_ctx(ctx, is_client, self.wrapper_handlers())?; - self.install_and_start(wrapper, verify); - Ok(()) +impl Transport for UpgradedDuplex { + /// See `is_shutdown` for the not-yet-started case. + fn is_closed(&self) -> bool { + self.wrapper_ref().is_some_and(|w| w.is_closed()) + } + + /// `None` means `start_tls` has not run yet, not shut down. + fn is_shutdown(&self) -> bool { + self.wrapper_ref().is_some_and(|w| w.is_shutdown()) } - #[uws_callback(export = "UpgradedDuplex__encode_and_write")] - pub(crate) fn encode_and_write(&self, data: &[u8]) -> i32 { - bun_output::scoped_log!(UpgradedDuplex, "encodeAndWrite (len: {})", data.len()); + fn write(&self, data: &[u8]) -> (i32, i32) { + bun_output::scoped_log!(UpgradedDuplex, "write (len: {})", data.len()); let _keep_alive = self.keep_alive(); - if let Some(w) = self.wrapper_ref() { - let written = w.write_data(data).unwrap_or(0); - return i32::try_from(written).expect("int cast"); - } - 0 + let written = self + .wrapper_ref() + .map_or(0, |w| w.write_data(data).unwrap_or(0)); + (i32::try_from(written).expect("int cast"), 0) } - #[uws_callback(export = "UpgradedDuplex__raw_write")] - pub(crate) fn raw_write(&self, encoded_data: &[u8]) -> i32 { + fn raw_write(&self, encoded_data: &[u8]) -> i32 { let _keep_alive = self.keep_alive(); self.write_encrypted(encoded_data); i32::try_from(encoded_data.len()).expect("int cast") } - /// The owning socket wrapper is being finalized: the JS duplex may be dead - /// too and a finalizer dispatches nothing, so the SSL shutdown that - /// follows writes no close_notify and ends nothing — it only unwinds the - /// native side. - #[uws_callback(export = "UpgradedDuplex__abandon_js_side", no_catch)] - pub(crate) fn abandon_js_side(&self) { - self.origin.set(JSValue::ZERO); + /// Half-close like `us_internal_ssl_shutdown`: close_notify (none mid-handshake), then end(). + fn shutdown(&self) { + let _keep_alive = self.keep_alive(); + let Some(w) = self.wrapper_ref() else { + self.pending_shutdown.set(true); + return; + }; + let _ = w.shutdown(false); + self.call_write_or_end(None, false); } - #[uws_callback(export = "UpgradedDuplex__close")] - pub(crate) fn close(&self) { + fn close(&self) { let _keep_alive = self.keep_alive(); let Some(w) = self.wrapper_ref() else { // `start_tls` is still queued: no SSL, and no close callback yet. @@ -642,40 +700,32 @@ impl UpgradedDuplex { let _ = w.shutdown(true); } - /// Half-close like `us_internal_ssl_shutdown`: close_notify (none mid-handshake), then end(). - #[uws_callback(export = "UpgradedDuplex__shutdown")] - pub(crate) fn shutdown(&self) { + /// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L117-L125 + fn pause_stream(&self) -> bool { let _keep_alive = self.keep_alive(); - let Some(w) = self.wrapper_ref() else { - self.pending_shutdown.set(true); - return; - }; - let _ = w.shutdown(false); - self.call_write_or_end(None, false); + // Like a connecting fd: `on_open` forgets the owner's pause, so nothing would resume this one. + let paused = self.wrapper_ref().is_some() && self.call_origin("pause"); + self.paused_origin.set(self.paused_origin.get() || paused); + paused } - #[uws_callback(export = "UpgradedDuplex__shutdown_read")] - pub(crate) fn shutdown_read(&self) { + fn resume_stream(&self) -> bool { + let _keep_alive = self.keep_alive(); + self.paused_origin.set(false); + self.call_origin("resume") + } + + fn shutdown_read(&self) { if let Some(w) = self.wrapper_ref() { w.shutdown_read(); } } - /// `None` means `start_tls` has not run yet (teardown never clears the slot), not shut down. - #[uws_callback(export = "UpgradedDuplex__is_shutdown", no_catch)] - pub(crate) fn is_shutdown(&self) -> bool { - self.wrapper_ref().is_some_and(|w| w.is_shutdown()) - } - - /// See [`Self::is_shutdown`] for the not-yet-started case. - #[uws_callback(export = "UpgradedDuplex__is_closed", no_catch)] - pub(crate) fn is_closed(&self) -> bool { - self.wrapper_ref().is_some_and(|w| w.is_closed()) - } - - #[uws_callback(export = "UpgradedDuplex__is_established", no_catch)] - pub(crate) fn is_established(&self) -> bool { - !self.is_closed() + fn flush(&self) { + let _keep_alive = self.keep_alive(); + if let Some(w) = self.wrapper_ref() { + w.flush(); + } } fn ssl(&self) -> Option<*mut bun_boringssl_sys::SSL> { @@ -684,112 +734,61 @@ impl UpgradedDuplex { .map(|p| p.as_ptr()) } - #[uws_callback(export = "UpgradedDuplex__ssl_error", no_catch)] - pub(crate) fn ssl_error(&self) -> us_bun_verify_error_t { - let err = self.ssl_error.get(); - us_bun_verify_error_t { - error_no: err.error_no, - code: err.code.as_deref().map_or(c"".as_ptr(), |c| c.as_ptr()), - reason: err.reason.as_deref().map_or(c"".as_ptr(), |c| c.as_ptr()), - // `struct us_bun_verify_error_t` (libusockets.h) has exactly these - // three fields: { int error; const char* code; const char* reason }. - } + /// The JS duplex may be dead too, so the SSL shutdown that follows writes no close_notify and ends nothing. + fn abandon_js_side(&self) { + self.origin.set(JSValue::ZERO); } - fn reset_timeout(&self) { - self.set_timeout_in_milliseconds(self.current_timeout.get()); - } - - fn set_timeout_in_milliseconds(&self, ms: c_uint) { - if self.event_loop_timer.get().state == EventLoopTimerState::ACTIVE { - timer_all().remove(self.event_loop_timer.as_ptr()); - } - self.current_timeout.set(ms); - - // if the interval is 0 means that we stop the timer - if ms == 0 { - return; - } - - // reschedule the timer - // Note: `EventLoopTimer.next` is the lower-tier `ElTimespec` stub; - // bridge from `bun_core::Timespec` until the lower tier switches. - let next = - bun_core::Timespec::ms_from_now(bun_core::TimespecMockMode::ForceRealTime, ms as i64); - self.event_loop_timer.with_mut(|t| { - t.next = ElTimespec { - sec: next.sec, - nsec: next.nsec, - }; - }); - timer_all().insert( - core::ptr::addr_of!(self.event_loop_timer) - .cast::() - .cast_mut(), - ); + fn write_runs_js(&self) -> bool { + true } +} - #[uws_callback(export = "UpgradedDuplex__set_timeout")] - pub(crate) fn set_timeout(&self, seconds: c_uint) { - bun_output::scoped_log!(UpgradedDuplex, "setTimeout({})", seconds); - self.set_timeout_in_milliseconds(seconds * 1000); +impl Drop for UpgradedDuplex { + fn drop(&mut self) { + debug_assert_eq!(self.ref_count.get(), 0); + LIVE_COUNT.fetch_sub(1, Ordering::Relaxed); + self.teardown(); } +} - /// Side-effecting teardown shared by `on_close` (early) and `Drop` (final). - /// Idempotent: resets to empty state. Also invoked by - /// `DuplexUpgradeContext`'s connect-error branches so the listener thunks - /// are neutered while the wrapper is still strongly reachable. - pub(super) fn teardown(&self) { - bun_output::scoped_log!(UpgradedDuplex, "deinit"); - // clear the timer - self.set_timeout(0); - - // Neuter in place rather than `self.wrapper.set(None)`: `teardown()` - // can run re-entrantly from `on_close` while a - // `SSLWrapper::handle_traffic` frame is still on the stack with a - // `&SSLWrapper` into the `Some` payload. Assigning `None` runs `Drop` - // (fine - `deinit()` nulls `ssl`/`ctx`) but then memmoves a fresh - // `Option::None` value over the slot, whose payload bytes are stack - // garbage - the in-flight frame's `self.ssl` then reads junk and - // `flush_pending_events` UAFs into BoringSSL. `deinit()` alone leaves - // `ssl == None` / `closed_notified` readable so those guards work; the - // `Option` is dropped for real when the parent `DuplexUpgradeContext` - // is freed, by the hop it queues once no entry point holds it. See - // WindowsNamedPipe's WRAPPER_BUSY for the sibling pattern. - if let Some(w) = self.wrapper_ref() { - w.deinit(); +// `close` may re-enter (`on_close`) and queue the free of `this`. +bun_jsc::impl_abort_handle_owner!(UpgradedDuplex, abort_handle, |this, _cause| { + // SAFETY: trait contract: `this` is live (armed, so `Drop` has not run). + unsafe { &*this }.close() +}); + +impl bun_event_loop::Taskable for UpgradedDuplex { + const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::UpgradedDuplex; + /// The one queued hop will not run. A `StartTLS` that never ran made no engine, so no close detached the socket. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract; the single queue entry for `this`. + let engine = unsafe { ThisPtr::new(this) }; + engine.queued.set(false); + if let Some(tls) = engine.tls.replace(None) { + // Neuter the listener thunks while the wrapper is still strongly held. + engine.teardown(); + // Consumes our ref and detaches the socket, so its finalizer finds nothing to reach into. `Err` is left for the release dispatcher's fold. + let _ = TLSSocket::on_close(tls.into_this_ptr(), engine.socket(), 0, None); } - - // Neuter the listener thunks so a late `origin` event sees null - // function data instead of a freed `*mut Self`. GC-root clearing is - // left to the wrapper's own collection. - self.origin.set(JSValue::ZERO); - for cb in [ - &self.on_data_callback, - &self.on_end_callback, - &self.on_writable_callback, - &self.on_close_callback, - ] { - let value = cb.get(); - if !value.is_empty() { - host_fn::set_function_data(value, None); - cb.set(JSValue::ZERO); - } + // Not `release_connection`: at zero that queues the hop this call stands in for. + if engine.take_connection_ref() { + engine.ref_count.set(engine.ref_count.get() - 1); + } + // An entry point on the stack still holds it: the release of that ref queues the free. + if engine.ref_count.get() == 0 { + // SAFETY: fn contract; nothing holds `this` and no borrow of it is live. + drop(unsafe { bun_core::heap::take(this) }); } - self.ssl_error.set(CertError::default()); - self.pending_data.set(Vec::new()); - self.pending_close.set(false); - self.pending_shutdown.set(false); } -} - -impl Drop for UpgradedDuplex { - fn drop(&mut self) { - self.teardown(); + /// The hop continues the script that upgraded the stream: once that context has stopped, `StartTLS` must not run. + unsafe fn context(this: *const Self) -> bun_event_loop::ContextId { + // SAFETY: fn contract: the queued engine. + unsafe { (*this).context } } } -// SAFETY (all four listeners): the function data is the `*mut UpgradedDuplex` +// SAFETY (all three listeners): 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. @@ -821,7 +820,7 @@ pub(crate) fn on_received_data<'a>( ) .to_js(); error_value.ensure_still_alive(); - (this.handlers.on_error)(this.handlers.ctx, error_value); + this.on_error(error_value); } } } @@ -837,23 +836,9 @@ pub(crate) fn on_end(_: &mut JsScope<'_>, callee: JsLocal<'_>) { let _keep_alive = this.keep_alive(); // Like node's JSStreamSocket. Ahead of staged bytes too: no handshake can complete after it. - (this.handlers.on_end)(this.handlers.ctx); - } -} - -pub(crate) fn on_writable(_: &mut JsScope<'_>, callee: JsLocal<'_>) { - bun_output::scoped_log!(UpgradedDuplex, "onWritable"); - - if let Some(self_ptr) = host_fn::get_function_data(callee.to_js()) { - // SAFETY: see the 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(); + if let Some(tls) = this.listener() { + fold(TLSSocket::on_end(tls, this.socket())); } - // call onWritable (will flush on demand) - (this.handlers.on_writable)(this.handlers.ctx); } } @@ -867,44 +852,3 @@ pub(crate) fn on_close_js(_: &mut JsScope<'_>, callee: JsLocal<'_>) { this.close(); } } - -// ────────────────────────────────────────────────────────────────────────── -// `bun_uws::UpgradedDuplex` link-time-dispatch shims (cycle break). -// -// `src/uws_sys/lib.rs` declares `UpgradedDuplex` as an opaque handle and binds -// these symbols via `extern "C"` so the low-tier socket dispatch can call into -// the runtime without an upward crate dep. Signatures MUST match the -// `unsafe extern "C"` block there. -// -// All but `ssl` are emitted by `#[uws_callback(export = "...")]` on the -// inherent methods above; `ssl` keeps a hand-written shim because the safe -// method returns `Option<*mut SSL>` while the C ABI flattens to a nullable -// raw pointer. -// ────────────────────────────────────────────────────────────────────────── - -#[unsafe(no_mangle)] -extern "C" fn UpgradedDuplex__set_inline_reject(this: *const c_void) { - // SAFETY: `this` is a live `*const UpgradedDuplex` from the uws_sys opaque handle. - if let Some(wrapper) = unsafe { (*this.cast::()).wrapper_ref() } { - wrapper.set_inline_reject(); - } -} - -#[unsafe(no_mangle)] -extern "C" fn UpgradedDuplex__latest_session( - this: *const c_void, -) -> *mut bun_boringssl_sys::SSL_SESSION { - // SAFETY: `this` is a live `*const UpgradedDuplex` from the uws_sys opaque handle. - unsafe { (*this.cast::()).wrapper_ref() } - .map_or(core::ptr::null_mut(), |wrapper| wrapper.latest_session()) -} - -#[unsafe(no_mangle)] -extern "C" fn UpgradedDuplex__ssl(this: *const c_void) -> *mut bun_boringssl_sys::SSL { - // SAFETY: `this` is a live `*const UpgradedDuplex` from the uws_sys opaque handle. - unsafe { - (*this.cast::()) - .ssl() - .unwrap_or(core::ptr::null_mut()) - } -} diff --git a/src/runtime/socket/WindowsNamedPipe.rs b/src/runtime/socket/WindowsNamedPipe.rs index 842b7d75f4d7..db3e201b04a2 100644 --- a/src/runtime/socket/WindowsNamedPipe.rs +++ b/src/runtime/socket/WindowsNamedPipe.rs @@ -18,40 +18,55 @@ //! with µWebSockets, bridging the gap between libuv's pipe handling and uSockets' //! unified socket interface. -use core::cell::Cell; +use core::cell::{Cell, OnceCell}; use core::ffi::{c_uint, c_void}; use core::ptr::NonNull; +use core::sync::atomic::{AtomicUsize, Ordering}; use bun_boringssl_sys as boringssl; use bun_collections::ByteVecExt; -use bun_core::timespec; +use bun_core::{ZStr, timespec}; use bun_io::pipe_writer::BaseWindowsPipeWriter as _; use bun_io::{StreamingWriter, WriteStatus}; -use bun_jsc::JsCell; use bun_jsc::virtual_machine::VirtualMachine; +use bun_jsc::{GlobalRef, JsCell, SysErrorJsc}; use bun_libuv_sys::{UvHandle as _, UvStream as _}; +use bun_ptr::RefPtr; use bun_sys::windows::libuv as uv; -use bun_sys::{self, Fd, ReturnCodeExt as _}; -use bun_uws::us_bun_verify_error_t; +use bun_sys::{self, Fd, ReturnCodeExt as _, SystemErrno}; +use bun_uws::ssl_wrapper::{self, SSLWrapper}; +use bun_uws::{self as uws, Transport, us_bun_verify_error_t}; -use crate::socket::SSLConfig; -use crate::socket::ssl_wrapper::{self, SSLWrapper}; +use crate::socket::{NewSocket, TCPSocket, TLSSocket}; use crate::timer::{ElTimespec, EventLoopTimer, EventLoopTimerState, EventLoopTimerTag}; bun_output::declare_scope!(WindowsNamedPipe, visible); -pub(crate) type CertError = crate::socket::upgraded_duplex::CertError; - type WrapperType = SSLWrapper<*mut WindowsNamedPipe>; use crate::jsc_hooks::timer_all_mut as timer_all; +/// Live pipes, read by `bun:internal-for-testing` leak tests: `heapStats()` only sees the JS wrappers. +pub(crate) static LIVE_COUNT: AtomicUsize = AtomicUsize::new(0); + +#[derive(bun_ptr::CellRefCounted)] +#[ref_count(destroy = Self::queue_free)] pub(crate) struct WindowsNamedPipe { - pub(crate) wrapper: JsCell>, + /// Starts at one, the connection's ref, which `on_close` releases. + ref_count: Cell, + handle: uws::TransportHandle, + /// Holds one ref on the socket while not `None`. `on_close` and `fail_connect` release it. + socket: Cell, + global_this: GlobalRef, + is_open: Cell, + /// In no uSockets group, so the context that opened it closes it through this when it stops. + abort_handle: bun_jsc::AbortHandle, + /// Set before the pipe opens and freed with `self`: a `handle_traffic` frame can be on the stack when the pipe closes. + wrapper: OnceCell, pub(crate) deferred_writer_close: Cell, pub(crate) root: Cell<*mut WindowsNamedPipe>, /// Non-owning alias of the heap `uv::Pipe`. The owning - /// `Box` is leaked in [`from`] and adopted by + /// `Box` is leaked in [`Self::create`] and adopted by /// `self.writer.source` (`Source::Pipe`) inside [`start`]; this field only /// ever observes/null-checks the handle, never frees it. Cleared by /// [`Self::on_close`] before the writer's async close frees the Box. @@ -63,8 +78,6 @@ pub(crate) struct WindowsNamedPipe { pub(crate) writer: JsCell>, pub(crate) incoming: JsCell>, // Maybe we should use IPCBuffer here as well - pub(crate) ssl_error: JsCell, - pub(crate) handlers: Handlers, pub(crate) connect_req: JsCell, pub(crate) event_loop_timer: JsCell, @@ -74,22 +87,77 @@ pub(crate) struct WindowsNamedPipe { bun_event_loop::impl_timer_owner!(WindowsNamedPipe; from_timer_ptr => event_loop_timer); +// `close` re-enters `on_close`, which may queue the free of `this`. +bun_jsc::impl_abort_handle_owner!(WindowsNamedPipe, abort_handle, |this, _cause| { + // SAFETY: trait contract: `this` is live. + unsafe { &*this }.close_or_cancel_connect() +}); + +/// What a new pipe is opened over. +enum Target<'a> { + /// A libuv descriptor. + Fd(Fd), + /// The name of the pipe to connect to. + Path(&'a [u8]), +} + +/// The socket this pipe is the transport of. +#[derive(Copy, Clone)] +pub(crate) enum SocketType { + Tls(bun_ptr::ThisPtr), + Tcp(bun_ptr::ThisPtr), + None, +} + +impl SocketType { + pub(crate) fn of(socket: bun_ptr::ThisPtr>) -> Self { + let socket = socket.as_ptr(); + // SAFETY: `socket` is live, `NewSocket` is `TLSSocket` and `NewSocket` is `TCPSocket`. + unsafe { + if SSL { + Self::Tls(bun_ptr::ThisPtr::new(socket.cast())) + } else { + Self::Tcp(bun_ptr::ThisPtr::new(socket.cast())) + } + } + } +} + +/// Runs `$body`, written over `NewSocket<$ssl>`, for the socket in `$socket`, and folds what its handlers left pending. `SocketType::None` is a no-op. +macro_rules! match_socket { + ($socket:expr, |$s:ident: NewSocket<$ssl:ident>| $body:expr) => { + match $socket { + SocketType::Tls($s) => { + const $ssl: bool = true; + let _ = $ssl; + crate::dispatch::fold($body) + } + SocketType::Tcp($s) => { + const $ssl: bool = false; + let _ = $ssl; + crate::dispatch::fold($body) + } + SocketType::None => {} + } + }; +} + 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; - const IS_SSL = 1 << 3; /// Rust-only bookkeeping: set once `start_with_pipe` adopts the - /// `Box` leaked in [`from`]. Lets `Drop` reclaim the orphan + /// `Box` leaked in `create`. Lets `Drop` reclaim the orphan /// allocation on early-error paths (before adoption) without risking a /// double-free once the writer owns it. 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; } } @@ -102,31 +170,6 @@ impl Flags { pub(crate) fn is_closed(self) -> bool { self.contains(Self::IS_CLOSED) } - #[inline] - pub(crate) fn is_ssl(self) -> bool { - self.contains(Self::IS_SSL) - } -} - -pub(crate) struct Handlers { - pub ctx: *mut c_void, - pub(crate) ref_ctx: fn(*mut c_void), - pub(crate) deref_ctx: fn(*mut c_void), - pub(crate) on_open: fn(*mut c_void), - pub(crate) on_handshake: fn(*mut c_void, bool, us_bun_verify_error_t), - pub(crate) on_data: fn(*mut c_void, &[u8]), - pub on_close: fn(*mut c_void), - pub(crate) on_end: fn(*mut c_void), - pub(crate) on_writable: fn(*mut c_void), - pub(crate) on_error: fn(*mut c_void, bun_sys::Error), - pub(crate) on_timeout: fn(*mut c_void), - /// A new resumable TLS session (serialized SSL_SESSION) - node's - /// `'session'` event on the wrapping TLSSocket. - pub(crate) on_session: fn(*mut c_void, &[u8]), - /// An NSS key-log line - node's `'keylog'` event. - pub(crate) on_keylog: fn(*mut c_void, &[u8]), - pub(crate) server_identity: - fn(*mut c_void, &mut boringssl::SSL) -> bun_boringssl::ServerIdentity, } impl WindowsNamedPipe { @@ -139,21 +182,7 @@ impl WindowsNamedPipe { #[inline] fn wrapper_ref(&self) -> Option<&WrapperType> { - self.wrapper.get().as_ref() - } - - fn with_wrapper(&self, f: impl FnOnce(&WrapperType) -> R) -> Option { - let w = self.wrapper_ref()?; - let was_busy = self.flags.get().contains(Flags::WRAPPER_BUSY); - self.update_flags(|flags| flags.insert(Flags::WRAPPER_BUSY)); - let result = f(w); - if !was_busy { - self.update_flags(|flags| flags.remove(Flags::WRAPPER_BUSY)); - if self.flags.get().is_closed() { - self.wrapper.set(None); - } - } - Some(result) + self.wrapper.get() } #[inline] @@ -164,7 +193,7 @@ impl WindowsNamedPipe { /// Reclaim the leaked `Box` on an early-error path **before** /// [`start`] hands it to `self.writer.source` via `start_with_pipe`. /// - /// [`from`] `Box::leak`s the allocation and records only a non-owning + /// [`Self::create`] `Box::leak`s the allocation and records only a non-owning /// `NonNull` in `self.pipe`; until adoption the writer's `Drop` /// (`close_without_reporting`) is a no-op (`source == None`), so any /// `connect`/`open`/`get_accepted_by` early return would leak the box and — @@ -181,7 +210,7 @@ impl WindowsNamedPipe { ); if let Some(pipe) = self.pipe.take() { // SAFETY: `pipe` is the `NonNull` recorded from `Box::leak` in - // `from()` and not yet re-materialised (asserted above); + // `create()` and not yet re-materialised (asserted above); // `close_and_destroy` reclaims via `Box::from_raw` either // immediately (never-init'd, `loop_ == null`) or in the `uv_close` // callback (init'd). Ownership transfers here exactly once. @@ -189,9 +218,9 @@ impl WindowsNamedPipe { } } - /// Holds a ref on the owning context until the returned guard drops. + /// Holds a ref on `self` until the returned guard drops. /// - /// The context frees itself from a task it queues when [`on_close`] releases + /// `self` frees itself from a task it queues when [`on_close`] releases /// the connection's ref. A handler can close the socket and then spin the /// event loop before it returns (`expect().resolves` blocks on a promise /// that way), which runs that task. A callback that uses `self` after it @@ -204,9 +233,33 @@ impl WindowsNamedPipe { /// `release_resources`): a ref taken at zero would queue the free twice. /// /// [`on_close`]: Self::on_close - fn keep_alive(&self) -> impl Drop + '_ { - self.r#ref(); - scopeguard::guard(self, |this| this.deref()) + fn keep_alive(&self) -> RefPtr { + // SAFETY: `root_ptr` is the live heap allocation. + unsafe { RefPtr::init_ref(self.root_ptr()) } + } + + /// Releases one ref. The last one queues the free, so `self` outlives the caller's frame. + fn release(&self) { + // SAFETY: `root_ptr` is the live heap allocation, and the caller owns the ref. + unsafe { Self::deref(self.root_ptr()) } + } + + fn queue_free(this: *mut Self) { + // SAFETY: the count just reached zero; `this` is live until the task runs. + unsafe { (*this).vm } + .event_loop_mut() + .enqueue_task(bun_jsc::Task::init(this)); + } + + /// SAFETY: `this` is the queued pointer, whose last ref is gone. The call frees it. + pub(crate) unsafe fn run_event(this: *mut Self) { + // SAFETY: fn contract. + drop(unsafe { bun_core::heap::take(this) }); + } + + /// `self` as the handle its socket holds. + fn handle(&self) -> uws::NewSocketHandler { + uws::NewSocketHandler::from_transport(&raw const self.handle) } fn on_writable(&self) { @@ -214,7 +267,13 @@ impl WindowsNamedPipe { // flush pending data self.flush(); // call onWritable (will flush on demand) - (self.handlers.on_writable)(self.handlers.ctx); + self.dispatch_writable(); + } + + fn dispatch_writable(&self) { + match_socket!(self.socket.get(), |s: NewSocket| { + NewSocket::on_writable(s, self.handle::()) + }); } fn on_read(&self, nread: usize) { @@ -228,11 +287,9 @@ impl WindowsNamedPipe { let mut data = self.incoming.replace(Vec::new()); - if self - .with_wrapper(|w| w.receive_data(data.as_slice())) - .is_none() - { - (self.handlers.on_data)(self.handlers.ctx, data.as_slice()); + match self.wrapper_ref() { + Some(w) => w.receive_data(data.as_slice()), + None => self.on_data(data.as_slice()), } data.clear(); self.incoming.set(data); @@ -262,6 +319,7 @@ impl WindowsNamedPipe { source.unref(); } }); + self.send_shutdown_if_drained(); } WriteStatus::EndOfFile => { // we send FIN so we close after this @@ -299,7 +357,9 @@ impl WindowsNamedPipe { let _keep_alive = self.keep_alive(); if err == bun_sys::E::EOF { // we received FIN but we dont allow half-closed connections right now - (self.handlers.on_end)(self.handlers.ctx); + match_socket!(self.socket.get(), |s: NewSocket| { + NewSocket::on_end(s, self.handle::()) + }); self.close_writer(); return; } @@ -310,28 +370,40 @@ impl WindowsNamedPipe { fn on_error(&self, err: bun_sys::Error) { bun_output::scoped_log!(WindowsNamedPipe, "onError"); let _keep_alive = self.keep_alive(); - (self.handlers.on_error)(self.handlers.ctx, err); + if self.is_open.get() { + match_socket!(self.socket.get(), |s: NewSocket| { + s.handle_error(err.to_js(&self.global_this)) + }); + } else { + self.fail_connect(SystemErrno::from_raw(err.errno)); + } self.close(); } + /// `connectError` is the last event of a socket whose connect failed, so this pipe forgets the socket: neither `on_close`, which still fires on the asynchronous failure path, nor `Drop` touches it again. + fn fail_connect(&self, errno: SystemErrno) { + // Cleared before `connectError` runs JS, which may connect the same socket again through a new pipe. + let socket = self.socket.replace(SocketType::None); + match_socket!(socket, |s: NewSocket| { + let failed = NewSocket::handle_connect_error(s, errno); + s.get().deref(); + failed + }); + } + fn on_open(&self) { bun_output::scoped_log!(WindowsNamedPipe, "onOpen"); - (self.handlers.on_open)(self.handlers.ctx); + self.is_open.set(true); + match_socket!(self.socket.get(), |s: NewSocket| { + NewSocket::on_open(s, self.handle::()) + }); } fn on_data(&self, decoded_data: &[u8]) { bun_output::scoped_log!(WindowsNamedPipe, "onData ({})", decoded_data.len()); - (self.handlers.on_data)(self.handlers.ctx, decoded_data); - } - - fn on_session(&self, session: &[u8]) { - bun_output::scoped_log!(WindowsNamedPipe, "onSession ({})", session.len()); - (self.handlers.on_session)(self.handlers.ctx, session); - } - - fn on_keylog(&self, line: &[u8]) { - bun_output::scoped_log!(WindowsNamedPipe, "onKeylog ({})", line.len()); - (self.handlers.on_keylog)(self.handlers.ctx, line); + match_socket!(self.socket.get(), |s: NewSocket| { + NewSocket::on_data(s, self.handle::(), decoded_data) + }); } // ── SSLWrapper trampolines ─────────────────────────────────────────────── @@ -350,29 +422,14 @@ impl WindowsNamedPipe { // SAFETY: see block note above. unsafe { &*this }.on_data(d) } - fn ssl_on_session(this: *mut Self, d: &[u8]) { - // SAFETY: see block note above. - unsafe { &*this }.on_session(d) - } - fn ssl_server_identity( - this: *mut Self, - ssl: &mut boringssl::SSL, - ) -> bun_boringssl::ServerIdentity { - // SAFETY: see block note above. - let this = unsafe { &*this }; - (this.handlers.server_identity)(this.handlers.ctx, ssl) - } - fn ssl_on_keylog(this: *mut Self, d: &[u8]) { - // SAFETY: see block note above. - unsafe { &*this }.on_keylog(d) - } fn ssl_on_close(this: *mut Self) { // SAFETY: see block note above. unsafe { &*this }.on_close() } - fn ssl_write(this: *mut Self, d: &[u8]) { + fn ssl_write(this: *mut Self, d: &[u8]) -> usize { // SAFETY: see block note above. - unsafe { &*this }.internal_write(d) + unsafe { &*this }.internal_write(d); + d.len() } fn wrapper_handlers(&self) -> ssl_wrapper::Handlers<*mut WindowsNamedPipe> { @@ -383,9 +440,6 @@ impl WindowsNamedPipe { on_data: Self::ssl_on_data, on_close: Self::ssl_on_close, write: Self::ssl_write, - on_session: Some(Self::ssl_on_session), - on_keylog: Some(Self::ssl_on_keylog), - server_identity: Some(Self::ssl_server_identity), } } @@ -393,21 +447,12 @@ impl WindowsNamedPipe { bun_output::scoped_log!(WindowsNamedPipe, "onHandshake"); let _keep_alive = self.keep_alive(); - self.ssl_error.set(CertError { - error_no: ssl_error.error_no, - code: ssl_error - .code() - .filter(|_| ssl_error.error_no != 0) - .map(Into::into), - reason: ssl_error - .reason() - .filter(|_| ssl_error.error_no != 0) - .map(Into::into), + match_socket!(self.socket.get(), |s: NewSocket| { + NewSocket::on_handshake(s, self.handle::(), handshake_success as i32, ssl_error) }); - (self.handlers.on_handshake)(self.handlers.ctx, handshake_success, ssl_error); // Retry writes parked during the handshake; a TLS 1.2 client's completion sends nothing. if handshake_success && !self.is_shutdown() { - (self.handlers.on_writable)(self.handlers.ctx); + self.dispatch_writable(); } } @@ -420,8 +465,15 @@ impl WindowsNamedPipe { self.pipe.set(None); if !self.flags.get().is_closed() { self.update_flags(|f| f.set(Flags::IS_CLOSED, true)); // only call onClose once - (self.handlers.on_close)(self.handlers.ctx); + let socket = self.socket.replace(SocketType::None); + match_socket!(socket, |s: NewSocket| { + let closed = NewSocket::on_close(s, self.handle::(), 0, None); + s.get().deref(); + closed + }); self.release_resources(); + // The connection's ref. + self.release(); } } @@ -447,9 +499,9 @@ impl WindowsNamedPipe { } if !msg_more { - let _ = self.with_wrapper(|w| { + if let Some(w) = self.wrapper_ref() { let _ = w.shutdown(false); - }); + } self.with_writer(|w| w.end()); } } @@ -465,40 +517,6 @@ impl WindowsNamedPipe { self.call_write_or_end(Some(encoded_data), true); } - #[bun_uws::uws_callback(export = "WindowsNamedPipe__resume_stream")] - pub(crate) fn resume_stream(&self) -> bool { - let Some(stream) = self.writer.with_mut(|w| w.get_stream()) else { - return false; - }; - let this: *mut Self = self.root_ptr(); - // SAFETY: `stream` is the live `*mut uv_stream_t` for our pipe - // (returned by `writer.get_stream()`); the `StreamReader` impl - // below routes the trampolines back to `self`. - let read_start_result = - unsafe { (*stream).read_start_ctx::(this) }.to_result(bun_sys::Tag::listen); - read_start_result.is_ok() - } - - #[bun_uws::uws_callback(export = "WindowsNamedPipe__pause_stream")] - pub(crate) fn pause_stream(&self) -> bool { - let Some(pipe) = self.uv_pipe() else { - return false; - }; - // SAFETY: live libuv handle alias; see `pipe`. - unsafe { (*pipe).read_stop() }; - true - } - - #[bun_uws::uws_callback(export = "WindowsNamedPipe__flush")] - pub(crate) fn flush(&self) { - let _ = self.with_wrapper(|w| { - let _ = w.flush(); - }); - if !self.flags.get().disconnected() { - let _ = self.writer.with_mut(|w| w.flush()); - } - } - pub(crate) fn on_timeout(&self) { bun_output::scoped_log!(WindowsNamedPipe, "onTimeout"); @@ -514,44 +532,115 @@ impl WindowsNamedPipe { return; } - (self.handlers.on_timeout)(self.handlers.ctx); + match_socket!(self.socket.get(), |s: NewSocket| { + NewSocket::on_timeout(s, self.handle::()) + }); } - pub(crate) fn from( - pipe: Box, - handlers: Handlers, - vm: &'static VirtualMachine, - ) -> WindowsNamedPipe { - WindowsNamedPipe { - vm, - // Leak the `Box` and keep only a non-owning `NonNull` alias. - // Ownership of the allocation is later transferred to - // `self.writer.source` via `start_with_pipe` in `start()`, which - // re-materialises the `Box` and is responsible for freeing it. + /// The transport of `socket`, on which it takes a ref. Returns the pipe with one ref, the connection's. + pub(crate) fn create(cx: &bun_jsc::JsThread<'_>, socket: SocketType) -> *mut WindowsNamedPipe { + let global_this = GlobalRef::from(cx.global()); + let pipe = Box::new(bun_core::ffi::zeroed::()); + let this = bun_core::heap::into_raw(Box::new(WindowsNamedPipe { + ref_count: Cell::new(1), + handle: Default::default(), + socket: Cell::new(socket), + vm: global_this.bun_vm(), + global_this, + is_open: Cell::new(false), + abort_handle: bun_jsc::AbortHandle::for_owner::(), + // Leaked here. `start_with_pipe` in `start()` hands the allocation to `self.writer.source`, which frees it. pipe: Cell::new(Some(NonNull::from(Box::leak(pipe)))), - wrapper: JsCell::new(None), + wrapper: OnceCell::new(), deferred_writer_close: Cell::new(false), root: Cell::new(core::ptr::null_mut()), - handlers, - // defaults: writer: JsCell::new(StreamingWriter::default()), incoming: JsCell::new(Vec::new()), - ssl_error: JsCell::new(CertError::default()), connect_req: JsCell::new(bun_core::ffi::zeroed::()), event_loop_timer: JsCell::new(EventLoopTimer::init_paused( EventLoopTimerTag::WindowsNamedPipe, )), current_timeout: Cell::new(0), - flags: Cell::new(Flags::DISCONNECTED), // disconnected: bool = true is the only non-false default + flags: Cell::new(Flags::DISCONNECTED), + })); + // SAFETY: just allocated, and nothing else has the pointer yet. + unsafe { + (*this).root.set(this); + (*this).handle.set(this); + } + LIVE_COUNT.fetch_add(1, Ordering::Relaxed); + match_socket!(socket, |s: NewSocket| { + s.ref_(); + Ok(()) + }); + // SAFETY: fully initialised and heap-pinned; dropping the handle disarms it. + unsafe { bun_jsc::AbortHandle::arm_owner(this, cx.context()) }; + this + } + + /// Makes a new pipe over `target` the transport of `socket`. On `Err` the socket is detached again and has had no event. + fn attach( + cx: &bun_jsc::JsThread<'_>, + target: Target<'_>, + ssl_ctx: Option, + socket: SocketType, + ) -> Result<(), SystemErrno> { + // SAFETY: `create()` returns a live pipe with its only ref. + let pipe = unsafe { &*Self::create(cx, socket) }; + match_socket!(socket, |s: NewSocket| { + s.socket.set(pipe.handle::()); + Ok(()) + }); + let started = match target { + Target::Fd(fd) => pipe.open(fd, ssl_ctx), + Target::Path(path) => pipe.connect_path(path, ssl_ctx), + }; + if started.is_ok() { + return Ok(()); } + pipe.socket.set(SocketType::None); + match_socket!(socket, |s: NewSocket| { + s.socket.set(uws::NewSocketHandler::::DETACHED); + s.get().deref(); + Ok(()) + }); + pipe.release(); + Err(SystemErrno::ENOENT) } - pub(crate) fn r#ref(&self) { - (self.handlers.ref_ctx)(self.handlers.ctx); + /// Makes a pipe over the libuv descriptor `fd` the transport of `socket`. + pub(crate) fn from_fd( + cx: &bun_jsc::JsThread<'_>, + fd: Fd, + ssl_ctx: Option, + socket: SocketType, + ) -> Result<(), SystemErrno> { + Self::attach(cx, Target::Fd(fd), ssl_ctx, socket) + } + + /// Makes a pipe that connects to `path` the transport of `socket`. + pub(crate) fn from_path( + cx: &bun_jsc::JsThread<'_>, + path: &[u8], + ssl_ctx: Option, + socket: SocketType, + ) -> Result<(), SystemErrno> { + Self::attach(cx, Target::Path(path), ssl_ctx, socket) } - pub(crate) fn deref(&self) { - (self.handlers.deref_ctx)(self.handlers.ctx); + fn connect_path( + &self, + path: &[u8], + ssl_ctx: Option, + ) -> bun_sys::Result<()> { + if path[path.len() - 1] == 0 { + return self.connect(ZStr::from_slice_with_nul(path), ssl_ctx); + } + let mut path_buf = bun_paths::path_buffer_pool::get(); + let len = path.len().min(path_buf.len() - 1); + path_buf[..len].copy_from_slice(&path[..len]); + path_buf[len] = 0; + self.connect(ZStr::from_buf(&path_buf[..], len), ssl_ctx) } /// `extern "C"` trampoline matching `uv_connect_cb` (`Pipe::connect`'s @@ -562,7 +651,7 @@ impl WindowsNamedPipe { extern "C" fn uv_on_connect(req: *mut uv::uv_connect_t, status: uv::ReturnCode) { // SAFETY: `req` is `self.connect_req`, whose `data` was set to // `self as *mut Self` in `connect`; the owning struct is kept alive by - // the `r#ref()` taken there until this callback runs. + // the `ref_()` taken there until this callback runs. let this = unsafe { (*req).data.cast::() }; // SAFETY: as above. unsafe { &*this }.on_connect(status); @@ -581,26 +670,26 @@ impl WindowsNamedPipe { // `on_close`. Do both here. `discard_unadopted_pipe` schedules the // `uv_close` that frees the `Box`, like the synchronous early-error // paths in `connect`/`open`/`get_accepted_by`, and `on_close` is - // what makes the owner (`handlers.on_close`) release its ref. + // what releases the connection's ref. self.discard_unadopted_pipe(); self.on_error(err); self.on_close(); - self.deref(); + self.release(); return; } self.update_flags(|f| f.set(Flags::DISCONNECTED, false)); - if self.start(true) { - if self.is_tls() { + if self.start() { + match self.wrapper_ref() { // trigger onOpen and start the handshake - let _ = self.with_wrapper(|w| w.start()); - } else { - // trigger onOpen - self.on_open(); + Some(w) => w.start(), + None => self.on_open(), } } self.flush(); - self.deref(); + // With nothing queued, no `on_write(Drained)` follows to send a shutdown requested before the connect. + self.send_shutdown_if_drained(); + self.release(); } pub(crate) fn get_accepted_by( @@ -612,20 +701,7 @@ impl WindowsNamedPipe { let _keep_alive = self.keep_alive(); self.update_flags(|f| f.set(Flags::DISCONNECTED, true)); - if let Some(tls) = ssl_ctx { - self.update_flags(|f| f.set(Flags::IS_SSL, true)); - match WrapperType::init_with_ctx(tls.clone(), false, self.wrapper_handlers()) { - Ok(w) => self.wrapper.set(Some(w)), - Err(_) => { - self.discard_unadopted_pipe(); - return bun_sys::Result::Err(bun_sys::Error { - errno: bun_sys::E::EPIPE as _, - syscall: bun_sys::Tag::connect, - ..Default::default() - }); - } - } - } + self.init_tls_wrapper(ssl_ctx.cloned(), false); let uv_loop = self.vm.uv_loop(); let pipe = self.uv_pipe().unwrap(); // SAFETY: live libuv handle alias; see `pipe`. @@ -651,33 +727,21 @@ impl WindowsNamedPipe { } self.update_flags(|f| f.set(Flags::DISCONNECTED, false)); - if self.start(false) { - if self.is_tls() { + if self.start() { + match self.wrapper_ref() { // trigger onOpen and start the handshake - let _ = self.with_wrapper(|w| w.start()); - } else { - // trigger onOpen - self.on_open(); + Some(w) => w.start(), + None => self.on_open(), } } bun_sys::Result::Ok(()) } - pub(crate) fn open( - &self, - fd: Fd, - ssl_options: Option, - owned_ctx: Option, - ) -> bun_sys::Result<()> { + fn open(&self, fd: Fd, ssl_ctx: Option) -> bun_sys::Result<()> { debug_assert!(self.pipe.get().is_some()); self.update_flags(|f| f.set(Flags::DISCONNECTED, true)); - if let Some(result) = self.init_tls_wrapper(ssl_options, owned_ctx) { - if result.is_err() { - self.discard_unadopted_pipe(); - return result; - } - } + self.init_tls_wrapper(ssl_ctx, true); let uv_loop = self.vm.uv_loop(); let pipe = self.uv_pipe().unwrap(); // SAFETY: live libuv handle alias; see `pipe`. @@ -699,17 +763,12 @@ impl WindowsNamedPipe { return Err(e); } - self.r#ref(); + self.ref_(); self.on_connect(uv::ReturnCode::ZERO); bun_sys::Result::Ok(()) } - pub(crate) fn connect( - &self, - path: &[u8], - ssl_options: Option, - owned_ctx: Option, - ) -> bun_sys::Result<()> { + fn connect(&self, path: &[u8], ssl_ctx: Option) -> bun_sys::Result<()> { debug_assert!(self.pipe.get().is_some()); self.update_flags(|f| f.set(Flags::DISCONNECTED, true)); let pipe = self.uv_pipe().unwrap(); @@ -717,12 +776,7 @@ impl WindowsNamedPipe { // SAFETY: live libuv handle alias; see `pipe`. unsafe { (*pipe).ref_() }; - if let Some(result) = self.init_tls_wrapper(ssl_options, owned_ctx) { - if result.is_err() { - self.discard_unadopted_pipe(); - return result; - } - } + self.init_tls_wrapper(ssl_ctx, true); let uv_loop = self.vm.uv_loop(); // SAFETY: as above. if let Err(e) = unsafe { (*pipe).init(uv_loop, false) }.to_result(bun_sys::Tag::pipe) { @@ -750,55 +804,18 @@ impl WindowsNamedPipe { self.discard_unadopted_pipe(); return Err(err); } - self.r#ref(); + self.ref_(); Ok(()) } - /// Set up the in-process SSL wrapper for `connect`/`open`. Prefers a prebuilt - /// `SSL_CTX` (moved into `wrapper`) so a memoised `tls.createSecureContext` - /// reaches this path with its CA bundle intact; on this branch `[buntls]` - /// returns `{secureContext}` and no longer spreads `{ca,cert,key}`, so the - /// `SSLConfig` fallback alone would build a CTX with an empty trust store - /// and fail `DEPTH_ZERO_SELF_SIGNED_CERT`. - /// Returns null when neither input requested TLS. - fn init_tls_wrapper( - &self, - ssl_options: Option, - owned_ctx: Option, - ) -> Option> { - if let Some(ctx) = owned_ctx { - self.update_flags(|f| f.set(Flags::IS_SSL, true)); - match WrapperType::init_with_ctx(ctx, true, self.wrapper_handlers()) { - Ok(w) => self.wrapper.set(Some(w)), - Err(_) => { - return Some(bun_sys::Result::Err(bun_sys::Error { - errno: bun_sys::E::EPIPE as _, - syscall: bun_sys::Tag::connect, - ..Default::default() - })); - } - } - return Some(bun_sys::Result::Ok(())); - } - if let Some(tls) = ssl_options { - self.update_flags(|f| f.set(Flags::IS_SSL, true)); - match ssl_wrapper::init(&tls, true, self.wrapper_handlers()) { - Ok(w) => self.wrapper.set(Some(w)), - Err(_) => { - return Some(bun_sys::Result::Err(bun_sys::Error { - errno: bun_sys::E::EPIPE as _, - syscall: bun_sys::Tag::connect, - ..Default::default() - })); - } - } - return Some(bun_sys::Result::Ok(())); - } - None + /// Makes the TLS engine if `ssl_ctx` asks for one. + fn init_tls_wrapper(&self, ssl_ctx: Option, is_client: bool) { + let Some(ctx) = ssl_ctx else { return }; + self.wrapper + .get_or_init(|| WrapperType::init_with_ctx(ctx, is_client, self.wrapper_handlers())); } - pub(crate) fn start(&self, is_client: bool) -> bool { - self.update_flags(|f| f.set(Flags::IS_CLIENT, is_client)); + fn start(&self) -> bool { let Some(pipe_nn) = self.pipe.get() else { return false; }; @@ -810,7 +827,7 @@ impl WindowsNamedPipe { w.set_parent(this); // SAFETY: `start_with_pipe`'s contract is "Box-allocated pointer; // ownership transfers to `self.source`". `pipe_nn` is the - // `NonNull` recorded from the `Box` leaked in `from()` + // `NonNull` recorded from the `Box` leaked in `create()` // and not yet adopted (asserted by `start_with_pipe`'s // `debug_assert!(source.is_none())`). unsafe { w.start_with_pipe(pipe_nn.as_ptr()) } @@ -839,26 +856,6 @@ impl WindowsNamedPipe { true } - pub(crate) fn is_tls(&self) -> bool { - self.flags.get().is_ssl() - } - - #[bun_uws::uws_callback(export = "WindowsNamedPipe__encode_and_write")] - pub(crate) fn encode_and_write(&self, data: &[u8]) -> i32 { - bun_output::scoped_log!(WindowsNamedPipe, "encodeAndWrite (len: {})", data.len()); - if let Some(r) = self.with_wrapper(|w| w.write_data(data)) { - return i32::try_from(r.unwrap_or(0)).expect("int cast"); - } - self.internal_write(data); - i32::try_from(data.len()).expect("int cast") - } - - #[bun_uws::uws_callback(export = "WindowsNamedPipe__raw_write")] - pub(crate) fn raw_write(&self, encoded_data: &[u8]) -> i32 { - self.internal_write(encoded_data); - i32::try_from(encoded_data.len()).expect("int cast") - } - /// `uv::open_handles` closes a not-yet-adopted pipe through here at teardown. unsafe fn stop_for_vm_teardown(this: *mut core::ffi::c_void) { // SAFETY: recorded right after `pipe.init` by this live object; replaced by @@ -878,79 +875,39 @@ impl WindowsNamedPipe { self.close(); } - #[bun_uws::uws_callback(export = "WindowsNamedPipe__close")] - pub(crate) fn close(&self) { - let _ = self.with_wrapper(|w| { - let _ = w.shutdown(false); - }); - self.with_writer(|w| w.end()); - } - - #[bun_uws::uws_callback(export = "WindowsNamedPipe__shutdown")] - pub(crate) fn shutdown(&self) { - let handled = self.with_wrapper(|w| { - 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. - self.with_writer(|w| w.end()); - } - } - - #[bun_uws::uws_callback(export = "WindowsNamedPipe__shutdown_read")] - pub(crate) fn shutdown_read(&self) { - if let Some(wrapper) = self.wrapper_ref() { - wrapper.shutdown_read(); - } else { - if let Some(stream) = self.writer.with_mut(|w| w.get_stream()) { - // SAFETY: `stream` is the live pipe stream; `uv_read_stop` - // always succeeds and is a no-op if not reading. - unsafe { (*stream).read_stop() }; - } - } - } - - #[bun_uws::uws_callback(export = "WindowsNamedPipe__is_shutdown", no_catch)] - pub(crate) fn is_shutdown(&self) -> bool { - if let Some(wrapper) = self.wrapper_ref() { - return wrapper.is_shutdown(); + /// 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; } - - self.flags.get().disconnected() || self.writer.get().is_done - } - - #[bun_uws::uws_callback(export = "WindowsNamedPipe__is_closed", no_catch)] - pub(crate) fn is_closed(&self) -> bool { - if let Some(wrapper) = self.wrapper_ref() { - return wrapper.is_closed(); + 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()); } - self.flags.get().disconnected() } - #[bun_uws::uws_callback(export = "WindowsNamedPipe__is_established", no_catch)] - pub(crate) fn is_established(&self) -> bool { - !self.is_closed() - } - - pub(crate) fn ssl(&self) -> Option<*mut boringssl::SSL> { - self.wrapper_ref() - .and_then(|w| w.ssl.get()) - .map(|p| p.as_ptr()) - } - - #[bun_uws::uws_callback(export = "WindowsNamedPipe__ssl_error", no_catch)] - pub(crate) fn ssl_error(&self) -> us_bun_verify_error_t { - let err = self.ssl_error.get(); - us_bun_verify_error_t { - error_no: err.error_no, - // CertError.code/.reason are owned `Box`s; fall back to "" when absent. - code: err.code.as_deref().map_or(c"".as_ptr(), |c| c.as_ptr()), - reason: err.reason.as_deref().map_or(c"".as_ptr(), |c| c.as_ptr()), - } + /// 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) }; } pub(crate) fn reset_timeout(&self) { @@ -985,15 +942,9 @@ impl WindowsNamedPipe { ); } - #[bun_uws::uws_callback(export = "WindowsNamedPipe__set_timeout")] - pub(crate) fn set_timeout(&self, seconds: c_uint) { - bun_output::scoped_log!(WindowsNamedPipe, "setTimeout({})", seconds); - self.set_timeout_in_milliseconds(seconds * 1000); - } - /// Free internal resources, it can be called multiple times. // Private idempotent helper invoked from on_close and Drop. - // Owned fields (writer, wrapper, ssl_error) free themselves via their own Drop impls; only + // Owned fields (writer, wrapper) free themselves via their own Drop impls; only // the side effects (timer cancel, read_stop, take()) remain explicit here. fn release_resources(&self) { bun_output::scoped_log!(WindowsNamedPipe, "deinit"); @@ -1006,7 +957,7 @@ impl WindowsNamedPipe { // via `ssl_on_close` (TLS close_notify): the underlying `uv_pipe_t` is // still open and in libuv's handle_queue, so without an explicit close // here the HANDLE survives ≥ one extra event-loop tick (until the - // embedding context's refcount hits 0 and `WindowsStreamingWriter::Drop` + // pipe's refcount hits 0 and `WindowsStreamingWriter::Drop` // finally runs). Inline `close_without_reporting()` (private on the // writer) so the source pipe is `uv_close`d NOW; the `get_fd() != INVALID` // guard makes this a no-op on the writer-initiated path where the source @@ -1021,17 +972,132 @@ impl WindowsNamedPipe { } self.writer.with_mut(|w| w.close_without_reporting()); self.writer.with_mut(|w| w.outgoing = Default::default()); - if !self.flags.get().contains(Flags::WRAPPER_BUSY) { - self.wrapper.set(None); + if let Some(w) = self.wrapper_ref() { + w.deinit(); + } + } +} + +impl Transport for WindowsNamedPipe { + fn is_closed(&self) -> bool { + if let Some(wrapper) = self.wrapper_ref() { + return wrapper.is_closed(); + } + self.flags.get().disconnected() + } + + fn is_shutdown(&self) -> bool { + if let Some(wrapper) = self.wrapper_ref() { + return wrapper.is_shutdown(); + } + + let flags = self.flags.get(); + flags.disconnected() + || flags.contains(Flags::SHUTDOWN_REQUESTED) + || self.writer.get().is_done + } + + fn write(&self, data: &[u8]) -> (i32, i32) { + bun_output::scoped_log!(WindowsNamedPipe, "write (len: {})", data.len()); + if let Some(w) = self.wrapper_ref() { + let written = w.write_data(data).unwrap_or(0); + return (i32::try_from(written).expect("int cast"), 0); + } + self.internal_write(data); + (i32::try_from(data.len()).expect("int cast"), 0) + } + + fn raw_write(&self, encoded_data: &[u8]) -> i32 { + self.internal_write(encoded_data); + i32::try_from(encoded_data.len()).expect("int cast") + } + + fn shutdown(&self) { + if let Some(w) = self.wrapper_ref() { + let _ = w.shutdown(false); + return; + } + 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(); + } + + fn close(&self) { + if let Some(w) = self.wrapper_ref() { + let _ = w.shutdown(false); + } + self.with_writer(|w| w.end()); + } + + fn pause_stream(&self) -> bool { + let Some(pipe) = self.uv_pipe() else { + return false; + }; + // SAFETY: live libuv handle alias; see `pipe`. + unsafe { (*pipe).read_stop() }; + true + } + + fn resume_stream(&self) -> bool { + let Some(stream) = self.writer.with_mut(|w| w.get_stream()) else { + return false; + }; + let this: *mut Self = self.root_ptr(); + // SAFETY: `stream` is the live `*mut uv_stream_t` for our pipe + // (returned by `writer.get_stream()`); the `StreamReader` impl + // below routes the trampolines back to `self`. + let read_start_result = + unsafe { (*stream).read_start_ctx::(this) }.to_result(bun_sys::Tag::listen); + read_start_result.is_ok() + } + + fn shutdown_read(&self) { + if let Some(wrapper) = self.wrapper_ref() { + wrapper.shutdown_read(); + } else { + if let Some(stream) = self.writer.with_mut(|w| w.get_stream()) { + // SAFETY: `stream` is the live pipe stream; `uv_read_stop` + // always succeeds and is a no-op if not reading. + unsafe { (*stream).read_stop() }; + } } - self.ssl_error.set(CertError::default()); + } + + fn flush(&self) { + if let Some(w) = self.wrapper_ref() { + w.flush(); + } + if !self.flags.get().disconnected() { + let _ = self.writer.with_mut(|w| w.flush()); + } + } + + fn set_timeout(&self, seconds: c_uint) { + bun_output::scoped_log!(WindowsNamedPipe, "setTimeout({})", seconds); + self.set_timeout_in_milliseconds(seconds * 1000); + } + + fn ssl(&self) -> Option<*mut boringssl::SSL> { + self.wrapper_ref() + .and_then(|w| w.ssl.get()) + .map(|p| p.as_ptr()) } } impl Drop for WindowsNamedPipe { fn drop(&mut self) { + LIVE_COUNT.fetch_sub(1, Ordering::Relaxed); + // A pipe that never opened and never failed still holds its socket. + let socket = self.socket.replace(SocketType::None); + match_socket!(socket, |s: NewSocket| { + s.get().deref(); + Ok(()) + }); self.release_resources(); - // Reclaim the `Box` leaked in `from()` if it was never + // Reclaim the `Box` leaked in `create()` if it was never // adopted by `self.writer.source` (early-error returns from // `connect`/`open`/`get_accepted_by` before `start()` runs). Once // `PIPE_ADOPTED` is set the writer is the sole owner and frees via @@ -1043,7 +1109,7 @@ impl Drop for WindowsNamedPipe { // `handle_queue` before the heap block is freed. if !self.flags.get().contains(Flags::PIPE_ADOPTED) { if let Some(pipe) = self.pipe.take() { - // SAFETY: `pipe` is the `NonNull` from `Box::leak` in `from()`, + // SAFETY: `pipe` is the `NonNull` from `Box::leak` in `create()`, // never adopted (gated on `!PIPE_ADOPTED`); `close_and_destroy` // is the unique reclaim and accepts both never-init'd and // init'd-but-unowned handles. @@ -1053,34 +1119,16 @@ impl Drop for WindowsNamedPipe { } } -// Hand-written `ssl` shim for the `bun_uws` cycle-break extern — the safe -// method returns `Option<*mut SSL>` while the C ABI flattens to a nullable -// raw pointer. All other `WindowsNamedPipe__*` symbols are emitted by -// `#[uws_callback(export = …)]` on the inherent methods above. -#[unsafe(no_mangle)] -pub(crate) extern "C" fn WindowsNamedPipe__set_inline_reject(this: *const c_void) { - // SAFETY: `this` is a live `*const WindowsNamedPipe` from the bun_uws opaque handle. - if let Some(wrapper) = unsafe { (*this.cast::()).wrapper_ref() } { - wrapper.set_inline_reject(); +impl bun_event_loop::Taskable for WindowsNamedPipe { + const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::WindowsNamedPipe; + /// The free that the last ref queued will not run, and nobody else frees `this`. It runs no script. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract. + unsafe { Self::run_event(this) } } -} - -#[unsafe(no_mangle)] -pub(crate) extern "C" fn WindowsNamedPipe__latest_session( - this: *const c_void, -) -> *mut bun_boringssl_sys::SSL_SESSION { - // SAFETY: `this` is a live `*const WindowsNamedPipe` from the bun_uws opaque handle. - unsafe { (*this.cast::()).wrapper_ref() } - .map_or(core::ptr::null_mut(), |wrapper| wrapper.latest_session()) -} - -#[unsafe(no_mangle)] -pub(crate) extern "C" fn WindowsNamedPipe__ssl(this: *const c_void) -> *mut boringssl::SSL { - // SAFETY: `this` is a live `*const WindowsNamedPipe` from the bun_uws opaque handle. - unsafe { - (*this.cast::()) - .ssl() - .unwrap_or(core::ptr::null_mut()) + /// A socket's own hop. Its handlers carry their context. + unsafe fn context(_: *const Self) -> bun_event_loop::ContextId { + bun_event_loop::ContextId::NONE } } @@ -1088,12 +1136,12 @@ impl bun_io::pipe_writer::WriterParent for WindowsNamedPipe { #[inline] unsafe fn ref_(this: *mut Self) { // SAFETY: BACKREF set via `set_parent`. Intrusive refcount bump. - unsafe { (*this).r#ref() }; + unsafe { (*this).ref_() }; } #[inline] unsafe fn deref(this: *mut Self) { - // SAFETY: see ref_. May free `this`. - unsafe { (*this).deref() }; + // SAFETY: see ref_. The last ref queues the free. + unsafe { WindowsNamedPipe::deref(this) }; } } diff --git a/src/runtime/socket/WindowsNamedPipeContext.rs b/src/runtime/socket/WindowsNamedPipeContext.rs deleted file mode 100644 index 2723608b059a..000000000000 --- a/src/runtime/socket/WindowsNamedPipeContext.rs +++ /dev/null @@ -1,518 +0,0 @@ -use core::cell::Cell; -use core::ffi::c_void; -use core::ptr; -use core::sync::atomic::{AtomicUsize, Ordering}; - -use crate::socket::NewSocket; -use crate::socket::SSLConfig; -use crate::socket::windows_named_pipe::{Handlers as NamedPipeHandlers, WindowsNamedPipe}; -use crate::socket::{TCPSocket, TLSSocket}; -use bun_boringssl_sys as boringssl; -use bun_core::ZStr; -use bun_event_loop::Task; -use bun_jsc::virtual_machine::VirtualMachine; -use bun_jsc::{GlobalRef, SysErrorJsc}; -use bun_sys::windows::libuv as uv; -use bun_sys::{self, Error as SysError, Fd, SystemErrno}; -use bun_uws::{self as uws, us_bun_verify_error_t}; - -bun_output::declare_scope!(WindowsNamedPipeContext, visible); - -/// Live contexts, read by `bun:internal-for-testing` leak tests: `heapStats()` -/// only sees the JS wrappers, and a failed connect lets those be collected -/// while the context (and its ref on the native socket) stays behind. -pub(crate) static LIVE_COUNT: AtomicUsize = AtomicUsize::new(0); - -#[derive(bun_ptr::CellRefCounted)] -#[ref_count(destroy = schedule_deinit)] -pub(crate) struct WindowsNamedPipeContext { - // Intrusive refcount; on zero → `schedule_deinit` (deferred free), not - // immediate `Box::from_raw`. - ref_count: Cell, - // Holds `create()`'s +1 on the wrapped socket while not `None`. `on_close` - // and `fail_connect` release it and clear this; anything still here is - // deref'd in `Drop` before the `named_pipe` field drops — teardown order - // must stay socket.deref() then named_pipe deinit. - socket: SocketType, - /// `pub(super)` so `WindowsNamedPipeListeningContext::on_client_connect` - /// (sibling module) can call `get_accepted_by` on the freshly-created - /// client. - pub(super) named_pipe: WindowsNamedPipe, - - vm: &'static VirtualMachine, - global_this: GlobalRef, - task_event: EventState, - is_open: bool, - /// A socket over a Windows named pipe is in no uSockets group: the context - /// that opened it closes it through this owner when it stops. - abort_handle: bun_jsc::AbortHandle, -} - -bun_jsc::impl_abort_handle_owner!(WindowsNamedPipeContext, abort_handle, |this, _cause| { - // SAFETY: trait contract — `this` is live; `close` re-enters `on_close`, - // which may free `this`. - unsafe { (*ptr::addr_of_mut!((*this).named_pipe)).close_or_cancel_connect() } -}); - -/// Reached from `on_close` → `Self::deref` while `WindowsNamedPipe::on_close` -/// still holds a live `&mut (*this).named_pipe` and uses it after we return, so -/// project raw fields only — same constraint as the `on_*` handlers below. -fn schedule_deinit(this: *mut WindowsNamedPipeContext) { - // SAFETY: called from `deref()` at count zero; `this` is live until the task fires. - // `task_event`/`vm` are disjoint from the caller's `&mut named_pipe`. - unsafe { - debug_assert!((*this).task_event != EventState::Deinit); - (*this).task_event = EventState::Deinit; - (*this).vm.enqueue_task(Task::init(this)); - } -} - -#[repr(u8)] -#[derive(Copy, Clone, PartialEq, Eq)] -pub(crate) enum EventState { - Deinit, - None, -} - -/// Intrusive-refcounted self-pointers into the wrapped JS socket (a *different* -/// allocation from this context, so `ThisPtr`'s `Deref` is sound on them). -/// `Copy` so matching by value avoids `&self.socket` aliasing `&mut self.named_pipe`. -#[derive(Copy, Clone)] -pub(crate) enum SocketType { - Tls(bun_ptr::ThisPtr), - Tcp(bun_ptr::ThisPtr), - None, -} - -/// Build a `uws::NewSocketHandler` from the wrapped named pipe. -/// -/// Takes a raw `*mut WindowsNamedPipe` (NOT `&mut`) because every caller is a -/// `NamedPipeHandlers` callback invoked *from* `WindowsNamedPipe::on_*`, which -/// already holds a live `&mut WindowsNamedPipe` to the same field and touches -/// it again after the callback returns. Forming a second `&mut` here would -/// retag the field and invalidate the caller's reference (Stacked Borrows). -/// The handler only needs the raw address to stuff into `InternalSocket::Pipe`. -#[inline] -fn socket_from_named_pipe( - pipe: *mut WindowsNamedPipe, -) -> uws::NewSocketHandler { - uws::NewSocketHandler { - socket: uws::InternalSocket::Pipe(pipe.cast()), - } -} - -/// Dispatch a `SocketType` value to a single body written generically over -/// `NewSocket`. Binds the inner `ThisPtr>` as `$s` -/// and a per-arm `const $ssl: bool` so the body can call -/// `NewSocket::on_x($s, socket_from_named_pipe::<$ssl>(..), ..)` once instead -/// of hand-duplicating the `Tls`/`Tcp` arms. `SocketType::None` is a no-op. -/// -/// Takes the `SocketType` by *value* (Copy) — not `*mut Self` — so callers -/// that must snapshot before mutating (`on_close`) or branch and re-match -/// (`on_error`) pass their saved copy; see the Stacked-Borrows note on the -/// `on_*` block below. -macro_rules! match_socket { - ($scrutinee:expr, |$s:ident: NewSocket<$ssl:ident>| $body:expr) => { - // This context is the named-pipe sockets' trampoline: what a handler - // left pending is folded here. - match $scrutinee { - SocketType::Tls($s) => { - const $ssl: bool = true; - let _ = $ssl; - crate::dispatch::fold($body) - } - SocketType::Tcp($s) => { - const $ssl: bool = false; - let _ = $ssl; - crate::dispatch::fold($body) - } - SocketType::None => {} - } - }; -} - -// ── NamedPipeHandlers callbacks ────────────────────────────────────────────── -// -// All eight `on_*` handlers below take `this: *mut Self` (NOT `&mut self`). -// They are invoked from `WindowsNamedPipe::on_*` via `(self.handlers.on_x)(ctx, ..)` -// where the caller already holds a live `&mut WindowsNamedPipe` — i.e. a -// `&mut (*this).named_pipe` — and *uses it again after the handler returns* -// (e.g. `self.incoming.clear()`, `self.close()`, `self.release_resources()`). -// -// Forming `&mut *this` (or `&mut (*this).named_pipe`) here would retag from the -// allocation-root provenance and pop the caller's Unique tag off the borrow -// stack → Stacked Borrows UB / LLVM `noalias` violation when control returns. -// -// Instead each handler projects only the disjoint fields it needs (`socket`, -// `is_open`, `global_this`) via raw-pointer place expressions, and passes -// `addr_of_mut!((*this).named_pipe)` as a raw pointer without retagging. -/// Fails the pending connect and releases `create()`'s sole ref, unless -/// `disarm()` runs first. -struct FailAndRelease(Option<*mut WindowsNamedPipeContext>); - -impl FailAndRelease { - fn get(&mut self) -> *mut WindowsNamedPipeContext { - self.0.expect("guard already disarmed") - } - - fn disarm(mut self) -> *mut WindowsNamedPipeContext { - self.0.take().expect("guard already disarmed") - } -} - -impl Drop for FailAndRelease { - fn drop(&mut self) { - if let Some(this) = self.0.take() { - WindowsNamedPipeContext::fail_and_release(this); - } - } -} - -impl WindowsNamedPipeContext { - fn on_open(this: *mut Self) { - // SAFETY: `this` is the live ctx ptr registered in `create()`; `is_open`, - // `socket` and the `named_pipe` field *address* are all reachable without - // forming a reference that overlaps the caller's `&mut named_pipe`. - let (socket, pipe) = unsafe { - (*this).is_open = true; - ((*this).socket, ptr::addr_of_mut!((*this).named_pipe)) - }; - match_socket!(socket, |s: NewSocket| NewSocket::on_open( - s, - socket_from_named_pipe::(pipe) - )); - } - - fn on_data(this: *mut Self, decoded_data: &[u8]) { - // SAFETY: see `on_open`. - let (socket, pipe) = unsafe { ((*this).socket, ptr::addr_of_mut!((*this).named_pipe)) }; - match_socket!(socket, |s: NewSocket| NewSocket::on_data( - s, - socket_from_named_pipe::(pipe), - decoded_data - )); - } - - fn on_session(this: *mut Self, session: &[u8]) { - // Only the TLS wrapper parks sessions; the TCP arm can never get here. - // SAFETY: see `on_open`. - if let SocketType::Tls(s) = unsafe { (*this).socket } { - crate::dispatch::fold(TLSSocket::on_session(s, session)); - } - } - - fn server_identity( - this: *mut Self, - ssl: &mut bun_boringssl_sys::SSL, - ) -> bun_boringssl::ServerIdentity { - // SAFETY: see `on_open`. - match unsafe { (*this).socket } { - SocketType::Tls(s) => s.server_identity(ssl), - _ => bun_boringssl::ServerIdentity::Unchecked, - } - } - - fn on_keylog(this: *mut Self, line: &[u8]) { - // SAFETY: see `on_open`. - if let SocketType::Tls(s) = unsafe { (*this).socket } { - crate::dispatch::fold(TLSSocket::on_keylog(s, line)); - } - } - - fn on_handshake(this: *mut Self, success: bool, ssl_error: us_bun_verify_error_t) { - // SAFETY: see `on_open`. - let (socket, pipe) = unsafe { ((*this).socket, ptr::addr_of_mut!((*this).named_pipe)) }; - match_socket!(socket, |s: NewSocket| NewSocket::on_handshake( - s, - socket_from_named_pipe::(pipe), - success as i32, - ssl_error - )); - } - - fn on_end(this: *mut Self) { - // SAFETY: see `on_open`. - let (socket, pipe) = unsafe { ((*this).socket, ptr::addr_of_mut!((*this).named_pipe)) }; - match_socket!(socket, |s: NewSocket| NewSocket::on_end( - s, - socket_from_named_pipe::(pipe) - )); - } - - fn on_writable(this: *mut Self) { - // SAFETY: see `on_open`. - let (socket, pipe) = unsafe { ((*this).socket, ptr::addr_of_mut!((*this).named_pipe)) }; - match_socket!(socket, |s: NewSocket| NewSocket::on_writable( - s, - socket_from_named_pipe::(pipe) - )); - } - - fn on_error(this: *mut Self, err: &SysError) { - // SAFETY: see `on_open`. `is_open`/`socket` are Copy field reads. - let (is_open, socket) = unsafe { ((*this).is_open, (*this).socket) }; - if is_open { - match_socket!(socket, |s: NewSocket| { - // SAFETY: `this` is live; `global_this` is disjoint from the caller's - // `&mut named_pipe` and the borrow ends before `handle_error` runs JS. - let js_err = err.to_js(unsafe { &(*this).global_this }); - s.handle_error(js_err) - }); - } else { - Self::fail_connect(this, err.errno as i32); - } - } - - /// `connectError` is the last event a socket whose connect failed receives: - /// `handle_connect_error` releases its connecting ref, and no `on_close` - /// follows for it. So this context is done with the socket too: release - /// `create()`'s +1 now and forget the socket, so that neither the pipe's - /// `on_close` (which still fires on the async failure path) nor `Drop` - /// touches it again. - fn fail_connect(this: *mut Self, errno: i32) { - // SAFETY: see `on_open`. Cleared before `connectError` runs JS, which may - // connect the same socket again through a new context. - let socket = unsafe { - let socket = (*this).socket; - (*this).socket = SocketType::None; - socket - }; - match_socket!(socket, |s: NewSocket| { - let failed = NewSocket::handle_connect_error(s, errno, 0); - // Release the +1 ref taken in `create()`. - s.get().deref(); - failed - }); - } - - fn on_timeout(this: *mut Self) { - // SAFETY: see `on_open`. - let (socket, pipe) = unsafe { ((*this).socket, ptr::addr_of_mut!((*this).named_pipe)) }; - match_socket!(socket, |s: NewSocket| NewSocket::on_timeout( - s, - socket_from_named_pipe::(pipe) - )); - } - - fn on_close(this: *mut Self) { - // SAFETY: see `on_open`. Snapshot `socket` BEFORE clearing it, then match - // the snapshot — the macro must not read `(*this).socket` directly here. - let (socket, pipe) = unsafe { - let socket = (*this).socket; - (*this).socket = SocketType::None; - (socket, ptr::addr_of_mut!((*this).named_pipe)) - }; - match_socket!(socket, |s: NewSocket| { - let closed = NewSocket::on_close(s, socket_from_named_pipe::(pipe), 0, None); - // Release the +1 ref taken in `create()`. - s.get().deref(); - closed - }); - // SAFETY: `this` is the live ctx pointer registered in create(); - // releasing the named-pipe's ref may schedule deinit. - unsafe { Self::deref(this) }; - } - - /// # Safety - /// `this` is the live queued pointer; the call may free it. - pub(crate) unsafe fn run_event(this: *mut Self) { - // SAFETY: called from the `task_tag::WindowsNamedPipeContext` dispatch - // arm; `this` is the live ctx pointer registered in create() - match unsafe { (*this).task_event } { - EventState::Deinit => { - // SAFETY: `this` was allocated via heap::alloc in create(); refcount hit zero - // and this deferred task is the sole remaining owner. Drop runs field destructors. - drop(unsafe { bun_core::heap::take(this) }); - } - EventState::None => panic!("Invalid event state"), - } - } - - /// Owns the freshly-`create()`d context until `disarm()`: on any early - /// return it fails the pending connect and releases the sole ref. - fn armed(this: *mut Self) -> FailAndRelease { - FailAndRelease(Some(this)) - } - - /// errdefer shared by `open`/`connect`: fail the wrapped JS socket, then - /// release the only ref `create()` handed us. - fn fail_and_release(this: *mut Self) { - Self::fail_connect(this, SystemErrno::ENOENT as i32); - // SAFETY: `this` was just returned from `create()` (refcount==1); - // release the only ref on the errdefer path. - unsafe { Self::deref(this) }; - } - - pub(crate) fn create( - cx: &bun_jsc::JsThread<'_>, - socket: SocketType, - ) -> *mut WindowsNamedPipeContext { - let global_this = GlobalRef::from(cx.global()); - let vm: &'static VirtualMachine = global_this.bun_vm(); - let this: *mut WindowsNamedPipeContext = bun_core::heap::into_raw(Box::< - core::mem::MaybeUninit, - >::new_uninit()) - .cast(); - - // named_pipe owns the pipe (PipeWriter owns the pipe and will close and deinit it) - // Non-capturing closures coerce to `fn(*mut c_void, …)`; each casts the - // erased ctx ptr back to `*mut Self` and forwards it RAW — the callee - // must not form `&mut Self` (see the doc-comment on the `on_*` block - // above for the Stacked-Borrows constraint vs the caller's - // `&mut WindowsNamedPipe`). - let handlers = NamedPipeHandlers { - ctx: this.cast::(), - // SAFETY: `p` is the `ctx` set above (`this.cast()`); the - // WindowsNamedPipe never invokes a handler after `on_close` - // schedules deinit, so the allocation is live for the call. - // `rc_ref` projects `ref_count` via raw place — `(*p).ref_()` would - // autoref `&Self` over the whole struct, but `WindowsNamedPipe::r#ref` - // holds `&mut (*this).named_pipe` across this callback. - ref_ctx: |p| unsafe { ::rc_ref(p.cast::()) }, - // SAFETY: `p` is the `ctx` set above (`this.cast()`); the allocation is live for the call (see `ref_ctx`). - deref_ctx: |p| unsafe { Self::deref(p.cast::()) }, - on_open: |p| Self::on_open(p.cast::()), - on_data: |p, d| Self::on_data(p.cast::(), d), - on_handshake: |p, ok, err| Self::on_handshake(p.cast::(), ok, err), - on_end: |p| Self::on_end(p.cast::()), - on_writable: |p| Self::on_writable(p.cast::()), - on_error: |p, e| Self::on_error(p.cast::(), &e), - on_timeout: |p| Self::on_timeout(p.cast::()), - on_close: |p| Self::on_close(p.cast::()), - on_session: |p, d| Self::on_session(p.cast::(), d), - on_keylog: |p, d| Self::on_keylog(p.cast::(), d), - server_identity: |p, ssl| Self::server_identity(p.cast::(), ssl), - }; - let named_pipe = { - let pipe = Box::new(bun_core::ffi::zeroed::()); - WindowsNamedPipe::from(pipe, handlers, vm) - }; - // SAFETY: `this` is freshly allocated uninit storage exclusively owned here; we write - // every field exactly once before any read. - unsafe { - ptr::write( - this, - WindowsNamedPipeContext { - ref_count: Cell::new(1), - socket, - named_pipe, - vm, - global_this, - task_event: EventState::None, - is_open: false, - abort_handle: bun_jsc::AbortHandle::for_owner::(), - }, - ); - (*ptr::addr_of_mut!((*this).named_pipe)) - .root - .set(ptr::addr_of_mut!((*this).named_pipe)); - } - LIVE_COUNT.fetch_add(1, Ordering::Relaxed); - - // Take a +1 intrusive ref so the wrapped JS socket outlives this context. - match_socket!(socket, |s: NewSocket| { - s.ref_(); - Ok(()) - }); - - // SAFETY: non-null, fully initialised above, heap-pinned; disarmed when freed. - unsafe { bun_jsc::AbortHandle::arm_owner(this, cx.context()) }; - - this - } - - /// `owned_ctx` is moved into `named_pipe.open`. Prefer it over `ssl_config` so a - /// memoised `tls.createSecureContext` reaches this path with its trust store intact — - /// on this branch `[buntls]` returns `{secureContext}` only, so `ssl_config` - /// alone would be empty. - pub(crate) fn open( - cx: &bun_jsc::JsThread<'_>, - fd: Fd, - ssl_config: Option, - owned_ctx: Option, - socket: SocketType, - ) -> Result<*mut WindowsNamedPipe, crate::Error> { - // TODO: reuse the same context for multiple connections when possibles - - let this = WindowsNamedPipeContext::create(cx, socket); - - // The guard reaches `socket` through `this`: `create()` moved it there. - let mut guard = Self::armed(this); - - // SAFETY: `this` is live and exclusively accessed here - unsafe { (*guard.get()).named_pipe.open(fd, ssl_config, owned_ctx) }?; - - let this = guard.disarm(); - // SAFETY: `this` is live; returning interior pointer to heap-allocated field (BACKREF) - Ok(unsafe { ptr::addr_of_mut!((*this).named_pipe) }) - } - - /// See `open` for `owned_ctx` ownership. - pub(crate) fn connect( - cx: &bun_jsc::JsThread<'_>, - path: &[u8], - ssl_config: Option, - owned_ctx: Option, - socket: SocketType, - ) -> Result<*mut WindowsNamedPipe, crate::Error> { - // TODO: reuse the same context for multiple connections when possibles - - let this = WindowsNamedPipeContext::create(cx, socket); - let mut guard = Self::armed(this); - - // SAFETY: `this` is live and exclusively accessed here - let named_pipe = unsafe { &mut (*guard.get()).named_pipe }; - - if path[path.len() - 1] == 0 { - // is already null terminated - // SAFETY: path[path.len()-1] == 0 checked above - let slice_z = ZStr::from_slice_with_nul(path); - named_pipe.connect(slice_z, ssl_config, owned_ctx)?; - } else { - let mut path_buf = bun_paths::path_buffer_pool::get(); - // we need to null terminate the path - let len = path.len().min(path_buf.len() - 1); - - path_buf[..len].copy_from_slice(&path[..len]); - path_buf[len] = 0; - // SAFETY: path_buf[len] == 0 written above - let slice_z = ZStr::from_buf(&path_buf[..], len); - named_pipe.connect(slice_z, ssl_config, owned_ctx)?; - } - - let this = guard.disarm(); - // SAFETY: `this` is live; returning interior pointer to heap-allocated field (BACKREF) - Ok(unsafe { ptr::addr_of_mut!((*this).named_pipe) }) - } -} - -impl Drop for WindowsNamedPipeContext { - fn drop(&mut self) { - bun_output::scoped_log!(WindowsNamedPipeContext, "deinit"); - LIVE_COUNT.fetch_sub(1, Ordering::Relaxed); - // Deref the wrapped socket, then let `named_pipe` drop. - match_socket!( - core::mem::replace(&mut self.socket, SocketType::None), - // +1 ref taken in `create()`; this is the matching release. - |s: NewSocket| { - s.get().deref(); - Ok(()) - } - ); - // `named_pipe` drops via field destructor after this. - } -} - -impl bun_event_loop::Taskable for WindowsNamedPipeContext { - const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::WindowsNamedPipeContext; - /// A `Deinit` hop (refcount already zero) that will not run: `this` is the - /// heap context, freed by nobody else — do what the hop does, script-free. - unsafe fn release_unrun(this: *mut Self) { - // SAFETY: fn contract. - unsafe { Self::run_event(this) } - } - /// A socket's own hop; its handlers carry their context. - unsafe fn context(_: *const Self) -> bun_event_loop::ContextId { - bun_event_loop::ContextId::NONE - } -} diff --git a/src/runtime/socket/mod.rs b/src/runtime/socket/mod.rs index aafa386a218e..8cee40c1d7f9 100644 --- a/src/runtime/socket/mod.rs +++ b/src/runtime/socket/mod.rs @@ -21,42 +21,24 @@ 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; +#[path = "FdPipe.rs"] +pub(crate) mod fd_pipe; + #[cfg(windows)] #[path = "WindowsNamedPipe.rs"] pub(crate) mod windows_named_pipe; -#[cfg(windows)] -#[path = "WindowsNamedPipeContext.rs"] -pub(crate) mod windows_named_pipe_context; - mod bundled_root_certs; mod cert_files; #[cfg(all(unix, not(target_vendor = "apple")))] mod system_certs; -/// Re-export of the canonical `bun_uws::ssl_wrapper` plus the runtime-tier -/// `init(&SSLConfig, ..)` constructor that the lower tier can't see (it would -/// need to name `crate::server::server_config::SSLConfig`). The body is the -/// same `as_usockets() → init_from_options()` round-trip the old local copy -/// did; the duplicate module file is gone. -pub(crate) mod ssl_wrapper { - pub(crate) use bun_uws::ssl_wrapper::*; - - /// Thin wrapper over `SSLWrapper::init_from_options` so callers in this - /// tier can keep passing `&SSLConfig` directly. - pub(crate) fn init( - ssl_options: &crate::server::server_config::SSLConfig, - is_client: bool, - handlers: Handlers, - ) -> Result, crate::Error> { - SSLWrapper::::init_from_options(&ssl_options.as_usockets(), is_client, handlers) - .map_err(crate::Error::from) - } -} - +pub(crate) mod node_http_socket; pub(crate) mod udp_socket; #[path = "uws_dispatch.rs"] @@ -70,10 +52,7 @@ 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, -}; +pub(crate) use ssl_config::{SSLConfig, http_client_defaults, resolve_reject_unauthorized}; // ─── canonical type surface ────────────────────────────────────────────────── // These were previously stub-defined inline here; now that the real @@ -83,14 +62,13 @@ pub(crate) use ssl_config::{ pub(crate) use handlers::{Handlers, SocketConfig}; pub(crate) use listener::Listener; pub(crate) use socket_address::SocketAddress; -pub(crate) use socket_body::DuplexUpgradeContext; pub(crate) use socket_body::{ - Flags as SocketFlags, NativeCallbacks, NewSocket, SocketMode, TCPSocket, TLSSocket, - server_ctx_rejects_unauthorized, server_ctx_requests_cert, + DeferredWriteCompletion, Flags as SocketFlags, NativeCallbacks, NewSocket, SocketMode, + TCPSocket, TLSSocket, server_ctx_rejects_unauthorized, server_ctx_requests_cert, }; #[cfg(windows)] -pub(crate) use windows_named_pipe_context::WindowsNamedPipeContext; +pub(crate) use windows_named_pipe::WindowsNamedPipe; pub(crate) use udp_socket::UDPSocket; diff --git a/src/runtime/socket/node_http_socket.rs b/src/runtime/socket/node_http_socket.rs new file mode 100644 index 000000000000..037137e3499c --- /dev/null +++ b/src/runtime/socket/node_http_socket.rs @@ -0,0 +1,449 @@ +//! `req.socket._handle` of a node:http server: a `TCPSocket` / `TLSSocket` over `InternalSocket::UwsHttp` until `leave_http` adopts the `us_socket_t`. + +use core::ffi::{c_int, c_void}; +use std::rc::Rc; + +use bun_jsc::virtual_machine::VirtualMachine; +use bun_jsc::{JSGlobalObject, JSValue, JsResult}; +use bun_ptr::ThisPtr; +use bun_uws::{self as uws, us_socket_t}; +use bun_uws_sys::response::PerMessageDeflateParams; + +use super::{Handlers, NewSocket, SocketFlags}; +use crate::dispatch::fold; +use crate::server::{AnyServer, ServerWebSocket}; + +type Transport = uws::NewSocketHandler; + +/// What the handle of each connection of one node:http server starts with, as a `Listener` has it for its connections. +pub(crate) struct AcceptedSocketConfig { + pub(crate) handlers: Rc, + /// `ALPNProtocols`, in wire format. + pub(crate) protos: Option>, + /// The handle's first `data`, where node:net's `open` handler finds its accept function. The server wrapper's `socketData` slot keeps it alive. + pub(crate) data: JSValue, +} + +impl NewSocket { + /// The socket holds one ref for the connection, which `on_close` releases. + fn accept_from_http( + config: &AcceptedSocketConfig, + s: *mut us_socket_t, + reject_unauthorized: bool, + ) -> ThisPtr { + let this = Self::new(Some(Rc::clone(&config.handlers))); + let transport = Transport::::from_uws_http(s); + this.socket.set(transport); + this.protos.set(config.protos.clone()); + this.update_flags(|f| { + f.set(SocketFlags::REJECT_UNAUTHORIZED, reject_unauthorized); + f.insert(SocketFlags::NO_DELAY | SocketFlags::HELD_BY_SERVER); + }); + this.ref_(); + let global = config.handlers.global_object; + Self::data_set_cached(this.get_this_value(&global), &global, config.data); + this + } + + /// Bytes read from the connection, after TLS decryption. uWS counts them while it owns the connection. + pub(crate) fn total_bytes_read(&self) -> u64 { + match self.socket.get().socket { + uws::InternalSocket::UwsHttp(s) => Bun__NodeHTTPServerSocket__bytesRead(s, SSL), + _ => self.bytes_read.get(), + } + } + + /// What `socket.write()` wrote and the bodies of the finished responses, plus the body of the response that has the connection while uWS owns it. + pub(crate) fn total_bytes_written(&self) -> u64 { + let own = self.bytes_written.get(); + match self.socket.get().socket { + uws::InternalSocket::UwsHttp(s) => { + own + Bun__NodeHTTPServerSocket__responseBytesWritten(s, SSL) + } + _ => own, + } + } + + /// The bytes that the kernel hasn't taken yet, sealed TLS records too. uWS and the response that has the connection hold them while uWS owns it. + pub(crate) fn write_queue_size(&self) -> u64 { + let socket = self.socket.get(); + match socket.socket { + uws::InternalSocket::UwsHttp(s) => Bun__NodeHTTPServerSocket__writeQueueSize(s, SSL), + _ => { + self.buffered_data_for_node_net.get().len() as u64 + + u64::from(socket.ssl_spill_len()) + } + } + } + + /// uWS has let go of `s`, which is open, and has destructed its ext: nothing here may ask the `UwsHttp` transport. `unsent` is what uWS accepted and the kernel hasn't taken. + fn leave_http( + this: ThisPtr, + s: *mut us_socket_t, + unsent: &[u8], + reads_paused: bool, + bytes_read: u64, + response_bytes_written: u64, + ) { + this.bytes_read.set(bytes_read); + this.bytes_written + .set(this.bytes_written.get() + response_bytes_written); + let handlers = this.get_handlers(); + let vm = VirtualMachine::get(); + let loop_ = vm.uws_loop(); + let group: *mut uws::SocketGroup = vm + .as_mut() + .client_socket_groups_in(vm.context_of(handlers.context())) + .bun_connect_group::(loop_); + let kind = if SSL { + uws::SocketKind::BunSocketTls + } else { + uws::SocketKind::BunSocketTcp + }; + let adopted = + Transport::::adopt_group(s, group, kind, this.as_ptr(), |owner, socket| { + // SAFETY: `owner` is `this`, which the caller keeps alive. + unsafe { (*owner).socket.set(socket) } + }); + debug_assert!(adopted, "the caller checks that the socket is open"); + this.update_flags(|f| f.remove(SocketFlags::HELD_BY_SERVER)); + if this.ref_pollref_on_connect.get() { + this.poll_ref.with_mut(|p| p.ref_(bun_io::js_vm_ctx())); + } + // Every reason but the handle's own pause, which is node:net's full buffer, belonged to the HTTP connection. + if reads_paused && !this.flags.get().contains(SocketFlags::IS_PAUSED) { + this.socket.get().resume_stream(); + } + if !unsent.is_empty() { + this.buffered_data_for_node_net + .with_mut(|queued| queued.append_slice(unsent)); + fold(Self::on_writable(this, this.socket.get())); + } + } +} + +/// What the `ws` package negotiated for one connection. `handshake` is the whole 101 response head. +pub(crate) struct WebSocketUpgrade<'a> { + pub(crate) websocket: JSValue, + pub(crate) handlers: JSValue, + pub(crate) handshake: &'a [u8], + pub(crate) max_payload_length: u32, + pub(crate) auto_pong: bool, + pub(crate) skip_utf8_validation: bool, + pub(crate) deflate_params: Option, +} + +unsafe extern "C" { + safe fn Bun__NodeHTTPServerSocket__bytesRead(s: *mut us_socket_t, ssl: bool) -> u64; + safe fn Bun__NodeHTTPServerSocket__responseBytesWritten(s: *mut us_socket_t, ssl: bool) -> u64; + safe fn Bun__NodeHTTPServerSocket__writeQueueSize(s: *mut us_socket_t, ssl: bool) -> u64; + safe fn Bun__getNodeHTTPServerSocketThisValue(is_ssl: bool, socket: *mut c_void) -> JSValue; + fn Bun__NodeHTTPSocket__adoptIntoWebSocket( + ssl: bool, + s: *mut us_socket_t, + web_socket_context: *mut c_void, + user_data: *mut c_void, + handshake: *const u8, + handshake_length: usize, + deflate_params: *const PerMessageDeflateParams, + handle: *mut c_void, + ) -> *mut us_socket_t; +} + +impl NewSocket { + /// The open `us_socket_t` of a connection that `server_handlers`' server accepted and that has left HTTP with nothing queued. + fn socket_for_web_socket( + &self, + server_handlers: Option<&Rc>, + ) -> Option<*mut us_socket_t> { + let accepted_by_server = matches!( + (self.handlers.get(), server_handlers), + (Some(own), Some(servers)) if Rc::ptr_eq(own, servers) + ); + let socket = self.socket.get(); + self.own_connected_socket().filter(|_| { + accepted_by_server + && !socket.is_closed() + && !socket.is_shutdown() + && self.buffered_data_for_node_net.get().len() == 0 + }) + } + + /// Makes the connection a WebSocket of `server` for the `ws` package. Returns the `ServerWebSocket`, or `None` with nothing written. + pub(crate) fn become_web_socket( + &self, + global: &JSGlobalObject, + server: AnyServer, + server_handlers: Option<&Rc>, + upgrade: &WebSocketUpgrade<'_>, + ) -> JsResult> { + if server.terminated() || self.socket_for_web_socket(server_handlers).is_none() { + return Ok(None); + } + server.init_ws_package_handlers(global, upgrade.handlers)?; + // That read properties of an object, which can run script. + let Some(s) = self.socket_for_web_socket(server_handlers) else { + return Ok(None); + }; + // SAFETY: `s` is open and its ext holds only this socket's pointer. + let adopted = + unsafe { adopt_into_web_socket(SSL, s, self.as_ctx_ptr().cast(), server, upgrade) }; + let Some(ws) = adopted else { + return Ok(None); + }; + self.update_flags(|f| f.insert(SocketFlags::HELD_BY_SERVER)); + self.bytes_written + .set(self.bytes_written.get() + upgrade.handshake.len() as u64); + // The server holds the event loop for its WebSockets. + self.poll_ref.with_mut(|p| p.unref(bun_io::js_vm_ctx())); + if self.flags.get().contains(SocketFlags::IS_PAUSED) { + self.update_flags(|f| f.remove(SocketFlags::IS_PAUSED)); + self.socket.get().resume_stream(); + } + Ok(ws.js_value()) + } +} + +/// SAFETY: `s` is open and its ext holds only `handle`, which the WebSocket keeps as its socket data. `None` with nothing written. +unsafe fn adopt_into_web_socket<'a>( + ssl: bool, + s: *mut us_socket_t, + handle: *mut c_void, + server: AnyServer, + upgrade: &WebSocketUpgrade<'_>, +) -> Option<&'a ServerWebSocket> { + let handler = server.ws_package_handler()?; + let context = server.ws_package_context( + upgrade.max_payload_length, + upgrade.auto_pong, + upgrade.skip_utf8_validation, + upgrade.deflate_params.is_some(), + )?; + let ws = ServerWebSocket::init_for_ws_package(handler.get(), upgrade.websocket); + // SAFETY: `init` just stored `ws` inline in its JS wrapper, which `ws` keeps alive. + let ws_ref = unsafe { &*ws }; + // The server doesn't count the connection until the WebSocket opens, so this keeps a stopped server from draining. + server.on_pending_request(); + // SAFETY: the caller's contract. + let adopted = unsafe { + Bun__NodeHTTPSocket__adoptIntoWebSocket( + ssl, + s, + context.as_ptr().cast(), + ws.cast(), + upgrade.handshake.as_ptr(), + upgrade.handshake.len(), + upgrade + .deflate_params + .as_ref() + .map_or(core::ptr::null(), core::ptr::from_ref), + handle, + ) + }; + server.on_request_complete(); + if adopted.is_null() { + ws_ref.discard_unopened(); + return None; + } + Some(ws_ref) +} + +/// Runs `$body` with `$this: ThisPtr>` for a handle that `Bun__NodeHTTPSocket__create` returned. +macro_rules! with_handle { + ($ssl:expr, $handle:expr, |$this:ident| $body:expr) => { + if $ssl { + // SAFETY: the caller's contract. + let $this = unsafe { ThisPtr::>::new($handle.cast()) }; + $body + } else { + // SAFETY: the caller's contract. + let $this = unsafe { ThisPtr::>::new($handle.cast()) }; + $body + } + }; +} + +/// SAFETY: `config` is a server's `node_socket_config`, and `s` is an open connection of that server. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Bun__NodeHTTPSocket__create( + config: *const AcceptedSocketConfig, + s: *mut us_socket_t, + ssl: bool, + reject_unauthorized: bool, +) -> *mut c_void { + // SAFETY: the caller's contract. + let config = unsafe { &*config }; + if ssl { + NewSocket::::accept_from_http(config, s, reject_unauthorized) + .as_ptr() + .cast() + } else { + NewSocket::::accept_from_http(config, s, false) + .as_ptr() + .cast() + } +} + +/// SAFETY: here and below, `handle` came from `create` with the same `ssl`, and C++ still holds the connection's ref on it. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Bun__NodeHTTPSocket__onOpen(handle: *mut c_void, ssl: bool) { + with_handle!(ssl, handle, |this| fold(NewSocket::on_open( + this, + this.socket.get() + ))) +} + +/// uWS has sent all that it buffered for the connection. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Bun__NodeHTTPSocket__onWritable(handle: *mut c_void, ssl: bool) { + with_handle!(ssl, handle, |this| fold(NewSocket::on_writable( + this, + this.socket.get() + ))) +} + +/// The HTTP side of a connection that uWS owns, a JSNodeHTTPServerSocket. `None` for every other socket. +pub(crate) fn http_connection_of(socket: &NewSocket) -> Option { + match socket.socket.get().socket { + uws::InternalSocket::UwsHttp(s) => { + Some(Bun__getNodeHTTPServerSocketThisValue(SSL, s.cast())).filter(|it| !it.is_empty()) + } + _ => None, + } +} + +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Bun__NodeHTTPSocket__globalObject( + handle: *mut c_void, + ssl: bool, +) -> *const JSGlobalObject { + with_handle!(ssl, handle, |this| core::ptr::from_ref::( + &this.get_handlers().global_object + )) +} + +/// A read that the JS socket gets before the HTTP parser does. SAFETY: `data` is valid for `length` bytes. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Bun__NodeHTTPSocket__onData( + handle: *mut c_void, + ssl: bool, + data: *const u8, + length: usize, +) { + // SAFETY: the caller's contract. + let data = unsafe { bun_core::ffi::slice(data, length) }; + with_handle!(ssl, handle, |this| fold(NewSocket::on_data( + this, + this.socket.get(), + data + ))) +} + +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Bun__NodeHTTPSocket__onEnd(handle: *mut c_void, ssl: bool) { + with_handle!(ssl, handle, |this| fold(NewSocket::on_end( + this, + this.socket.get() + ))) +} + +/// Another response takes the connection from one that wrote `bytes` of body. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Bun__NodeHTTPSocket__addBytesWritten( + handle: *mut c_void, + ssl: bool, + bytes: u64, +) { + with_handle!(ssl, handle, |this| { + this.bytes_written.set(this.bytes_written.get() + bytes) + }) +} + +/// uWS closed the connection and is about to free the `us_socket_t`. JavaScript hears of it in `onClose`. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Bun__NodeHTTPSocket__forgetConnection( + handle: *mut c_void, + ssl: bool, +) { + with_handle!(ssl, handle, |this| { + this.bytes_read.set(this.total_bytes_read()); + this.bytes_written.set(this.total_bytes_written()); + this.socket.set(Transport::DETACHED) + }) +} + +/// The WebSocket that `become_web_socket` made of the connection closed. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Bun__NodeHTTPSocket__forgetWebSocket( + handle: *mut c_void, + ssl: bool, + sent_everything: bool, +) { + with_handle!(ssl, handle, |this| { + this.update_flags(|f| f.set(SocketFlags::CLOSED_WITH_ALL_DATA_SENT, sent_everything)); + this.socket.set(Transport::DETACHED) + }) +} + +/// The TLS handshake ended. The socket can be closed when this returns. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Bun__NodeHTTPSocket__onHandshake( + handle: *mut c_void, + success: c_int, + verify_error: uws::us_bun_verify_error_t, +) { + // SAFETY: the caller's contract, for a TLS connection. + let this = unsafe { ThisPtr::>::new(handle.cast()) }; + fold(NewSocket::on_handshake( + this, + this.socket.get(), + success, + verify_error, + )) +} + +/// Releases the connection's ref, so `handle` dangles when this returns. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Bun__NodeHTTPSocket__onClose( + handle: *mut c_void, + ssl: bool, + read_error: c_int, + run_script: bool, +) { + with_handle!(ssl, handle, |this| { + this.update_flags(|f| f.remove(SocketFlags::HELD_BY_SERVER)); + if !run_script { + this.update_flags(|f| f.insert(SocketFlags::FINALIZING)); + } + fold(NewSocket::on_close( + this, + Transport::DETACHED, + read_error, + None, + )) + }) +} + +/// The ext slot of `s` takes over the connection's ref. SAFETY: `s` is open, `HttpResponse::leaveHttp()` has run on it, and `unsent` is valid for `unsent_length` bytes. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Bun__NodeHTTPSocket__leaveHttp( + handle: *mut c_void, + ssl: bool, + s: *mut us_socket_t, + unsent: *const u8, + unsent_length: usize, + reads_paused: bool, + bytes_read: u64, + response_bytes_written: u64, +) { + // SAFETY: the caller's contract. + let unsent = unsafe { bun_core::ffi::slice(unsent, unsent_length) }; + with_handle!(ssl, handle, |this| NewSocket::leave_http( + this, + s, + unsent, + reads_paused, + bytes_read, + response_bytes_written + )) +} diff --git a/src/runtime/socket/pending_writes.rs b/src/runtime/socket/pending_writes.rs new file mode 100644 index 000000000000..bc0e8d7cb5ea --- /dev/null +++ b/src/runtime/socket/pending_writes.rs @@ -0,0 +1,113 @@ +use bun_io::StreamBuffer; +use bun_sys::SystemErrno; + +/// 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 { + bytes: StreamBuffer, + completion: Completion, +} + +/// How the queued bytes end, which their owner hears once. +#[derive(Default, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Completion { + #[default] + None, + /// `$write` answered "queued", so node:net waits for one `writeComplete` call. + Owed, + /// The bytes are lost. The error waits for the close, behind what the peer sent. + Failed(SystemErrno), + /// The TLS layer's send of records it held met the peer's reset ahead of the poll. The close reports what the poll's would: a failed read, and a canceled write. + Reset, +} + +impl PendingWrites { + /// The number of unsent bytes. + #[inline] + pub(crate) fn len(&self) -> usize { + self.bytes.size() + } + + /// The unsent bytes. + #[inline] + pub(crate) fn slice(&self) -> &[u8] { + self.bytes.slice() + } + + /// The size of the allocation, which includes the bytes already sent. + #[inline] + pub(crate) fn capacity(&self) -> usize { + self.bytes.memory_cost() + } + + #[inline] + pub(crate) fn append_slice(&mut self, bytes: &[u8]) { + bun_core::handle_oom(self.bytes.write(bytes)); + } + + /// 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.bytes = StreamBuffer::default(); + } else { + self.bytes.wrote(n); + } + } + + /// Frees the bytes. + #[inline] + pub(crate) fn clear(&mut self) { + self.bytes = StreamBuffer::default(); + } + + #[inline] + pub(crate) fn completion(&self) -> Completion { + self.completion + } + + /// The owner handed over bytes and hasn't heard how they ended. The TLS layer's own records (a handshake flight, an alert) aren't the owner's. + #[inline] + pub(crate) fn is_pending(&self) -> bool { + self.len() > 0 || self.completion == Completion::Owed + } + + /// `$write` answers "queued". + #[inline] + pub(crate) fn owe_completion(&mut self) { + if self.completion == Completion::None { + self.completion = Completion::Owed; + } + } + + #[inline] + pub(crate) fn is_lost(&self) -> bool { + matches!(self.completion, Completion::Failed(_) | Completion::Reset) + } + + /// Frees the bytes, which no send can deliver any more. The first failure is the one reported. + pub(crate) fn fail(&mut self, errno: SystemErrno) { + self.clear(); + if !self.is_lost() { + self.completion = Completion::Failed(errno); + } + } + + /// `fail()` for a send that the TLS layer made by itself, of records it held. + pub(crate) fn fail_held_records(&mut self, errno: SystemErrno) { + self.clear(); + if !self.is_lost() { + self.completion = match errno { + SystemErrno::ECONNRESET => Completion::Reset, + _ => Completion::Failed(errno), + }; + } + } + + /// Frees the bytes and hands the completion to the caller, who delivers it. + pub(crate) fn take(&mut self) -> Completion { + self.clear(); + core::mem::take(&mut self.completion) + } +} diff --git a/src/runtime/socket/socket_body.bind.ts b/src/runtime/socket/socket_body.bind.ts index ff7dea850d8c..9975a5d1ec62 100644 --- a/src/runtime/socket/socket_body.bind.ts +++ b/src/runtime/socket/socket_body.bind.ts @@ -1,8 +1,8 @@ import * as b from "../../codegen/bindgen-lib.ts"; import { socketOptions } from "../../codegen/builtins/host/socket-options.ts"; import * as table from "../../codegen/builtins/lib.ts"; +import { SecureContext } from "../api/bun/SecureContext.bind.ts"; import { ServerWebSocket } from "../server/server.bind.ts"; -import { SocketHandler } from "./SocketConfig.bind.ts"; export default b.module({ rustPath: "crate::socket::socket_body" }); @@ -12,14 +12,48 @@ const cached = { ...js, getter: textIfOpen, cache: true } as const; const write = { ...js, args: { data: b.any, byteOffset: b.any, byteLength: b.any, encoding: b.any }, - returns: b.any.returning("int32"), + returns: b.any.returning("number"), length: 3, } as const; -const end = { ...write, returns: b.any.returning("number") } as const; +const end = write; const buffered = { ...js, args: { data: b.any, encoding: b.any } } as const; const bufferIfAny = { returns: b.any.returning("Buffer", "undefined"), ...js } as const; const objectIfAny = { returns: b.any.returning("object", "undefined"), ...js } as const; +/** Members of the trait `TlsHandle`. */ +const tlsHandle = { + alpnProtocol: { ...js, getter: b.any.returning("string", "boolean") }, + disableRenegotiation: {}, + exportKeyingMaterial: { ...bufferIfAny, args: { length: b.any, label: b.any, context: b.any } }, + getCertificate: objectIfAny, + getCipher: objectIfAny, + getEphemeralKeyInfo: { returns: b.any.returning("object", "null"), ...js }, + getPeerCertificate: { + ...js, + args: { abbreviated: b.any }, + returns: b.any.returning("object", "undefined", "null"), + }, + getPeerX509Certificate: objectIfAny, + getServername: { returns: textIfOpen, ...js }, + getSession: bufferIfAny, + getSharedSigalgs: { returns: b.any.returning("Array", "null", "undefined"), ...js }, + getTLSFinishedMessage: bufferIfAny, + getTLSPeerFinishedMessage: bufferIfAny, + getTLSTicket: bufferIfAny, + getTLSVersion: { returns: b.any.returning("string", "null"), ...js }, + getX509Certificate: objectIfAny, + isSessionReused: { returns: b.bool }, + renegotiate: js, + resumeSNI: { args: { context: SecureContext.nullable, isError: b.bool.loose }, capability: "js" }, + setKeyCert: { ...js, args: { context: b.any } }, + setMaxSendFragment: { ...js, args: { size: b.any }, returns: b.any.returning("boolean") }, + setSession: { ...js, args: { session: b.any } }, +} as const; +const rustTraits = ["crate::socket::socket_body::TlsHandle"]; +/** The order `for-in` shows. */ +const byName = (members: T) => + Object.fromEntries(Object.entries(members).sort(([a], [b]) => (a < b ? -1 : 1))) as T; + /** What `Bun.connect()` resolves to and the `open` handler of `Bun.listen()` takes. */ function socket(ssl: SSL) { return b.class({ @@ -27,75 +61,54 @@ function socket(ssl: SSL) { refCounted: true, finalize: true, memoryCost: true, + rustTraits, /** `handlers` is shared with the listener and its other sockets. `duplex*` serve a `TLSSocket` over a `Duplex`. */ internalFields: [ "handlers", "data", - ...(ssl ? ["duplexOrigin", "duplexOnData", "duplexOnEnd", "duplexOnWritable", "duplexOnClose"] : []), + ...(ssl ? ["duplexOrigin", "duplexOnData", "duplexOnEnd", "duplexOnClose"] : []), ], proto: { - alpnProtocol: { ...js, getter: b.any.returning("string", "boolean") }, - authorized: { getter: b.bool, pure: true }, - bytesWritten: { getter: b.f64, pure: true }, - /** Can call the socket's handlers. */ - close: { capability: "js" }, - data: { setter: true, internalField: "data" }, - disableRenegotiation: {}, - end, - exportKeyingMaterial: { ...bufferIfAny, args: { length: b.any, label: b.any, context: b.any } }, - fd: { getter: b.any.returning("number"), capability: "heap" }, - flush: { capability: "js" }, - getAuthorizationError: { returns: b.any.returning("Error", "null"), capability: "js" }, - getCertificate: objectIfAny, - getCipher: objectIfAny, - getEphemeralKeyInfo: { returns: b.any.returning("object", "null"), ...js }, - getPeerCertificate: { - ...js, - args: { abbreviated: b.any }, - returns: b.any.returning("object", "undefined", "null"), - }, - ...(ssl ? { getPeerX509Certificate: objectIfAny } : {}), - getServername: { returns: textIfOpen, ...js }, - getSession: bufferIfAny, - getSharedSigalgs: { returns: b.any.returning("Array", "null", "undefined"), ...js }, - getTLSFinishedMessage: bufferIfAny, - getTLSPeerFinishedMessage: bufferIfAny, - getTLSTicket: bufferIfAny, - getTLSVersion: { returns: b.any.returning("string", "null"), ...js }, - getTypeOfService: { returns: b.i32 }, - ...(ssl ? { getX509Certificate: objectIfAny } : {}), - isSessionReused: { returns: b.bool }, - listener: { getter: b.any.returning("Listener", "undefined"), capability: "heap" }, - localAddress: cached, - localFamily: { getter: textIfOpen, cache: true, capability: "js" }, - localPort: { getter: b.u16.optional, cache: true }, - pause: { capability: "js" }, - readyState: { getter: b.i32 }, - ref: { rustName: "js_ref" }, - reload: { ...js, args: { options: b.any } }, - remoteAddress: cached, - remoteFamily: { getter: textIfOpen, cache: true, capability: "js" }, - remotePort: { getter: b.u16.optional }, - renegotiate: js, - resume: { capability: "js" }, - resumeSNI: { args: { context: b.any, isError: b.bool.loose }, capability: "js" }, - setKeepAlive: { ...js, args: { enable: b.bool.loose, initialDelay: b.any }, returns: b.bool }, - setKeyCert: { ...js, args: { context: b.any } }, - setMaxSendFragment: { ...js, args: { size: b.any }, returns: b.any.returning("boolean") }, - /** No argument enables it, `undefined` disables it. */ - setNoDelay: { args: { noDelay: b.any.variadic }, returns: b.bool, length: 1 }, - setServername: { ...js, args: { name: b.any } }, - setSession: { ...js, args: { session: b.any } }, - setTypeOfService: { ...js, args: { tos: b.any }, returns: b.i32 }, - setVerifyMode: { ...js, args: { requestCert: b.any, rejectUnauthorized: b.any } }, - shutdown: { args: { halfClose: b.bool.loose }, capability: "js" }, - terminate: { capability: "js" }, - timeout: { ...js, args: { seconds: b.any } }, - unref: { rustName: "js_unref" }, - upgradeTLS: { ...js, args: { options: b.any }, returns: b.any.returning("Array", "undefined") }, - write, + ...byName({ + ...tlsHandle, + authorized: { getter: b.bool, pure: true }, + bytesWritten: { getter: b.f64, pure: true }, + /** Can call the socket's handlers. */ + close: { capability: "js" }, + data: { setter: true, internalField: "data" }, + end, + fd: { getter: b.any.returning("number"), capability: "heap" }, + flush: { capability: "js" }, + getAuthorizationError: { returns: b.any.returning("Error", "null"), capability: "js" }, + getTypeOfService: { returns: b.i32 }, + listener: { getter: b.any.returning("Listener", "undefined"), capability: "heap" }, + localAddress: cached, + localFamily: { getter: textIfOpen, cache: true, capability: "js" }, + localPort: { getter: b.u16.optional, cache: true }, + pause: { capability: "js" }, + readyState: { getter: b.i32 }, + ref: { rustName: "js_ref" }, + reload: { ...js, args: { options: b.any } }, + remoteAddress: cached, + remoteFamily: { getter: textIfOpen, cache: true, capability: "js" }, + remotePort: { getter: b.u16.optional }, + resume: { capability: "js" }, + setKeepAlive: { ...js, args: { enable: b.bool.loose, initialDelay: b.any }, returns: b.bool }, + /** No argument enables it, `undefined` disables it. */ + setNoDelay: { args: { noDelay: b.any.variadic }, returns: b.bool, length: 1 }, + setServername: { ...js, args: { name: b.any } }, + setTypeOfService: { ...js, args: { tos: b.any }, returns: b.i32 }, + setVerifyMode: { ...js, args: { requestCert: b.any, rejectUnauthorized: b.any } }, + shutdown: { args: { halfClose: b.bool.loose }, capability: "js" }, + terminate: { capability: "js" }, + timeout: { ...js, args: { seconds: b.any } }, + unref: { rustName: "js_unref" }, + upgradeTLS: { ...js, args: { options: b.any }, returns: b.any.returning("Array", "undefined") }, + write, + /** Node's `handle.writeQueueSize`, for the `_handle` of a `net.Socket`. */ + writeQueueSize: { getter: b.f64, enumerable: false }, + }), "@@dispose": { ...end, rustName: "end", length: 0 }, - endBuffered: { ...buffered, returns: b.any.returning("boolean"), privateSymbol: "end" }, writeBuffered: { ...buffered, returns: b.any.returning("boolean", "number"), privateSymbol: "write" }, }, }); @@ -108,6 +121,7 @@ export const BunSocket = b.union({ tcp: TCPSocket, tls: TLSSocket }); /** The `_handle` of a `net.Socket`: `new net.Socket({ handle })` keeps any object. */ export const SocketHandle = b.union({ tcp: TCPSocket, tls: TLSSocket, ignored: b.any }); +const upgradeOptions = [...socketOptions, table.runs(table.named("isServer", "initialData"))]; const newListOf = (element: table.TableType) => table.instance("Array").holding({ element }).fresh; /** `upgradeTLS()` for node:tls: `upgrade_tls_impl`. Returns the raw socket and the `TLSSocket`, or undefined for a socket that is detached. */ export const upgradeTLSDeferred = b.fn({ @@ -119,36 +133,22 @@ export const upgradeTLSDeferred = b.fn({ throws: true, uses: { socket: table.retainedByNativeCode, - options: [...socketOptions, table.runs(table.named("isServer", "initialData"))], + options: upgradeOptions, }, }); -export const releaseHeldFlight = b.fn({ args: { socket: SocketHandle } }); -export const checkServerIdentityInHandshake = b.fn({ args: { socket: SocketHandle, inHandshake: b.bool } }); -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 }); -/** Node's `handle.hasPendingRead()`: data, a FIN or an error that the loop has not polled for yet. */ -export const hasPendingRead = b.fn({ args: { socket: SocketHandle }, returns: b.bool }); -/** `tls` is `true` or what `SSLConfig` takes, and can hold the `secureContext`. */ -export const DuplexTLSOptions = b.dictionary({ - socket: SocketHandler, - isServer: b.bool.loose.default(false), - secureContext: b.any.emptyStringAsNull, - tls: b.any.emptyStringAsNull, - data: b.any, -}); /** - * Returns the `TLSSocket` and the listeners for the duplex's `data`, `end`, `drain` and `close`. - * The options are those of a connection. UpgradedDuplex.rs keeps the duplex, calls its `pause`, `resume`, `write` and `end` + * Returns the `TLSSocket` and the listeners for the duplex's `data`, `end` and `close`. + * The options are those of `upgradeTLSDeferred`. UpgradedDuplex.rs keeps the duplex, calls its `pause`, `resume`, `write` and `end` * and reads `_readableState.ended`, `writableEnded` and `destroyed`. */ export const upgradeDuplexToTLS = b.fn({ - ...js, - args: { duplex: b.any, options: DuplexTLSOptions }, + args: { duplex: b.any, options: b.any }, returns: b.any.returning( newListOf(table.union(table.instance("TLSSocket"), table.instance("Array").holding({ element: table.callable }))) .spec, ), + capability: "js", + throws: true, uses: { duplex: [ ...table.retainedByNativeCode, @@ -166,9 +166,16 @@ export const upgradeDuplexToTLS = b.fn({ table.runsTransitively(table.named("_readableState", "ended", "writableEnded", "destroyed")), table.membersOnlyInspected, ], - options: socketOptions, + options: upgradeOptions, }, }); +export const releaseHeldFlight = b.fn({ args: { socket: SocketHandle } }); +export const checkServerIdentityInHandshake = b.fn({ args: { socket: SocketHandle, inHandshake: b.bool } }); +export const firstFlightBeforeFin = b.fn({ args: { socket: SocketHandle } }); +export const isNamedPipeSocket = b.fn({ args: { socket: SocketHandle }, returns: b.bool }); +/** Node's `handle.hasPendingRead()`: data, a FIN or an error that the loop has not polled for yet. */ +export const hasPendingRead = b.fn({ args: { socket: SocketHandle }, returns: b.bool }); +export const getBytesRead = b.fn({ args: { socket: SocketHandle }, returns: b.f64 }); // `bun:internal-for-testing` export const createSocketPair = b.fn({ ...js, returns: b.array(b.i32) }); @@ -180,13 +187,12 @@ export const socketFaultInjectionAvailable = b.fn({ returns: b.bool, rustPath: testing("socket_fault_injection_available"), }); -export const namedPipeContextLiveCount = b.fn({ - returns: b.usize, - rustPath: testing("named_pipe_context_live_count"), -}); -export const duplexUpgradeContextLiveCount = b.fn({ - returns: b.usize, - rustPath: testing("duplex_upgrade_context_live_count"), +export const namedPipeLiveCount = b.fn({ returns: b.usize, rustPath: testing("named_pipe_live_count") }); +export const upgradedDuplexLiveCount = b.fn({ returns: b.usize, rustPath: testing("upgraded_duplex_live_count") }); +export const sweepSocketTimers = b.fn({ + ...js, + args: { count: b.u16.validateInteger() }, + rustPath: testing("sweep_socket_timers"), }); export const clearSocketFaults = b.fn({ ...js, rustPath: testing("clear_socket_faults") }); /** `socket`, `close` and `shutdown` have no hook in bsd.c: a rule for one would never fire. */ @@ -219,6 +225,8 @@ export const SocketFaultErrnoName = b.enumeration([ "ENETUNREACH", "EHOSTUNREACH", "EPROTOTYPE", + "EMFILE", + "EPERM", ]); export const SocketFaultErrno = b.union({ name: SocketFaultErrnoName, code: b.i32 }); const count = b.i32.wrap.emptyStringAsNull; diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 9017eeb99c92..c221491d4dfb 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -5,7 +5,7 @@ use core::ffi::{c_int, c_uint, c_void}; use core::ptr::{self, NonNull}; use crate::generated_bindgen::socket_body::{ - AnySocket, BunSocket, DuplexTLSOptions, SocketHandle, js_TCPSocket, js_TLSSocket, + AnySocket, BunSocket, SocketHandle, js_TCPSocket, js_TLSSocket, }; use bun_core::EncodedSlice; use bun_io::KeepAlive; @@ -18,16 +18,16 @@ 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, JSGlobalObject, JSValue, JsRef, JsResult, SystemError}; // `bun_jsc::VirtualMachine` is the *module* (alias of `virtual_machine`); name the // struct directly so `VirtualMachine::get()` resolves as an associated fn. -use super::upgraded_duplex::{Handlers as UpgradedDuplexHandlers, UpgradedDuplex}; +use super::upgraded_duplex::{ServerVerify, UpgradedDuplex}; use crate::crypto::boringssl_jsc::err_to_js as boringssl_err_to_js; use crate::node::{BlobOrStringOrBuffer, StringObjects, StringOrBuffer}; -use crate::socket::{SSLConfig, SSLConfigFromJs}; +use crate::socket::SSLConfig; +use crate::socket::ssl_config::ParsedTls; use bun_boringssl_sys as boringssl_sys; use bun_cares_sys::c_ares_draft as c_ares; use bun_core::String as BunString; @@ -35,19 +35,6 @@ use bun_jsc::virtual_machine::VirtualMachine; use bun_sys as sys; use bun_uws as uws; -// `uws::NewSocketHandler::from_duplex` is now inherent on the canonical -// `bun_uws_sys::socket` impl; thin local wrapper that erases the concrete -// `runtime::socket::UpgradedDuplex` to the opaque `bun_uws_sys::UpgradedDuplex` -// handle (same allocation, different-crate newtype — see uws_sys/lib.rs §shim). -#[inline] -fn from_duplex(duplex: &UpgradedDuplex) -> uws::NewSocketHandler { - uws::NewSocketHandler::::from_duplex( - std::ptr::from_ref::(duplex) - .cast_mut() - .cast(), - ) -} - /// Shorthand for the JS-side `EventLoopCtx` (replaces direct VM passing to /// `KeepAlive::ref_/unref` — `bun_io` no longer accepts `&VirtualMachine`). #[inline] @@ -55,33 +42,64 @@ fn js_loop_ctx() -> bun_io::EventLoopCtx { bun_io::posix_event_loop::get_vm_ctx(bun_io::posix_event_loop::AllocatorType::Js) } -/// The error behind a close that the event loop initiated: the `recv()` -/// failure or `SO_ERROR` that uSockets closed the socket with (`> 2`, see -/// `on_close`). uSockets reports it in the platform's own numbering: an errno -/// on POSIX, a WSA code (`WSAECONNRESET` = 10054) on Windows. `sys::Error` -/// stores `SystemErrno` discriminants, so the WSA code has to be mapped first -/// or the error reaches JS with no `code` at all. +/// The `recv()` failure or `SO_ERROR` that uSockets closed the socket with (`> 2`, see `on_close`). fn read_error_from_close_code(code: c_int) -> sys::Error { - #[cfg(not(windows))] - { - sys::Error::from_code_int(code, sys::Tag::read) + let errno = uws::SocketGroup::errno(code); + // libuv reports WSAECONNABORTED as ECONNRESET on the read path (uv__process_tcp_read_req). + #[cfg(windows)] + let errno = errno.filter(|errno| *errno != sys::SystemErrno::ECONNABORTED); + sys::Error::new( + errno.unwrap_or(sys::SystemErrno::ECONNRESET), + sys::Tag::read, + ) +} + +/// "IPv4" or "IPv6" for the 4 or 16 address bytes uSockets reports, `undefined` for a socket with no such address. +fn ip_family_to_js(global: &JSGlobalObject, address: Option<&[u8]>) -> JSValue { + match address.map_or(0, <[u8]>::len) { + 4 => global.common_strings().ipv4(), + 16 => global.common_strings().ipv6(), + _ => JSValue::UNDEFINED, } +} + +/// The text of the same bytes. +pub(super) fn ip_address_to_js( + global: &JSGlobalObject, + address: Option<&[u8]>, +) -> JsResult { + let address = address.unwrap_or_default(); + let ip: std::net::IpAddr = if let Ok(v4) = <[u8; 4]>::try_from(address) { + v4.into() + } else if let Ok(v6) = <[u8; 16]>::try_from(address) { + v6.into() + } else { + return Ok(JSValue::UNDEFINED); + }; + let mut text_buf = [0u8; 512]; + // `format_ip` strips the port and the brackets of `addr:port` / `[addr]:port`, so a bare `IpAddr` would corrupt IPv6. + let text = + bun_fmt::format_ip(&std::net::SocketAddr::new(ip, 0), &mut text_buf).expect("unreachable"); + bun_string_jsc::create_utf8_for_js(global, text) +} + +/// `errno` as the status libuv hands a write request's callback. +pub(super) fn uv_status(errno: sys::SystemErrno) -> c_int { #[cfg(windows)] { - // Winsock reports a reset as WSAECONNRESET or, once the local stack - // has torn the connection down, WSAECONNABORTED. libuv reports both - // as ECONNRESET on the read path (uv__process_tcp_read_req), so Node - // sees one code on every platform; same here. A code the table does - // not name still closed the connection, so it is reported the same way - // rather than as a code-less error. - let errno = match sys::SystemErrno::init(code.unsigned_abs()) { - Some(errno) if errno != sys::SystemErrno::ECONNABORTED => errno, - _ => sys::SystemErrno::ECONNRESET, - }; - sys::Error::new(errno, sys::Tag::read) + sys::windows::libuv::e_discriminant_to_uv(errno as u16).unwrap_or(-(errno as c_int)) + } + #[cfg(not(windows))] + { + -(errno as c_int) } } +/// Why a send failed, from uSockets' numbering. +fn send_errno(send_error: c_int) -> sys::SystemErrno { + uws::SocketGroup::errno(send_error).unwrap_or(sys::SystemErrno::ECONNRESET) +} + /// `read_error_from_close_code` for C++: the `closeError` getter of `JSNodeHTTPServerSocket`. #[unsafe(no_mangle)] pub(crate) extern "C" fn Bun__socketReadErrorFromCloseCode( @@ -96,12 +114,15 @@ pub(crate) extern "C" fn Bun__socketReadErrorFromCloseCode( // ────────────────────────────────────────────────────────────────────────── pub(super) use super::handlers::Handlers; +use super::pending_writes::{Completion, PendingWrites}; use std::rc::Rc; mod tls_socket_functions; +use crate::api::SSLContextCache::SSLContextCache; use crate::api::bun::h2_frame_parser::H2FrameParser; use crate::api::bun_secure_context::SecureContext; use crate::socket::udp_socket::get_us_error; +pub(crate) use tls_socket_functions::TlsHandle; bun_output::declare_scope!(Socket, visible); macro_rules! log { @@ -112,35 +133,39 @@ macro_rules! log { // ALPN select callback // ────────────────────────────────────────────────────────────────────────── -/// `SSL_CTX_set_alpn_select_cb` registers on the listener-level `SSL_CTX`, so -/// 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 { +/// The socket `on_open` put on the `SSL`. An `SSL_CTX` is shared, so its ALPN callback has no argument of its own. +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); + tls_socket_functions::ffi::us_ssl_bun_socket(boringssl_sys::SSL::opaque_ref(ssl)); 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 + // SAFETY: a `*mut TLSSocket` (set in on_open, taken back in on_close), kept // live for this handshake callback by the JS wrapper's ref. let this = unsafe { bun_ptr::ThisPtr::new(this_ptr.cast::()) }; // 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 @@ -156,8 +181,7 @@ extern "C" fn select_alpn_callback( // can run JS touching another TLS socket (the callback, its `error` // handler, the selection's `toString`, the scope's checkpoint), and // restore it on every path back to BoringSSL — after the scope guard - // below has exited, since this guard is declared first. Connected - // usockets only: the BIO_get_data of an UpgradedDuplex/Pipe is not loop_ssl_data. + // below has exited, since this guard is declared first. const LOOP_STATE_SLOTS: usize = 6; // US_SSL_LOOP_STATE_SLOTS debug_assert_eq!( LOOP_STATE_SLOTS as core::ffi::c_int, @@ -166,13 +190,10 @@ extern "C" fn select_alpn_callback( ); let mut saved_loop_state: [*mut c_void; LOOP_STATE_SLOTS] = [core::ptr::null_mut(); LOOP_STATE_SLOTS]; - if matches!(this.socket.get().socket, uws::InternalSocket::Connected(_)) { - tls_socket_functions::ffi::us_internal_ssl_loop_state_save( - boringssl_sys::SSL::opaque_ref(ssl), - saved_loop_state.as_mut_ptr(), - ); - } - // (A no-op for the all-null snapshot of a non-Connected socket.) + tls_socket_functions::ffi::us_internal_ssl_loop_state_save( + boringssl_sys::SSL::opaque_ref(ssl), + saved_loop_state.as_mut_ptr(), + ); let _restore_loop_state = scopeguard::guard(saved_loop_state, |mut saved| { tls_socket_functions::ffi::us_internal_ssl_loop_state_restore(saved.as_mut_ptr()); }); @@ -185,13 +206,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) { @@ -199,8 +220,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() { @@ -219,7 +240,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 @@ -235,23 +256,41 @@ 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 + // `us_dispatch_alpn_select` 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 +} + +/// BoringSSL's ALPN select callback for a server whose listen socket has no protocol list of its own. +#[unsafe(no_mangle)] +pub(crate) extern "C" fn us_dispatch_alpn_select( + ssl: *mut bun_boringssl_sys::SSL, + out: *mut *const u8, + outlen: *mut u8, + in_: *const u8, + inlen: c_uint, +) -> 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; @@ -282,19 +321,6 @@ extern "C" fn select_alpn_callback( } } -/// BoringSSL reads the server ALPN callback off the *current* `ssl->ctx`, and -/// SNI replaces that with `SSL_set_SSL_CTX` before ALPN negotiation. -pub(in crate::socket) fn install_sni_alpn_selector(ctx: *mut boringssl_sys::SSL_CTX) { - if ctx.is_null() { - return; - } - tls_socket_functions::ffi::SSL_CTX_set_alpn_select_cb( - SSL_CTX::opaque_ref(ctx), - Some(select_alpn_callback), - ptr::null_mut(), - ); -} - // ────────────────────────────────────────────────────────────────────────── // NewSocket // ────────────────────────────────────────────────────────────────────────── @@ -305,10 +331,8 @@ pub(in crate::socket) fn install_sni_alpn_selector(ctx: *mut boringssl_sys::SSL_ #[derive(bun_ptr::RefCounted)] pub(crate) struct NewSocket { pub(crate) socket: Cell>, - /// `SSL_CTX` this client connection was opened with. Server-accepted - /// sockets and plain TCP leave this `None` (the Listener / SecureContext - /// owns the ref there). - pub(crate) owned_ssl_ctx: JsCell>, + /// A TLS socket whose close still waits for the peer. `socket` is detached by then, so `terminate()` closes this one. + pub(crate) closing: Cell>, pub(crate) flags: Cell, pub(crate) ref_count: bun_ptr::RefCount, @@ -328,14 +352,18 @@ pub(crate) struct NewSocket { pub this_value: JsCell, pub poll_ref: JsCell, pub(crate) ref_pollref_on_connect: Cell, + /// What `timeout(seconds)` last asked for, or the 120 s of an accepted socket. uSockets keeps only when it's due. + pub(crate) timeout_seconds: Cell, pub(crate) connection: JsCell>, /// `localAddress`/`localPort` from the connect options: the socket is /// bound to this address before connecting. Always a literal IP. 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 @@ -348,8 +376,6 @@ pub(crate) struct NewSocket { /// keeps its verdict after detach (the live error borrows the `SSL`, and /// EPROTO reasons are stack-copied in uSockets). pub(crate) verify_error: JsCell>, - /// Owns one reference to the session the new-session callback gave last: TLS 1.3 never stores it on the `SSL`. - pub(crate) latest_session: Cell>>, } /// Associated `Socket` handler type. @@ -357,7 +383,6 @@ pub(super) type SocketHandler = uws::NewSocketHandler; impl Drop for NewSocket { fn drop(&mut self) { - self.set_latest_session(ptr::null_mut()); self.mark_inactive(); self.detach_native_callback(); self.poll_ref.with_mut(|p| { @@ -546,9 +571,30 @@ impl NewSocket { } } - /// Heap-allocates the socket; ownership passes to the intrusive refcount. + /// Heap-allocates a detached socket; ownership passes to the intrusive refcount. /// The returned handle is live by construction. - pub(crate) fn new(init: Self) -> bun_ptr::ThisPtr { + pub(crate) fn new(handlers: Option>) -> bun_ptr::ThisPtr { + let init = Self { + socket: Cell::new(SocketHandler::::DETACHED), + closing: Cell::new(SocketHandler::::DETACHED), + flags: Cell::new(Flags::empty()), + ref_count: bun_ptr::RefCount::init(), + handlers: JsCell::new(handlers), + this_value: JsCell::new(JsRef::empty()), + poll_ref: JsCell::new(KeepAlive::init()), + ref_pollref_on_connect: Cell::new(true), + timeout_seconds: Cell::new(0), + connection: JsCell::new(None), + local_binding: JsCell::new(None), + protos: 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(NativeCallbacks::None), + twin: JsCell::new(None), + verify_error: JsCell::new(None), + }; // SAFETY: freshly allocated, non-null. unsafe { bun_ptr::ThisPtr::new(bun_core::heap::into_raw(Box::new(init))) } } @@ -581,17 +627,36 @@ impl NewSocket { } } - /// Connect to `self.connection` (must be `Some`). Reads the field directly - /// rather than taking it by-ref so the single caller in `connect_finish` - /// doesn't need a disjoint borrow. + /// Connects to `self.connection`. `Err` is why that failed, and the inner result is what an `on_open` that ran in here left pending. What outlives the call (a connecting socket, an `SSL`, a pipe's TLS engine) takes its own reference on `ssl_ctx`. pub(crate) fn do_connect( &self, - context: &bun_jsc::ScriptExecutionContext, - ) -> crate::Result<()> { + cx: &bun_jsc::JsThread<'_>, + ssl_ctx: Option, + ) -> Result, sys::SystemErrno> { // Keep `self` alive across the re-entrant connect path. // SAFETY: `self` is live for this call and outlives the sockets below. let this = unsafe { bun_ptr::ThisPtr::new(self.as_ctx_ptr()) }; let _guard = RefPtr::from_this(this); + let connection = self.connection.get().as_ref(); + let connection = connection.expect("do_connect requires self.connection to be set"); + + #[cfg(windows)] + if !self.flags.get().contains(Flags::WRITE_ONLY_FD) { + use super::listener::NamedPipe; + use super::windows_named_pipe::{SocketType, WindowsNamedPipe}; + let mut buf = bun_paths::path_buffer_pool::get(); + let socket = SocketType::of(this); + match super::listener::named_pipe(connection, buf.as_mut_slice()) { + Some(NamedPipe::Path(name)) => { + return WindowsNamedPipe::from_path(cx, name, ssl_ctx, socket).map(Ok); + } + Some(NamedPipe::Fd(fd)) => { + return WindowsNamedPipe::from_fd(cx, fd, ssl_ctx, socket).map(Ok); + } + None => {} + } + } + let last_errno = || sys::SystemErrno::from_raw(sys::last_error() as u16); // `VirtualMachine::get()` yields the canonical `*mut` (write // provenance) — never derive `&mut` from the `&'static` borrow stored @@ -599,30 +664,23 @@ impl NewSocket { let vm = VirtualMachine::get().as_mut(); let loop_ = vm.uws_loop(); let group = vm - .client_socket_groups_in(context) + .client_socket_groups_in(cx.context()) .bun_connect_group::(loop_); let kind: uws::SocketKind = if SSL { uws::SocketKind::BunSocketTls } else { uws::SocketKind::BunSocketTcp }; - let flags: i32 = if self.flags.get().contains(Flags::ALLOW_HALF_OPEN) { - uws::LIBUS_SOCKET_ALLOW_HALF_OPEN - } else { - 0 - }; - let ssl_ctx: Option<*mut uws::SslCtx> = if SSL { - self.owned_ssl_ctx - .get() - .as_ref() - .map(|p| p.as_ptr().cast::()) - } else { - None - }; + // 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 owned_ssl_ctx = ssl_ctx.filter(|_| SSL); + let ssl_ctx: Option<*mut uws::SslCtx> = owned_ssl_ctx + .as_ref() + .map(|p| p.as_ptr().cast::()); use super::listener::UnixOrHost; - match self.connection.get() { - Some(UnixOrHost::Host { host, port }) => { + match connection { + UnixOrHost::Host { host, port } => { // getaddrinfo doesn't accept bracketed IPv6. let hostz = bun_core::ZBox::from_bytes(bun_core::ip_address::strip_ipv6_brackets(host)); @@ -646,9 +704,7 @@ impl NewSocket { flags, core::mem::size_of::<*mut c_void>() as c_int, ) { - uws::ConnectResult::Failed => { - return Err(crate::Error::FailedToOpenSocket); - } + uws::ConnectResult::Failed => return Err(last_errno()), uws::ConnectResult::Socket(s) => { *uws::us_socket_t::opaque_mut(s).ext() = Some(this); SocketHandler::::from(s) @@ -660,7 +716,7 @@ impl NewSocket { }, ); } - Some(UnixOrHost::Unix(u)) => { + UnixOrHost::Unix(u) => { let s = group.connect_unix( kind, ssl_ctx, @@ -669,12 +725,17 @@ impl NewSocket { core::mem::size_of::<*mut c_void>() as c_int, ); if s.is_null() { - return Err(crate::Error::FailedToOpenSocket); + return Err(last_errno()); } *uws::us_socket_t::opaque_mut(s).ext() = Some(this); self.socket.set(SocketHandler::::from(s)); } - Some(UnixOrHost::Fd(f)) => { + UnixOrHost::Fd(f) if !SSL && self.flags.get().contains(Flags::WRITE_ONLY_FD) => { + // SAFETY: `!SSL`, so `Self` is `TCPSocket`. + let tcp = unsafe { bun_ptr::ThisPtr::new(self.as_ctx_ptr().cast::()) }; + return super::fd_pipe::FdPipe::open(tcp, *f, cx.context()); + } + UnixOrHost::Fd(f) => { // The fd is connected, so it is registered paused outright; a dial is paused in on_open. let flags = if self.flags.get().contains(Flags::PAUSE_ON_CONNECT) { flags | uws::LIBUS_SOCKET_OPEN_PAUSED @@ -693,16 +754,15 @@ impl NewSocket { false, ); if s.is_null() { - return Err(crate::Error::ConnectionFailed); + return Err(last_errno()); } *uws::us_socket_t::opaque_mut(s).ext() = Some(this); let sock = SocketHandler::::from(s); self.socket.set(sock); - Self::on_open(this, sock)?; + return Ok(Self::on_open(this, sock)); } - None => unreachable!("do_connect requires self.connection to be set"), } - Ok(()) + Ok(Ok(())) } pub(crate) fn resume(&self, _: &mut JsScope<'_>) { @@ -737,6 +797,7 @@ impl NewSocket { if !self.flags.get().contains(Flags::IS_PAUSED) { let paused = self.socket.get().pause_stream(); self.update_flags(|f| f.set(Flags::IS_PAUSED, paused)); + self.fail_write_of_stopped_reader(); } } @@ -779,7 +840,14 @@ impl NewSocket { .is_none_or(|enabled| enabled.to_js().to_boolean()); log!("setNoDelay({})", enabled); - self.socket.get().set_no_delay(enabled) + if enabled && self.flags.get().contains(Flags::NO_DELAY) && !self.socket.get().is_closed() { + return true; + } + let set = self.socket.get().set_no_delay(enabled); + if set { + self.update_flags(|f| f.set(Flags::NO_DELAY, enabled)); + } + set } /// `_handle.setTypeOfService(tos)` - returns 0 on success or a negative @@ -817,34 +885,6 @@ impl NewSocket { self.socket.get().get_tos() } - /// `handle.resumeSNI(secureContextOrNull, isError)` - resumes a server - /// handshake suspended by an asynchronous SNICallback. A no-op when the - /// socket already closed (the resolution outlived the connection). - pub(crate) fn resume_sni(&self, _: &mut JsScope<'_>, context: JsLocal<'_>, is_error: bool) { - log!("resumeSNI"); - let socket = self.socket.get(); - if socket.is_detached() { - return; - } - // The selected context: a native SecureContext (a +1 reference that - // us_socket_sni_resolve consumes) or null to fall through to the - // listener's default context. - let ctx_ptr = if !is_error { - if let Some(sc) = context - .to_js() - .as_class_ref::() - { - sc.ctx.clone().into_raw() - } else { - core::ptr::null_mut() - } - } else { - core::ptr::null_mut() - }; - install_sni_alpn_selector(ctx_ptr); - socket.sni_resolve(ctx_ptr, is_error); - } - pub(crate) fn handle_error(&self, err_value: JSValue) -> JsResult<()> { log!("handleError"); let handlers = self.get_handlers(); @@ -858,6 +898,57 @@ impl NewSocket { called } + /// Completes a write from the event loop, like libuv, not inside the call that closed the handle or stopped its reads. + fn complete_write_later(&self, handlers: &Rc, status: Option) { + if self.flags.get().contains(Flags::FINALIZING) { + return; + } + let global = handlers.global_object; + let this_value = self.get_this_value(&global); + if this_value.is_undefined() { + return; + } + let task = Box::new(DeferredWriteCompletion:: { + socket: self.ref_guard(), + this_value: jsc::Strong::create(this_value, &global), + handlers: Rc::clone(handlers), + status, + }); + handlers + .vm + .event_loop_mut() + .enqueue_task(jsc::Task::init(bun_core::heap::into_raw(task))); + } + + /// A send failed for good. The transport reads on and closes by itself, and the write ends with that close. + fn fail_queued_writes( + &self, + send_error: c_int, + record: fn(&mut PendingWrites, sys::SystemErrno), + ) { + // The TLS layer has done this itself if a send is what failed. + if !SSL { + self.socket.get().reject_send(send_error); + } + self.buffered_data_for_node_net + .with_mut(|b| record(b, send_errno(send_error))); + self.fail_write_of_stopped_reader(); + } + + /// The transport stays open for a reader. A socket that doesn't read closes a turn later. + fn fail_write_of_stopped_reader(&self) { + if self.lost_write_of_stopped_reader() + && let Some(handlers) = self.handlers.get() + { + self.complete_write_later(handlers, None); + } + } + + fn lost_write_of_stopped_reader(&self) -> bool { + self.flags.get().contains(Flags::IS_PAUSED) + && self.buffered_data_for_node_net.get().is_lost() + } + /// 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 @@ -870,73 +961,49 @@ impl NewSocket { _socket: SocketHandler, ) -> JsResult<()> { jsc::mark_binding!(); - // A late event on a socket that already released its Handlers through - // a path that did not route back through this dispatch - e.g. a - // JS-side destroy on a TLS socket driven by an upgraded duplex. There - // is nothing to dispatch to. - if !this.has_handlers() { + let Some(handlers) = this.dispatch_handlers() else { return Ok(()); - } - if this.socket.get().is_detached() { + }; + if this.native_callback.get().on_writable() { return Ok(()); } - if this.native_callback.get().on_writable() { + // The TLS layer couldn't send records it still held. + let send_error = this.socket.get().rejected_send_error(); + if send_error != 0 { + if this.buffered_data_for_node_net.get().is_pending() { + this.fail_queued_writes(send_error, PendingWrites::fail_held_records); + } return Ok(()); } - let handlers = this.get_handlers(); let callback = handlers.on_writable(); - if callback.is_empty() { + let queue = this.buffered_data_for_node_net.get(); + if callback.is_empty() + && queue.len() == 0 + && queue.completion() == Completion::None + && !this.flags.get().contains(Flags::END_AFTER_FLUSH) + { return Ok(()); } // Hold the socket alive for the rest of the dispatch: `internal_flush` // and the drain callback can both re-enter JS and close it. let _guard = RefPtr::from_this(this); - // NOTE (Windows): the drain dispatch deliberately does not depend on - // whether the flush hit a fatal send error. Skipping it on fatal - // (tried in f0325bddf2) made Windows servers reset FIN-terminated - // responses: write_check_error's fatal detection interacts with - // Windows would-block semantics, and a skipped drain stalls the - // response teardown into an RST. Until that detection is verified on - // Windows, keep the legacy contract there (the close path still fails - // the pending write callback when the socket is torn down). - let fatal_send_errno = this.internal_flush(); - // On POSIX the fatal signal is trustworthy: us_socket_write_check_error - // only reports an errno that is either known peer-gone or persisted - // across its bounded unclassified-errno retry window. internal_flush - // already dropped the undeliverable buffer and the writable poll is no - // longer re-armed, so this dispatch is the last place the errno is - // visible - swallowing it here acknowledged the bytes to JS, sent a - // clean FIN, and the peer saw a silently truncated stream. Deliver it - // like a failed write (syscall "write", same shape as net.ts - // 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); + if !this.internal_flush() || this.socket.get().is_detached() { + return Ok(()); + } + let queue = this.buffered_data_for_node_net.get(); + if queue.len() > 0 { + return Ok(()); + } + let global = handlers.global_object; + if queue.completion() == Completion::Owed { + if !this.holds_unsent_tls_records() { + this.buffered_data_for_node_net.with_mut(|b| b.take()); + handlers.call_write_complete(this.get_this_value(&global), 0); } return Ok(()); } - 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() { return Ok(()); } @@ -947,13 +1014,8 @@ impl NewSocket { scope: Some(handlers.enter()), }; - let global = handlers.global_object; let this_value = this.get_this_value(&global); - // The TLS engine ended the writes only: it reads what the peer sent, then closes. - // node:net gets what `$write` returns for such a send, and holds the write until that close. - let args = [this_value, JSValue::js_number(f64::from(-fatal_send_errno))]; - let argc = if SSL && fatal_send_errno != 0 { 2 } else { 1 }; - if let Err(err) = callback.call(&global, this_value, &args[..argc]) { + if let Err(err) = callback.call(&global, this_value, &[this_value]) { handlers.call_error_handler(this_value, &[this_value, global.take_error(err)])?; } Ok(()) @@ -965,17 +1027,9 @@ impl NewSocket { _socket: SocketHandler, ) -> JsResult<()> { jsc::mark_binding!(); - // A late event on a socket that already released its Handlers through - // a path that did not route back through this dispatch - e.g. a - // JS-side destroy on a TLS socket driven by an upgraded duplex. There - // is nothing to dispatch to. - if !this.has_handlers() { - return Ok(()); - } - if this.socket.get().is_detached() { + let Some(handlers) = this.dispatch_handlers() else { return Ok(()); - } - let handlers = this.get_handlers(); + }; log!( "onTimeout {}", if handlers.mode == super::SocketMode::Server { @@ -985,10 +1039,10 @@ impl NewSocket { } ); let callback = handlers.on_timeout(); - if callback.is_empty() || this.flags.get().contains(Flags::FINALIZING) { + if this.flags.get().contains(Flags::FINALIZING) { return Ok(()); } - + let _guard = RefPtr::from_this(this); // 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 { @@ -996,12 +1050,67 @@ impl NewSocket { 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)])?; + // The peer took nothing of `end(data)`'s tail for the whole timeout. + let tail_timed_out = this.waits_to_send_tail(); + let mut handled = Ok(()); + if !callback.is_empty() { + let global = handlers.global_object; + let this_value = this.get_this_value(&global); + if let Err(err) = callback.call(&global, this_value, &[this_value]) { + handled = + handlers.call_error_handler(this_value, &[this_value, global.take_error(err)]); + } } - Ok(()) + if this.waits_to_send_tail() { + // `end(data)` in the handler is how a program drops a peer that stopped reading, so its tail doesn't wait another timeout. + if tail_timed_out { + this.buffered_data_for_node_net + .with_mut(|b| b.fail(sys::SystemErrno::ETIMEDOUT)); + } + this.mark_inactive(); + } + handled + } + + fn waits_to_send_tail(&self) -> bool { + self.flags.get().contains(Flags::END_AFTER_FLUSH) + && self.buffered_data_for_node_net.get().len() > 0 + && !self.socket.get().is_detached() + } + + /// The raw half of an `upgradeTLS` pair asks for its TLS half, which the events of the socket they share go to. + fn tls_half_waits_to_send_tail(&self) -> bool { + if !self.flags.get().contains(Flags::BYPASS_TLS) { + return false; + } + let uws::InternalSocket::Connected(socket) = self.socket.get().socket else { + return false; + }; + let tls_half: Option> = *uws::us_socket_t::opaque_mut(socket).ext(); + tls_half.is_some_and(|tls_half| tls_half.waits_to_send_tail()) + } + + /// Closes an ended socket once its tail is sent. Until then the socket's own timeout runs, from each send that moved bytes. + fn close_if_flushed(&self) { + if self.can_end_after_flush() { + self.mark_inactive(); + } else { + self.arm_tail_timeout(); + } + } + + fn arm_tail_timeout(&self) { + if self.timeout_seconds.get() > 0 && self.waits_to_send_tail() { + self.socket.get().set_timeout(self.timeout_seconds.get()); + } + } + + /// The callbacks an event goes to. `None` for a late event: the socket is detached, or a JS-side destroy released them (TLS over a Duplex). + fn dispatch_handlers(&self) -> Option> { + if self.socket.get().is_detached() { + return None; + } + self.handlers_opt() } /// This socket's callbacks. Panics if it has none — every dispatch entry @@ -1054,27 +1163,24 @@ impl NewSocket { /// this socket via `m_ptr` (node:net `autoSelectFamily` retries inside the /// `connectError` callback). /// - /// `dns_error` is the raw `getaddrinfo(3)` return code when the name - /// lookup itself failed; 0 for a connect failure past name resolution - /// (then `errno` carries the connect error). /// `Err` is what the `connectError`/`error` handler or a promise /// rejection left pending; the socket is released either way (guards). pub(crate) fn handle_connect_error( this: bun_ptr::ThisPtr, - errno: c_int, - dns_error: i32, + errno: sys::SystemErrno, ) -> JsResult<()> { let native_ref = if this.socket.get().is_detached() { NativeSocketRef::Released } else { NativeSocketRef::Held }; - Self::connect_failed(this, errno, dns_error, native_ref) + Self::connect_failed(this, errno, 0, native_ref) } + /// `dns_error` is `getaddrinfo(3)`'s return code when the name lookup failed, and `errno` means nothing then. fn connect_failed( this: bun_ptr::ThisPtr, - errno: c_int, + errno: sys::SystemErrno, dns_error: i32, native_ref: NativeSocketRef, ) -> JsResult<()> { @@ -1090,11 +1196,10 @@ impl NewSocket { this.ref_count.get() ); // Ensure the socket is still alive for any defer's we have. Declared - // before clear_and_free/unrefOnNextTick so the ref is balanced even if + // before discard_queued_writes/unrefOnNextTick so the ref is balanced even if // those calls unwind. let _guard = RefPtr::from_this(this); - this.buffered_data_for_node_net - .with_mut(|b| b.clear_and_free()); + this.discard_queued_writes(); this.socket.set(SocketHandler::::DETACHED); @@ -1127,9 +1232,7 @@ impl NewSocket { let global = handlers.global_object; // A failed name lookup is reported as the resolver error // (`getaddrinfo ENOTFOUND `, `syscall`/`hostname` set), - // matching `node:dns` — never collapsed into ECONNREFUSED. On that - // path `errno` carries the same (possibly negative) getaddrinfo code - // as `dns_error`, so it is only treated as an errno in the else arm. + // matching `node:dns`. let dns_err = c_ares::Error::init_eai(dns_error).filter(|_| dns_error != 0); let err = if let Some(dns_err) = dns_err { let hostname: &[u8] = match this.connection.get() { @@ -1142,68 +1245,11 @@ impl NewSocket { hostname, ) } else { - debug_assert!(errno >= 0); - // uSockets hands us raw WSA codes (SO_ERROR, the recv probe) on - // Windows; map those onto the SystemErrno numbering the whitelist - // below compares against. - #[cfg(windows)] - let errno: c_int = if errno >= 10000 { - sys::SystemErrno::init(errno as u32) - .map(|e| e as c_int) - .unwrap_or(sys::SystemErrno::ECONNREFUSED as c_int) - } else { - errno - }; - // Unix-path connect errors keep their real code (a non-socket file - // is ENOTSOCK, a permission-denied path is EACCES, a missing one is - // 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") - }; - #[cfg(windows)] - let errno_ = -sys::windows::libuv::e_discriminant_to_uv(errno_ as u16) - .unwrap_or(sys::windows::libuv::UV_ECONNREFUSED); - SystemError { - errno: -errno_, - message: BunString::static_("Failed to connect"), - syscall: BunString::static_("connect"), - code: code_, - ..Default::default() - } + let mut err = SystemError::from( + sys::Error::new(errno, sys::Tag::connect).to_shell_system_error(), + ); + err.message = BunString::static_("Failed to connect"); + err }; let _scope_guard = ScopeExit { @@ -1276,6 +1322,7 @@ impl NewSocket { errno: c_int, ) -> JsResult<()> { jsc::mark_binding!(); + let errno = uws::SocketGroup::errno(errno).unwrap_or(sys::SystemErrno::ECONNREFUSED); Self::connect_failed(this, errno, socket.dns_error(), NativeSocketRef::Held) } @@ -1295,15 +1342,75 @@ 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. node:net hears how its write ended. For anyone else, `close(socket, error)` reports a failure. + fn discard_queued_writes(&self) { + if self.flags.get().contains(Flags::SENDING_QUEUE) { + return; + } + self.buffered_data_for_node_net.with_mut(|b| b.clear()); + let status = match self.buffered_data_for_node_net.get().completion() { + Completion::None => return, + Completion::Owed | Completion::Reset => sys::SystemErrno::ECANCELED, + Completion::Failed(errno) => errno, + }; + if let Some(handlers) = self.handlers.get() + && !handlers.on_write_complete().is_empty() + { + self.buffered_data_for_node_net.with_mut(|b| b.take()); + self.complete_write_later(handlers, Some(uv_status(status))); + } + } + + /// Takes the error of the bytes that were lost, if any. `send_error` is the one uSockets closed the socket for. + fn take_failed_write(&self, send_error: c_int) -> Option { + if self.flags.get().contains(Flags::SENDING_QUEUE) { + return None; + } + if send_error != 0 && self.buffered_data_for_node_net.get().is_pending() { + self.buffered_data_for_node_net + .with_mut(|b| b.fail_held_records(send_errno(send_error))); + } + let Completion::Failed(errno) = self.buffered_data_for_node_net.get().completion() else { + return None; + }; + self.buffered_data_for_node_net.with_mut(|b| b.take()); + Some(errno) + } + pub(crate) fn close_and_detach(&self, code: uws::CloseCode) { let socket = self.socket.get(); - self.buffered_data_for_node_net - .with_mut(|b| b.clear_and_free()); + // A graceful TLS close ends with a close_notify, which says that the stream is whole. + let queue = self.buffered_data_for_node_net.get(); + let code = match code { + uws::CloseCode::Normal if queue.len() > 0 || queue.is_lost() => { + uws::CloseCode::FastShutdown + } + code => code, + }; + self.discard_queued_writes(); self.socket.set(SocketHandler::::DETACHED); self.detach_native_callback(); socket.close(code); + self.keep_if_still_closing(socket); + } + + /// A TLS close can wait for the peer to read or to answer. Keeps that socket for `terminate()`. + fn keep_if_still_closing(&self, socket: SocketHandler) { + if SSL && matches!(socket.socket, uws::InternalSocket::Connected(_)) && !socket.is_closed() + { + self.closing.set(socket); + } } /// Discard a still-live native socket so this wrapper can be reused for a @@ -1315,19 +1422,26 @@ impl NewSocket { /// `on_connecting_error` dispatch early-returns and no JS callback fires; /// `mark_inactive`/`deref` balance the refs the previous `connect_finish` /// took. Caller must hold an independent +1 across this call. Only - /// handles Connected/Connecting; Pipe/UpgradedDuplex back-pointers do - /// not live in the ext slot so those are left for the caller's existing - /// `debug_assert!` to catch. - pub(crate) fn detach_for_reconnect(&self) { + /// Connected / Connecting are this socket's to drop. False, with nothing changed, while another owner (uWS, a transport, an unfinished close) points at `self`. + #[must_use] + pub(crate) fn detach_for_reconnect(&self) -> bool { + if self.flags.get().contains(Flags::HELD_BY_SERVER) { + return false; + } let old = self.socket.get(); + match old.socket { + uws::InternalSocket::Connected(_) | uws::InternalSocket::Connecting(_) => {} + uws::InternalSocket::Detached => return self.closing.get().is_detached(), + uws::InternalSocket::UwsHttp(_) | uws::InternalSocket::Transport(_) => return false, + } let Some(ext) = old.ext::<*mut c_void>() else { - return; + return false; }; // SAFETY: ext slot is sized for `*mut c_void`; single-threaded. unsafe { *ext = core::ptr::null_mut() }; self.socket.set(SocketHandler::::DETACHED); - self.buffered_data_for_node_net - .with_mut(|b| b.clear_and_free()); + self.discard_queued_writes(); + 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())); @@ -1340,6 +1454,19 @@ impl NewSocket { } } self.deref(); + true + } + + /// The established `us_socket_t` whose ext slot is this socket's, so that it can move to another group. + pub(crate) fn own_connected_socket(&self) -> Option<*mut uws::us_socket_t> { + match self.socket.get().socket { + uws::InternalSocket::Connected(s) + if !self.flags.get().contains(Flags::HELD_BY_SERVER) => + { + Some(s) + } + _ => None, + } } pub(crate) fn mark_inactive(&self) { @@ -1398,14 +1525,9 @@ impl NewSocket { socket: SocketHandler, ) -> JsResult<()> { let this_ptr = this.as_ptr(); - this.set_latest_session(ptr::null_mut()); - // A late event on a socket that already released its Handlers through - // a path that did not route back through this dispatch - e.g. a - // JS-side destroy on a TLS socket driven by an upgraded duplex. There - // is nothing to dispatch to. - if !this.has_handlers() { + let Some(handlers) = this.handlers_opt() else { return Ok(()); - } + }; log!( "onOpen {} {:p} {} {}", if this.is_server() { "S" } else { "C" }, @@ -1432,12 +1554,37 @@ impl NewSocket { // Add SNI support for TLS (mongodb and others requires this) if SSL { if let Some(ssl_ptr) = this.socket.get().ssl() { + // BoringSSL's callbacks find this socket through the `SSL`, whichever engine drives it. + tls_socket_functions::ffi::us_ssl_set_bun_socket( + boringssl_sys::SSL::opaque_ref(ssl_ptr), + this_ptr.cast::(), + ); if tls_socket_functions::ffi::SSL_is_init_finished(boringssl_sys::SSL::opaque_ref( ssl_ptr, )) == 0 { - if let Some(server_name) = this.server_name.get() { - let host: &[u8] = server_name.as_ref(); + if this.acts_as_tls_server() { + // As in Node, a `serverName` handler (the user's SNICallback) replaces the lookup in the addContext() entries. + if !handlers.on_server_name().is_empty() { + tls_socket_functions::ffi::us_ssl_on_server_name( + boringssl_sys::SSL::opaque_ref(ssl_ptr), + ); + } + // The static list is read by `us_dispatch_alpn_select`. + if !handlers.on_alpn_callback().is_empty() { + tls_socket_functions::ffi::us_ssl_on_alpn_offer( + boringssl_sys::SSL::opaque_ref(ssl_ptr), + ); + } + } else { + // Only a client sends a server name. A server's `SSL` must keep the ClientHello's. + let host: &[u8] = match (this.server_name.get(), this.connection.get()) { + (Some(server_name), _) => server_name.as_ref(), + (None, Some(super::listener::UnixOrHost::Host { host, .. })) => { + bun_core::ip_address::strip_ipv6_brackets(host.as_ref()) + } + _ => b"", + }; if !host.is_empty() { let host_z = bun_core::ZBox::from_bytes(host); // SAFETY: `host_z` is NUL-terminated; FFI reads until NUL. @@ -1445,65 +1592,12 @@ impl NewSocket { 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()); - 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(), - ) - }; - } + // Like Node observably does, a rejecting client refuses a bad chain during the handshake (test-tls-close-error). + if this.flags.get().contains(Flags::REJECT_UNAUTHORIZED) { + this.socket.get().set_inline_reject(); } - } - // A server needs the per-connection ALPN selector when it - // has static ALPNProtocols OR a dynamic ALPNCallback (the - // selector consults the callback first and falls back to - // the static list). The callback reads `this` from the SSL, - // not the CTX-level arg (shared across the listener). - // 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. - if this.acts_as_tls_server() - && (this.protos.get().is_some() - || !this.get_handlers().on_alpn_callback().is_empty()) - { - let ssl_ref = boringssl_sys::SSL::opaque_ref(ssl_ptr); - tls_socket_functions::ffi::SSL_set_ex_data( - ssl_ref, - 0, - this_ptr.cast::(), - ); - tls_socket_functions::ffi::SSL_CTX_set_alpn_select_cb( - SSL_CTX::opaque_ref(tls_socket_functions::ffi::SSL_get_SSL_CTX( - ssl_ref, - )), - Some(select_alpn_callback), - ptr::null_mut(), - ); - } - // A rejecting client must refuse a bad chain DURING the - // handshake, like node observably does (its post-verify - // destroy cancels the queued Finished; test-tls-close-error). - if !this.acts_as_tls_server() - && this.flags.get().contains(Flags::REJECT_UNAUTHORIZED) - { - this.socket.get().set_inline_reject(); - } - if let Some(protos) = this.protos.get() { - if this.acts_as_tls_server() { - // Registered above (selector + ex_data); nothing - // further to do for the static server list here. - } else { - // SAFETY: `ssl_ptr` non-null in this branch; - // `protos.as_ptr()` is readable for `protos.len()` - // bytes (borrowed `&[u8]` from `this.protos`) and - // BoringSSL copies the buffer internally — raw - // ptr+len pair is the genuine FFI precondition here. + if let Some(protos) = this.protos.get() { + // SAFETY: `ssl_ptr` is non-null in this branch, and BoringSSL copies the `protos.len()` readable bytes. unsafe { boringssl_sys::SSL_set_alpn_protos( ssl_ptr, @@ -1522,7 +1616,6 @@ impl NewSocket { unsafe { *ctx = this_ptr.cast::() }; } - let handlers = this.get_handlers(); let global = handlers.global_object; // The socket is live whether or not script may run: give it its owner // (the wrapper adopts the creation reference; its finalizer releases @@ -1587,29 +1680,8 @@ impl NewSocket { && !this.socket.get().is_detached() && this.buffered_data_for_node_net.get().len() > 0 { - // A write issued from inside the open/'connection' callback (a - // server answering the moment a connection arrives) can be - // deferred into `buffered_data_for_node_net` before the socket has - // any usockets-level backpressure, so no writable event would ever - // flush it and its JS write callback would never run - the socket - // then never finishes and holds the event loop (the FIN-terminated - // http response tests hung on every Linux target). Deliver it now - // that the open dispatch is done; if it fully drains, complete the - // 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 drain_callback = handlers.on_writable(); - if !drain_callback.is_empty() { - if let Err(err) = drain_callback.call(&global, this_value, &[this_value]) { - handlers.call_error_handler( - this_value, - &[this_value, global.take_error(err)], - )?; - } - } - } + // What the `open` handler queued is sent again at once. + return Self::on_writable(this, socket); } Ok(()) } @@ -1664,17 +1736,9 @@ impl NewSocket { _socket: SocketHandler, ) -> JsResult<()> { jsc::mark_binding!(); - // A late event on a socket that already released its Handlers through - // a path that did not route back through this dispatch - e.g. a - // JS-side destroy on a TLS socket driven by an upgraded duplex. There - // is nothing to dispatch to. - if !this.has_handlers() { - return Ok(()); - } - if this.socket.get().is_detached() { + let Some(handlers) = this.dispatch_handlers() else { return Ok(()); - } - let handlers = this.get_handlers(); + }; log!( "onEnd {}", if handlers.mode == super::SocketMode::Server { @@ -1688,6 +1752,12 @@ impl NewSocket { let callback = handlers.on_end(); if callback.is_empty() { + // `on_writable` sends the queued bytes, then closes. + if this.buffered_data_for_node_net.get().len() > 0 { + this.update_flags(|f| f.insert(Flags::END_AFTER_FLUSH)); + this.close_if_flushed(); + 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. @@ -1697,17 +1767,35 @@ 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; } - Ok(()) + self.update_flags(|f| f.insert(Flags::END_AFTER_FLUSH)); + self.close_if_flushed(); } /// Takes `ThisPtr` for the same re-entrancy reason as `on_writable`. @@ -1718,26 +1806,20 @@ impl NewSocket { ssl_error: uws::us_bun_verify_error_t, ) -> JsResult<()> { jsc::mark_binding!(); - // A late event on a socket that already released its Handlers through - // a path that did not route back through this dispatch - e.g. a - // JS-side destroy on a TLS socket driven by an upgraded duplex. There - // is nothing to dispatch to. Nor from the GC finalizer's own close - // (an SSL shutdown reports the never-finished handshake): a finalizer - // dispatches nothing and may not inspect other cells mid-sweep. - if !this.has_handlers() || this.flags.get().contains(Flags::FINALIZING) { + let Some(handlers) = this.handlers_opt() else { + return Ok(()); + }; + // The GC finalizer's own close reports the never-finished handshake, and a finalizer may not inspect other cells mid-sweep. + if this.flags.get().contains(Flags::FINALIZING) { return Ok(()); } let first_report = !this.flags.get().contains(Flags::HANDSHAKE_COMPLETE); this.update_flags(|f| f.insert(Flags::HANDSHAKE_COMPLETE)); this.socket.set(s); - if this.socket.get().is_detached() { - return Ok(()); - } // Keep the socket alive across the callbacks below (which re-enter JS) // and across `reject_unauthorized_connection`, whose close may // otherwise drop the last reference. let _guard = RefPtr::from_this(this); - let handlers = this.get_handlers(); log!( "onHandshake {} ({})", if handlers.mode == super::SocketMode::Server { @@ -1824,7 +1906,6 @@ impl NewSocket { // including the raw twin of an `upgradeTLS` pair, which shares the fd. this.update_flags(|f| { f.set(Flags::AUTHORIZED, authorized && !verify_failed); - f.set(Flags::HOSTNAME_MISMATCH, hostname_mismatch); f.set(Flags::REJECTED, transport_unusable); }); if transport_unusable { @@ -1937,12 +2018,7 @@ impl NewSocket { pub(crate) fn reset_client_tls_flags(&self, reject_unauthorized: bool) { self.verify_error.set(None); self.update_flags(|f| { - f.remove( - Flags::HANDSHAKE_COMPLETE - | Flags::AUTHORIZED - | Flags::HOSTNAME_MISMATCH - | Flags::REJECTED, - ); + f.remove(Flags::HANDSHAKE_COMPLETE | Flags::AUTHORIZED | Flags::REJECTED); f.set(Flags::REJECT_UNAUTHORIZED, reject_unauthorized); }); } @@ -2017,30 +2093,11 @@ impl NewSocket { /// unwound, so the JS handler may safely destroy the socket. /// /// Takes `ThisPtr` for the same re-entrancy reason as `on_writable`. - /// Takes a reference to `session` and drops the one held before. Null only drops. - pub(crate) fn set_latest_session(&self, session: *mut boringssl_sys::SSL_SESSION) { - let session = ptr::NonNull::new(session); - if let Some(session) = session { - // SAFETY: the caller passes a live session. - unsafe { tls_socket_functions::ffi::SSL_SESSION_up_ref(session.as_ptr().cast()) }; - } - if let Some(prev) = self.latest_session.replace(session) { - // SAFETY: `latest_session` owned one reference. - unsafe { boringssl_sys::SSL_SESSION_free(prev.as_ptr()) }; - } - } - pub(crate) fn on_session(this: bun_ptr::ThisPtr, session: &[u8]) -> JsResult<()> { jsc::mark_binding!(); - if this.socket.get().is_detached() { - return Ok(()); - } - // Same late-event guard as the other dispatch entry points: the - // socket may already have released its Handlers. - if !this.has_handlers() { + let Some(handlers) = this.dispatch_handlers() else { return Ok(()); - } - let handlers = this.get_handlers(); + }; if handlers.vm.script_execution_status() != jsc::ScriptExecutionStatus::Running { return Ok(()); } @@ -2075,15 +2132,9 @@ impl NewSocket { /// Takes `ThisPtr` for the same re-entrancy reason as `on_writable`. pub(crate) fn on_keylog(this: bun_ptr::ThisPtr, line: &[u8]) -> JsResult<()> { jsc::mark_binding!(); - if this.socket.get().is_detached() { - return Ok(()); - } - // Same late-event guard as the other dispatch entry points: the - // socket may already have released its Handlers. - if !this.has_handlers() { + let Some(handlers) = this.dispatch_handlers() else { return Ok(()); - } - let handlers = this.get_handlers(); + }; if handlers.vm.script_execution_status() != jsc::ScriptExecutionStatus::Running { return Ok(()); } @@ -2123,7 +2174,12 @@ impl NewSocket { reason: Option<*mut c_void>, ) -> JsResult<()> { jsc::mark_binding!(); - this.set_latest_session(ptr::null_mut()); + if let Some(ssl) = socket.ssl() { + tls_socket_functions::ffi::us_ssl_set_bun_socket( + boringssl_sys::SSL::opaque_ref(ssl), + ptr::null_mut(), + ); + } // A late close on a socket that already released its Handlers through // a path that did not route back through this dispatch - e.g. a // JS-side destroy on a TLS socket driven by an upgraded duplex. There @@ -2131,6 +2187,7 @@ impl NewSocket { // ext-slot/owner pin) - release it and detach so nothing further // dispatches either. mark_inactive is not needed: handlers being // null means the previous teardown already ran it. + this.closing.set(SocketHandler::::DETACHED); if !this.has_handlers() { this.detach_native_callback(); this.socket.set(SocketHandler::::DETACHED); @@ -2149,7 +2206,7 @@ impl NewSocket { // Like Node, an http2 session reports a failed read, not a send that the kernel rejected. let err = if SSL && matches!(this.native_callback.get(), NativeCallbacks::H2(_)) - && socket.ssl_send_rejected() + && socket.rejected_send_error() != 0 { 0 } else { @@ -2157,6 +2214,19 @@ impl NewSocket { }; this.detach_native_callback(); this.socket.set(SocketHandler::::DETACHED); + // A write whose send failed ends with that error, ahead of `close`. Any other queued write is canceled. + let send_error = if err > 2 { + socket.rejected_send_error() + } else { + 0 + }; + let failed_write = this.take_failed_write(send_error); + let reset = this.buffered_data_for_node_net.get().completion() == Completion::Reset; + // node:net hears of it through the write's own callback. + let lost_tail = this.flags.get().contains(Flags::END_AFTER_FLUSH) + && this.buffered_data_for_node_net.get().len() > 0 + && handlers.on_write_complete().is_empty(); + this.discard_queued_writes(); // Declared first so it drops last: ticks drain ahead of the teardown. let _cleanup = CloseTeardown { socket: this, @@ -2186,6 +2256,11 @@ impl NewSocket { this.poll_ref.with_mut(|p| p.unref(js_loop_ctx())); + if let Some(errno) = failed_write { + let this_value = this.get_this_value(&handlers.global_object); + handlers.call_write_complete(this_value, uv_status(errno)); + } + let callback = handlers.on_close(); if callback.is_empty() { @@ -2193,7 +2268,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 + // 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() { @@ -2209,7 +2284,6 @@ impl NewSocket { let global = handlers.global_object; let this_value = this.get_this_value(&global); - let mut js_error: JSValue = JSValue::UNDEFINED; // `err` is overloaded: when WE closed the socket it's a libus // CloseCode enum (0=clean, 1=failure/RST, 2=fast-shutdown); when the // close was driven by a recv() failure or a poll error (loop.c's @@ -2219,10 +2293,22 @@ impl NewSocket { // values >2 are real read errors and 0/1/2 are self-initiated closes // that must not surface as a JS read error (matching Node's // onStreamRead, which only sees errors that came from uv_read_cb). - if err > 2 { - js_error = - ::to_js(&read_error_from_close_code(err), &global); - } + let error = match failed_write { + Some(errno) => Some(sys::Error::new(errno, sys::Tag::write)), + None if reset => Some(sys::Error::new( + sys::SystemErrno::ECONNRESET, + sys::Tag::read, + )), + None if err > 2 => Some(read_error_from_close_code(err)), + None if lost_tail => Some(sys::Error::new( + sys::SystemErrno::ECANCELED, + sys::Tag::write, + )), + None => None, + }; + let js_error = error.map_or(JSValue::UNDEFINED, |error| { + ::to_js(&error, &global) + }); if let Err(e) = callback.call(&global, this_value, &[this_value, js_error]) { handlers.call_error_handler(this_value, &[this_value, global.take_error(e)])?; @@ -2237,21 +2323,15 @@ impl NewSocket { data: &[u8], ) -> JsResult<()> { jsc::mark_binding!(); - // A late event on a socket that already released its Handlers through - // a path that did not route back through this dispatch - e.g. a - // JS-side destroy on a TLS socket driven by an upgraded duplex. There - // is nothing to dispatch to. - if !this.has_handlers() { + let Some(handlers) = this.handlers_opt() else { return Ok(()); - } + }; this.socket.set(s); - 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(()); } - let handlers = this.get_handlers(); log!( "onData {} ({})", if handlers.mode == super::SocketMode::Server { @@ -2339,9 +2419,9 @@ impl NewSocket { } log!("timeout({})", t); - self.socket - .get() - .set_timeout(c_uint::try_from(t).expect("int cast")); + let seconds = c_uint::try_from(t).expect("int cast"); + self.timeout_seconds.set(seconds); + self.socket.get().set_timeout(seconds); Ok(()) } @@ -2425,59 +2505,18 @@ impl NewSocket { Ok(match result { WriteResult::Fail => return Err(jsc::JsError::Thrown), // The errno of a fatal send (< -1) is for node:net; this API documents -1. - WriteResult::Success { wrote, .. } => JSValue::js_number_from_int32(wrote.max(-1)), + WriteResult::Success { wrote, .. } => JSValue::js_number(wrote.max(-1) as f64), }) } pub(crate) fn get_local_family(&self, cx: &mut JsScope<'_>) -> JSValue { - let global = cx.global(); - if self.socket.get().is_detached() { - return JSValue::UNDEFINED; - } - let mut buf = [0u8; 64]; - let Some(address_bytes) = self.socket.get().local_address(&mut buf) else { - return JSValue::UNDEFINED; - }; - match address_bytes.len() { - 4 => global.common_strings().ipv4(), - 16 => global.common_strings().ipv6(), - _ => JSValue::UNDEFINED, - } + ip_family_to_js(cx.global(), self.socket.get().local_address(&mut buf)) } pub(crate) fn get_local_address(&self, cx: &mut JsScope<'_>) -> JsResult { - let global = cx.global(); - if self.socket.get().is_detached() { - return Ok(JSValue::UNDEFINED); - } - let mut buf = [0u8; 64]; - let mut text_buf = [0u8; 512]; - - let Some(address_bytes) = self.socket.get().local_address(&mut buf) else { - return Ok(JSValue::UNDEFINED); - }; - // `format_ip` expects `addr:port` / `[addr]:port` shape (it strips - // `:port` and brackets), so pass a `SocketAddr` — bare `IpAddr` corrupts IPv6. - let address: std::net::SocketAddr = match address_bytes.len() { - 4 => std::net::SocketAddrV4::new( - std::net::Ipv4Addr::from(<[u8; 4]>::try_from(address_bytes).unwrap()), - 0, - ) - .into(), - 16 => std::net::SocketAddrV6::new( - std::net::Ipv6Addr::from(<[u8; 16]>::try_from(address_bytes).unwrap()), - 0, - 0, - 0, - ) - .into(), - _ => return Ok(JSValue::UNDEFINED), - }; - - let text = bun_fmt::format_ip(&address, &mut text_buf).expect("unreachable"); - bun_string_jsc::create_utf8_for_js(global, text) + ip_address_to_js(cx.global(), self.socket.get().local_address(&mut buf)) } pub(crate) fn get_local_port(&self) -> Option { @@ -2485,121 +2524,119 @@ impl NewSocket { } pub(crate) fn get_remote_family(&self, cx: &mut JsScope<'_>) -> JSValue { - let global = cx.global(); - if self.socket.get().is_detached() { - return JSValue::UNDEFINED; - } - let mut buf = [0u8; 64]; - let Some(address_bytes) = self.socket.get().remote_address(&mut buf) else { - return JSValue::UNDEFINED; - }; - match address_bytes.len() { - 4 => global.common_strings().ipv4(), - 16 => global.common_strings().ipv6(), - _ => JSValue::UNDEFINED, - } + ip_family_to_js(cx.global(), self.socket.get().remote_address(&mut buf)) } pub(crate) fn get_remote_address(&self, cx: &mut JsScope<'_>) -> JsResult { - let global = cx.global(); - if self.socket.get().is_detached() { - return Ok(JSValue::UNDEFINED); - } - let mut buf = [0u8; 64]; - let mut text_buf = [0u8; 512]; - - let Some(address_bytes) = self.socket.get().remote_address(&mut buf) else { - return Ok(JSValue::UNDEFINED); - }; - let address: std::net::SocketAddr = match address_bytes.len() { - 4 => std::net::SocketAddrV4::new( - std::net::Ipv4Addr::from(<[u8; 4]>::try_from(address_bytes).unwrap()), - 0, - ) - .into(), - 16 => std::net::SocketAddrV6::new( - std::net::Ipv6Addr::from(<[u8; 16]>::try_from(address_bytes).unwrap()), - 0, - 0, - 0, - ) - .into(), - _ => return Ok(JSValue::UNDEFINED), - }; - - let text = bun_fmt::format_ip(&address, &mut text_buf).expect("unreachable"); - bun_string_jsc::create_utf8_for_js(global, text) + ip_address_to_js(cx.global(), self.socket.get().remote_address(&mut buf)) } pub(crate) fn get_remote_port(&self) -> Option { self.socket.get().remote_port() } + /// `bytesWritten` counts a write when the socket accepts it, as in Node. #[inline] - fn do_socket_write(&self, buffer: &[u8]) -> i32 { - if self.flags.get().contains(Flags::BYPASS_TLS) { - self.socket.get().raw_write(buffer) - } else { - self.socket.get().write(buffer) - } + fn count_written(&self, bytes: usize) { + self.bytes_written + .set(self.bytes_written.get() + bytes as u64); } /// Vectored raw write for plain-TCP sockets: all chunks reach the fd in one /// writev. Callers guarantee this socket has no TLS layer (raw writes bypass - /// SSL framing). Updates bytes_written like the scalar path. + /// SSL framing). pub(crate) fn write_vectored_raw(&self, iov: &[bun_uws_sys::UsIoVec]) -> i32 { - let socket = self.socket.get(); - if socket.is_shutdown() || socket.is_closed() { + if !self.takes_writes() { return -1; } - if SSL && self.flags.get().contains(Flags::REJECTED) { - return -1; - } - let res = socket.raw_writev(iov); - let uwrote: usize = usize::try_from(res.max(0)).expect("int cast"); - self.bytes_written - .set(self.bytes_written.get() + uwrote as u64); + let res = self.socket.get().raw_writev(iov); + self.count_written(usize::try_from(res.max(0)).expect("int cast")); res } - pub(crate) fn write_maybe_corked(&self, buffer: &[u8]) -> i32 { + /// 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(); - if socket.is_shutdown() || socket.is_closed() { - return -1; + socket.is_established() + && !socket.is_shutdown() + && !socket.is_closed() + && !(SSL && self.flags.get().contains(Flags::REJECTED)) + } + + /// Asked once per write, where it enters. uSockets answers 0 to a write on such a socket, like for a full one. + fn takes_writes(&self) -> bool { + let socket = self.socket.get(); + !socket.is_shutdown() + && !socket.is_closed() + // The program closed it. `data` still comes while the TLS layer sends what it holds. + && self.closing.get().is_detached() + && !(SSL && self.flags.get().contains(Flags::REJECTED)) + } + + /// One send of at most `i32::MAX` bytes. Returns what the socket took, or the negative errno of a send that failed for good. + fn try_send(&self, buffer: &[u8]) -> i32 { + let socket = self.socket.get(); + let buffer = &buffer[..buffer.len().min(i32::MAX as usize)]; + // The raw half of an `upgradeTLS` pair shares the TLS half's us_socket_t, where `write_check_error` would encrypt. + if self.flags.get().contains(Flags::BYPASS_TLS) { + return socket.raw_write(buffer); } - let flags = self.flags.get(); - if SSL && flags.contains(Flags::REJECTED) { + let (res, fatal_errno) = socket.write_check_error(buffer); + log!("write({}) = {}", buffer.len(), res); + if fatal_errno != 0 { -fatal_errno } else { res } + } + + /// An unbuffered write: the caller keeps what the socket didn't take. + fn send_unbuffered(&self, buffer: &[u8]) -> i32 { + let res = self.try_send(buffer); + self.count_written(usize::try_from(res.max(0)).expect("int cast")); + res + } + + /// The http2 parser's write. Returns -1 if the socket takes no writes. + pub(crate) fn write_maybe_corked(&self, buffer: &[u8]) -> i32 { + if !self.takes_writes() { return -1; } + self.send_unbuffered(buffer) + } - // The raw [raw, tls] upgrade twin shares the TLS half's us_socket_t - // (`s->ssl` is set) but must write raw bytes: write_check_error would - // route it through the SSL-encrypting us_socket_write. - if flags.contains(Flags::BYPASS_TLS) { - let res = self.do_socket_write(buffer); - let uwrote: usize = usize::try_from(res.max(0)).expect("int cast"); - self.bytes_written - .set(self.bytes_written.get() + uwrote as u64); - log!("write({}) = {}", buffer.len(), res); - return res; + /// Sends `bytes` until the socket refuses some. Returns what it took, and the errno of a send that failed for good. + fn send(&self, mut bytes: &[u8]) -> (usize, c_int) { + let mut sent: usize = 0; + loop { + let wrote = self.try_send(bytes); + if wrote < -1 { + return (sent, -wrote); + } + let wrote = usize::try_from(wrote.max(0)).expect("int cast"); + sent += wrote; + bytes = &bytes[wrote..]; + // Only a whole `i32::MAX` slice leaves more to send without the socket having refused anything. + if wrote < i32::MAX as usize || bytes.is_empty() { + return (sent, 0); + } } + } - let (res, fatal_errno) = socket.write_check_error(buffer); + /// Sends the queue. Returns the errno of a send that failed for good, which drops the queue. + fn send_queue(&self) -> c_int { + let (sent, fatal_errno) = self.send_queued(|bytes| self.send(bytes)); if fatal_errno != 0 { - // Kernel rejected the send (peer gone): return the negative errno so - // JS fails the write; never close from under the caller's stack, and - // leave the undeliverable buffer to the caller (aliasing). - return -fatal_errno; + self.fail_queued_writes(fatal_errno, PendingWrites::fail); + return fatal_errno; } - let uwrote: usize = usize::try_from(res.max(0)).expect("int cast"); - self.bytes_written - .set(self.bytes_written.get() + uwrote as u64); - log!("write({}) = {}", buffer.len(), res); - res + self.buffered_data_for_node_net + .with_mut(|b| b.consume(sent)); + if self.socket.get().is_detached() { + self.discard_queued_writes(); + } + 0 } + /// node:net's write. `true`: sent. `false`: queued, and `writeComplete` follows. A negative errno: refused, nothing follows. pub(crate) fn write_buffered( &self, cx: &mut JsScope<'_>, @@ -2607,83 +2644,77 @@ impl NewSocket { encoding: JsLocal<'_>, ) -> JsResult { let global = cx.global(); - if self.socket.get().is_detached() { - self.buffered_data_for_node_net - .with_mut(|b| b.clear_and_free()); + let (false, Some(handlers)) = (self.socket.get().is_detached(), self.handlers_opt()) else { + let errno = if cfg!(windows) { + sys::SystemErrno::EPIPE + } else { + sys::SystemErrno::EBADF + }; + return Ok(JSValue::js_number_from_int32(uv_status(errno))); + }; + + // A Duplex transport's `write()` runs JS, which can close this socket. + let _guard = self.ref_guard(); + let result = if self.buffered_data_for_node_net.get().len() == 0 { + self.write_or_end::( + global, + data.to_js(), + JSValue::UNDEFINED, + JSValue::UNDEFINED, + encoding.to_js(), + true, + ) + } else { + self.write_behind_queue(global, data.to_js(), encoding.to_js()) + }; + let WriteResult::Success { wrote, total } = result else { + return Err(jsc::JsError::Thrown); + }; + if wrote < -1 { + let send_error = c_int::try_from(-wrote).expect("int cast"); + if !SSL { + let status = uv_status(send_errno(send_error)); + return Ok(JSValue::js_number_from_int32(status)); + } + // A TLS write never fails in the same call, as in Node. + self.fail_queued_writes(send_error, PendingWrites::fail); return Ok(JSValue::FALSE); } - - Ok( - match self.write_or_end_buffered::(global, data.to_js(), encoding.to_js()) { - WriteResult::Fail => return Err(jsc::JsError::Thrown), - WriteResult::Success { wrote, total } => { - if wrote < -1 { - // Fatal send (peer gone): hand JS the negative errno so - // 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 { - JSValue::TRUE - } else { - JSValue::FALSE - } - } - }, - ) - } - - pub(crate) fn end_buffered( - &self, - cx: &mut JsScope<'_>, - data: JsLocal<'_>, - encoding: JsLocal<'_>, - ) -> JsResult { - let global = cx.global(); + if usize::try_from(wrote.max(0)).expect("int cast") == total + && !self.holds_unsent_tls_records() + { + return Ok(JSValue::TRUE); + } if self.socket.get().is_detached() { + self.buffered_data_for_node_net.with_mut(|b| b.take()); + let canceled = uv_status(sys::SystemErrno::ECANCELED); + self.complete_write_later(&handlers, Some(canceled)); + } else { self.buffered_data_for_node_net - .with_mut(|b| b.clear_and_free()); - return Ok(JSValue::FALSE); + .with_mut(|b| b.owe_completion()); } + Ok(JSValue::FALSE) + } - // `write_or_end_buffered` reaches `internal_flush`, which re-enters JS. - let _guard = self.ref_guard(); - let result = - match self.write_or_end_buffered::(global, data.to_js(), encoding.to_js()) { - WriteResult::Fail => return Err(jsc::JsError::Thrown), - WriteResult::Success { wrote, total } => { - if wrote >= 0 && usize::try_from(wrote).expect("int cast") == total { - let _ = self.internal_flush(); - } - - JSValue::from(usize::try_from(wrote.max(0)).expect("int cast") == total) - } - }; - Ok(result) + /// The write stays pending until the writable event that follows. The raw twin of an `upgradeTLS` pair gets none. + fn holds_unsent_tls_records(&self) -> bool { + let uws_buffers = matches!(self.socket.get().socket, uws::InternalSocket::UwsHttp(_)); + (uws_buffers || (SSL && !self.flags.get().contains(Flags::BYPASS_TLS))) + && self.socket.get().ssl_spill_pending() } - fn write_or_end_buffered( + /// `$write` while earlier bytes are still queued. + fn write_behind_queue( &self, global: &JSGlobalObject, data_value: JSValue, encoding_value: JSValue, ) -> WriteResult { - if self.buffered_data_for_node_net.get().len() == 0 { - return self.write_or_end::( - global, - data_value, - JSValue::UNDEFINED, - JSValue::UNDEFINED, - encoding_value, - true, - ); - } - let buffer: StringOrBuffer = if data_value.is_undefined() { StringOrBuffer::EMPTY } else { match StringOrBuffer::from_js_with_encoding_value_allow_string_object( global, - // allocator dropped (global mimalloc) data_value, encoding_value, StringObjects::Allow, @@ -2702,127 +2733,27 @@ impl NewSocket { Err(_) => return WriteResult::Fail, } }; - // `buffer` Drop frees. - if !self.flags.get().contains(Flags::END_AFTER_FLUSH) && IS_END { - self.update_flags(|f| f.insert(Flags::END_AFTER_FLUSH)); + let total = buffer.slice().len() + self.buffered_data_for_node_net.get().len(); + if !self.takes_writes() { + return WriteResult::Success { wrote: -1, total }; } - - let socket = self.socket.get(); - if socket.is_shutdown() || socket.is_closed() { + self.count_written(buffer.slice().len()); + self.buffered_data_for_node_net + .with_mut(|b| b.append_slice(buffer.slice())); + let fatal_errno = self.send_queue(); + if fatal_errno != 0 { return WriteResult::Success { - wrote: -1, - total: buffer.slice().len() + self.buffered_data_for_node_net.get().len() as usize, + wrote: -(fatal_errno as isize), + total, }; } - - let total_to_write: usize = - buffer.slice().len() + self.buffered_data_for_node_net.get().len() as usize; - if total_to_write == 0 { - if SSL { - log!("total_to_write == 0"); - if !data_value.is_undefined() { - log!("data_value is not undefined"); - // special condition for SSL_write(0, "", 0) - // we need to send an empty packet after the buffer is flushed and after the handshake is complete - // and in this case we need to ignore SSL_write() return value because 0 should not be treated as an error - self.update_flags(|f| f.insert(Flags::EMPTY_PACKET_PENDING)); - if !self.try_write_empty_packet() { - return WriteResult::Success { - wrote: -1, - total: total_to_write, - }; - } - } - } - - return WriteResult::Success { wrote: 0, total: 0 }; + if self.socket.get().is_detached() { + return WriteResult::Success { wrote: -1, total }; } - - #[allow(unused_labels)] - let wrote: i32 = 'brk: { - #[cfg(unix)] - if !SSL { - // fast-ish path: use writev() to avoid cloning to another buffer. - if let uws::InternalSocket::Connected(connected) = socket.socket { - if !buffer.slice().is_empty() { - // SAFETY: `connected` is a live `*mut us_socket_t` (guard above). - let rc = unsafe { - (*connected).write2( - self.buffered_data_for_node_net.get().slice(), - buffer.slice(), - ) - }; - let written: usize = usize::try_from(rc.max(0)).expect("int cast"); - self.bytes_written - .set(self.bytes_written.get() + written as u64); - let leftover = total_to_write.saturating_sub(written); - if leftover == 0 { - self.buffered_data_for_node_net - .with_mut(|b| b.clear_and_free()); - break 'brk rc; - } - - // writev order: buffered data first, then `buffer`. - let buf_len = self.buffered_data_for_node_net.get().len(); - let input_written = - written.saturating_sub(buf_len).min(buffer.slice().len()); - let remaining_in_input_data = &buffer.slice()[input_written..]; - - self.buffered_data_for_node_net - .with_mut(|b| b.drain_front(written)); - - if !remaining_in_input_data.is_empty() { - // Result intentionally discarded - let _ = self - .buffered_data_for_node_net - .with_mut(|b| b.append_slice(remaining_in_input_data)); - } - - break 'brk rc; - } - } - } - - // slower-path: clone the data, do one write. - // Result intentionally discarded - 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()); - 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()); - } 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); - } - }); - } - - rc - }; - + let left = self.buffered_data_for_node_net.get().len(); WriteResult::Success { - wrote, - total: total_to_write, + wrote: isize::try_from(total - left).expect("int cast"), + total, } } @@ -2836,16 +2767,22 @@ impl NewSocket { buffer_unwritten_data: bool, ) -> WriteResult { if data.is_undefined() { - if !self.flags.get().contains(Flags::END_AFTER_FLUSH) && IS_END { + if IS_END { self.update_flags(|f| f.insert(Flags::END_AFTER_FLUSH)); } log!("writeOrEnd undefined"); return WriteResult::Success { wrote: 0, total: 0 }; } - debug_assert!(self.buffered_data_for_node_net.get().len() == 0); - let (offset_value, length_value, encoding_value) = if byte_length.is_string() { - (byte_offset, JSValue::UNDEFINED, byte_length) + // `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 (offset_value, length_value, encoding_value) = if byte_length.is_string() { + (byte_offset, JSValue::UNDEFINED, byte_length) } else if byte_offset.is_string() { (JSValue::UNDEFINED, byte_length, byte_offset) } else { @@ -2916,7 +2853,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"); @@ -2976,14 +2913,13 @@ impl NewSocket { return WriteResult::Fail; } - let socket = self.socket.get(); - if socket.is_shutdown() || socket.is_closed() { + if !self.takes_writes() { return WriteResult::Success { wrote: -1, total: bytes.len(), }; } - if !self.flags.get().contains(Flags::END_AFTER_FLUSH) && IS_END { + if IS_END { self.update_flags(|f| f.insert(Flags::END_AFTER_FLUSH)); } @@ -3004,19 +2940,27 @@ impl NewSocket { return WriteResult::Success { wrote: 0, total: 0 }; } 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 { - let remaining = &bytes[uwrote..]; - if !remaining.is_empty() { - let _ = self - .buffered_data_for_node_net - .with_mut(|b| b.append_slice(remaining)); // OOM/capacity: fire-and-forget - } + if !buffer_unwritten_data { + return WriteResult::Success { + wrote: self.send_unbuffered(bytes) as isize, + total: bytes.len(), + }; + } + // Ahead of the send: a Duplex transport's `write()` can destroy the socket, and node:net reads the count there. + self.count_written(bytes.len()); + let (sent, fatal_errno) = self.send(bytes); + if fatal_errno != 0 { + return WriteResult::Success { + wrote: -(fatal_errno as isize), + total: bytes.len(), + }; + } + if sent < bytes.len() { + self.buffered_data_for_node_net + .with_mut(|b| b.append_slice(&bytes[sent..])); } - WriteResult::Success { - wrote, + wrote: isize::try_from(sent).expect("int cast"), total: bytes.len(), } } @@ -3042,78 +2986,25 @@ impl NewSocket { && flags.contains(Flags::END_AFTER_FLUSH) && !flags.contains(Flags::EMPTY_PACKET_PENDING) && self.buffered_data_for_node_net.get().len() == 0 + && !self.tls_half_waits_to_send_tail() } - /// Flushes the node:net buffered tail. Returns 0, or the positive errno of - /// a fatal send error (buffer dropped, writable not re-armed). - /// `on_writable` consumes the errno. It hands a TLS socket's to the drain - /// callback. For plain TCP on POSIX it dispatches the error - /// handler and closes the socket. On Windows the errno is still ignored - /// (the drain callback is dispatched regardless) - skipping the drain on - /// fatal made Windows servers reset FIN-terminated responses (see - /// a5e7ba5905) - until the Windows fatal-write detection is verified. - fn internal_flush(&self) -> i32 { + /// Sends the queue, then closes a socket that `end()` is waiting to close. Returns false if a send failed for good. + fn internal_flush(&self) -> bool { // A TLS socket whose handshake was rejected has no usable transport: // never push the buffered tail at it, and report no error (the // rejection is surfaced by the handshake path, not by the flush). if SSL && self.flags.get().contains(Flags::REJECTED) { - return 0; + return true; } - // R-2: every mutated field is `Cell`/`JsCell`, so `&self` carries no - // `noalias` for them and the previous `black_box` launder (which - // mitigated ASM-verified PROVEN_CACHED stale loads of - // `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. - // 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 - // kept this buffer parked forever (the FIN-terminated-response hang). - // BYPASS_TLS twins keep the raw write path. - 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()); - 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()); - return fatal_errno; - } - res - }; - 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(); - } - }); - } + if self.buffered_data_for_node_net.get().len() > 0 && self.send_queue() != 0 { + return false; } let _ = self.try_write_empty_packet(); self.socket.get().flush(); - - if self.can_end_after_flush() { - self.mark_inactive(); - } - 0 + self.close_if_flushed(); + true } pub(crate) fn flush(&self, _: &mut JsScope<'_>) { @@ -3133,18 +3024,26 @@ impl NewSocket { pub(crate) fn terminate(&self, _: &mut JsScope<'_>) { self.close_and_detach(uws::CloseCode::Failure); + self.closing + .replace(SocketHandler::::DETACHED) + .close(uws::CloseCode::Failure); } pub(crate) fn shutdown(&self, _: &mut JsScope<'_>, half_close: bool) { if half_close { self.socket.get().shutdown_read(); - } else { + } else if !self.native_callback.get().defer_shutdown() + && !self.waits_to_send_tail() + && !self.tls_half_waits_to_send_tail() + { + // Otherwise the parser sends the FIN after its output, or `end(data)` does once its tail is sent. self.socket.get().shutdown(); } } pub(crate) fn close(&self, _: &mut JsScope<'_>) { let socket = self.socket.get(); + self.discard_queued_writes(); // `_handle.close()` is the net.Socket `_destroy()` path. Node closes the fd // with no close_notify (crypto_tls.cc sends the alert only from DoShutdown, // the end() path), so `.fast_shutdown` raw-closes synchronously: a bare FIN. @@ -3155,6 +3054,7 @@ impl NewSocket { // defer behind its ciphertext write spill (`ssl_close_after_spill`). socket.close(uws::CloseCode::FastShutdown); self.socket.set(SocketHandler::::DETACHED); + self.keep_if_still_closing(socket); self.poll_ref.with_mut(|p| { p.unref(bun_io::posix_event_loop::get_vm_ctx( bun_io::AllocatorType::Js, @@ -3178,28 +3078,47 @@ impl NewSocket { // `write_or_end` reaches `internal_flush`, which re-enters JS. let _guard = self.ref_guard(); + // No writable event reaches the raw half of an `upgradeTLS` pair, so nothing would send its tail. + let queues_tail = !self.flags.get().contains(Flags::BYPASS_TLS); let result = self.write_or_end::( global, data.to_js(), byte_offset.to_js(), byte_length.to_js(), encoding.to_js(), - false, + queues_tail, ); let result = match result { WriteResult::Fail => return Err(jsc::JsError::Thrown), WriteResult::Success { wrote, total } => { - if wrote >= 0 && usize::try_from(wrote).expect("int cast") == total { + if wrote < -1 { + self.fail_queued_writes( + c_int::try_from(-wrote).expect("int cast"), + PendingWrites::fail, + ); + } else if wrote >= 0 && usize::try_from(wrote).expect("int cast") == total { let _ = self.internal_flush(); + } else { + self.arm_tail_timeout(); + } + if queues_tail && wrote >= 0 { + JSValue::js_number(total as f64) + } else { + JSValue::js_number(wrote.max(-1) as f64) } - JSValue::js_number_from_int32(wrote.max(-1)) } }; Ok(result) } + /// Returns false for a node:http connection that uWS owns: its server holds the loop for it, and `leave_http` applies the wish. + fn holds_loop_when_reffed(&self) -> bool { + let socket = self.socket.get(); + socket.is_established() && !matches!(socket.socket, uws::InternalSocket::UwsHttp(_)) + } + pub(crate) fn js_ref(&self) { - if self.socket.get().is_established() { + if self.holds_loop_when_reffed() { self.poll_ref.with_mut(|p| p.ref_(js_loop_ctx())); } else { // `connect_finish` holds the loop until then; `on_open` applies this. @@ -3208,7 +3127,7 @@ impl NewSocket { } pub(crate) fn js_unref(&self) { - if self.socket.get().is_established() { + if self.holds_loop_when_reffed() { self.poll_ref.with_mut(|p| p.unref(js_loop_ctx())); } else { self.ref_pollref_on_connect.set(false); @@ -3273,7 +3192,11 @@ impl NewSocket { } pub(crate) fn get_bytes_written(&self) -> f64 { - (self.bytes_written.get() + self.buffered_data_for_node_net.get().len() as u64) as f64 + self.total_bytes_written() as f64 + } + + pub(crate) fn get_write_queue_size(&self) -> f64 { + self.write_queue_size() as f64 } /// In-place TCP→TLS upgrade. The underlying `us_socket_t` is @@ -3309,195 +3232,34 @@ impl NewSocket { // adopt; the old `isDetached()/isNamedPipe()` guard let those // through and the `.connected` payload read below would then be // illegal-union-access on a `.connecting` socket. - if !matches!(self.socket.get().socket, uws::InternalSocket::Connected(_)) { + if self.own_connected_socket().is_none() { return Err(global.throw_invalid_arguments(format_args!( "upgradeTLS requires an established socket" ))); } - if !opts.is_object() { - return Err(global.throw(format_args!("Expected options object"))); - } - - // Server-side upgrade (`new tls.TLSSocket(socket, { isServer: true })`): - // adopt the fd into an accept-state SSL so the native read path drives the - // handshake — same code path as the client upgrade, only `is_client` flips. - // An explicit `isServer` option wins over the underlying socket's mode so - // an outgoing connection can still be wrapped as the server side, the way - // Node honors the option regardless of how the socket was created. - let is_server = match opts.get_truthy(global, "isServer")? { - Some(value) => value.to_boolean(), - None => self.is_server(), - }; - - let socket_obj = opts - .get(global, "socket")? - .ok_or_else(|| global.throw(format_args!("Expected \"socket\" option")))?; - // Bytes already consumed from the wire before the upgrade (e.g. the - // ClientHello sitting in the readable buffer of the socket being - // wrapped); fed into the TLS engine once the upgrade is wired up. - let initial_data: Vec = match opts.get_truthy(global, "initialData")? { - Some(v) => StringOrBuffer::from_js(global, v)? - .map(|data| data.slice().to_vec()) - .unwrap_or_default(), - None => Vec::new(), - }; - // Client mode: a standalone `new TLSSocket(socket, { isServer })` is NOT - // a SocketListener, so these handlers have no listener to release. The - // server-ness lives in the SSL accept state (adopt_tls - // is_client=!is_server) + the ServerHandlers JS table, not here. - let handlers = Handlers::from_js(global, socket_obj, super::SocketMode::Client)?; - // Nothing holds the callback cell until the TLS wrapper below does. - let _cell_root = handlers.root_cell(global); - - // Resolve the `SSL_CTX*`. Prefer a passed `SecureContext` (the - // memoised `tls.createSecureContext` path) so 10k upgrades share - // one `SSL_CTX_new`; otherwise build an owned one from inline - // `tls:` options. Either way `owned_ctx` holds one ref we drop in - // deinit; SSL_new() takes its own. - // - let owned_ctx: Option; - let mut ssl_opts: Option = None; - // Drop frees ssl_opts. - - // node:net wraps the result of `[buntls]` as `opts.tls`, so the - // SecureContext arrives as `opts.tls.secureContext`. Bun.connect - // userland may also pass it top-level. Check both. - let sc_js: JSValue = 'blk: { - if let Some(v) = opts.get_truthy(global, "secureContext")? { - break 'blk v; - } - if let Some(t) = opts.get_truthy(global, "tls")? { - if t.is_object() { - if let Some(v) = t.get_truthy(global, "secureContext")? { - break 'blk v; - } - } - } - JSValue::ZERO - }; - if !sc_js.is_empty() { - let Some(sc) = sc_js.as_class_ref::() else { - return Err(global.throw_invalid_argument_type_value( - b"secureContext", - b"SecureContext", - sc_js, - )); - }; - owned_ctx = Some(sc.ctx.clone()); - // servername / ALPN still come from the surrounding tls config. - if let Some(t) = opts.get_truthy(global, "tls")? { - if !t.is_boolean() { - ssl_opts = SSLConfig::from_js( - // SAFETY: per-thread VM singleton. - VirtualMachine::get().as_mut(), - global, - t, - is_server, - )?; - } - } - } else if let Some(tls_js) = opts.get_truthy(global, "tls")? { - if !tls_js.is_boolean() { - ssl_opts = SSLConfig::from_js( - // SAFETY: per-thread VM singleton. - VirtualMachine::get().as_mut(), - global, - tls_js, - is_server, - )?; - } else if tls_js.to_boolean() { - ssl_opts = Some(crate::socket::tls_true_defaults(handlers.vm, is_server)); - } - let cfg = ssl_opts - .as_mut() - .ok_or_else(|| global.throw(format_args!("Expected \"tls\" option")))?; - let mut create_err = uws::create_bun_socket_error_t::none; - // Per-VM weak cache: `tls:true` and `{servername}`-only hit - // the same CTX as `Bun.connect`; an inline CA dedupes across - // every upgradeTLS that names it. - // `bun_jsc::rare_data::RareData::ssl_ctx_cache()` returns - // the high-tier opaque ZST stub (cycle-break); the concrete - // `SSLContextCache` lives on this thread's `RuntimeState`. - let cache = { - let state = crate::jsc_hooks::runtime_state(); - debug_assert!(!state.is_null(), "RuntimeState not installed"); - // SAFETY: per-thread `RuntimeState` boxed by `init_runtime_state`; - // stable address for the VM's lifetime, JS-thread-only access. - unsafe { &mut (*state).ssl_ctx_cache } - }; - owned_ctx = cache.get_or_create(cfg, &mut create_err); - if owned_ctx.is_none() { - // us_ssl_ctx_from_options only sets *err for the CA/cipher - // cases; bad cert/key/DH return NULL with err==.none and the - // detail is on the BoringSSL error queue. - if create_err != uws::create_bun_socket_error_t::none { - return Err(global.throw_value( - crate::socket::uws_jsc::create_bun_socket_error_to_js(create_err, global), - )); - } - return Err( - global.throw_value(boringssl_err_to_js(global, boringssl_sys::ERR_get_error())) - ); - } - } else { - return Err(global.throw(format_args!("Expected \"tls\" option"))); - } - - let mut default_data = JSValue::ZERO; - if let Some(v) = opts.fast_get(global, jsc::BuiltinName::Data)? { - default_data = v; - default_data.ensure_still_alive(); - } + let options = UpgradeOptions::from_js(global, opts, self.is_server())?; + let is_server = options.is_server; // An option getter can close this socket, or re-enter upgradeTLS and adopt its fd. - let uws::InternalSocket::Connected(raw_socket) = self.socket.get().socket else { + let Some(raw_socket) = self.own_connected_socket() else { return Err(global.throw_invalid_arguments(format_args!( "upgradeTLS requires an established socket" ))); }; + // The TLS socket would start in the middle of the data that `end(data)` took. + if self.waits_to_send_tail() { + return Err(global.throw_invalid_arguments(format_args!( + "upgradeTLS requires a socket that has not ended" + ))); + } - let vm = handlers.vm; - - let cfg = ssl_opts.as_ref(); - let ctx_ptr = owned_ctx.as_ref().map(|c| c.as_ptr()); - let reject_unauthorized = upgrade_reject_policy(vm, cfg, is_server, ctx_ptr); - let (adopt_request_cert, adopt_reject_unauthorized) = match cfg { - Some(c) => (c.request_cert != 0, c.reject_unauthorized != 0), - None => ( - server_ctx_requests_cert(ctx_ptr), - server_ctx_rejects_unauthorized(ctx_ptr), - ), - }; - let mut initial_flags = Flags::initial(reject_unauthorized); - 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 { - ref_count: bun_ptr::RefCount::init(), - handlers: JsCell::new(Some(handlers)), - socket: Cell::new(SocketHandler::::DETACHED), - owned_ssl_ctx: JsCell::new(owned_ctx), - connection: JsCell::new(self.connection.get().clone()), - local_binding: JsCell::new(None), - protos: JsCell::new(cfg.and_then(|c| c.protos_bytes().map(Box::<[u8]>::from))), - server_name: JsCell::new( - cfg.and_then(|c| c.server_name_bytes().map(Box::<[u8]>::from)), - ), - flags: Cell::new(initial_flags), - 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), - native_callback: JsCell::new(NativeCallbacks::None), - twin: JsCell::new(None), - verify_error: JsCell::new(None), - latest_session: Cell::new(None), - }); - // Never shadow this with a long-lived borrow: it would alias the - // reference dispatch materialises from the ext slot during - // `on_open`/`start_tls_handshake`. - - let sni: Option<&core::ffi::CStr> = cfg.and_then(|c| c.server_name_cstr()); + let vm = options.handlers.vm; + let verify = options.server_verify(); + let tls = options.new_socket(defers_server_identity); + tls.update_flags(|f| f.insert(self.flags.get() & Flags::ENDS_ON_PEER_FIN)); + tls.connection.set(self.connection.get().clone()); + let sni: Option<&core::ffi::CStr> = + options.config.as_ref().and_then(|c| c.server_name_cstr()); let loop_ = vm.uws_loop(); // The TLS socket stays with the context the TCP socket belongs to, whoever upgrades it. let accepted = self @@ -3522,17 +3284,17 @@ impl NewSocket { }; let group = groups.bun_connect_group::(loop_); // SAFETY: `raw_socket` is the live `*mut us_socket_t` extracted from - // `InternalSocket::Connected` above; `owned_ssl_ctx` is the +1 ref + // `InternalSocket::Connected` above; `options.ctx` is the +1 ref // taken from SecureContext/ssl_ctx_cache and never null here. let new_raw: NonNull = match unsafe { (*raw_socket).adopt_tls( group, uws::SocketKind::BunSocketTls, - &mut *(tls.owned_ssl_ctx.get().as_ref().unwrap().as_ptr()), + &mut *options.ctx.as_ptr(), sni, !is_server, - adopt_request_cert, - adopt_reject_unauthorized, + verify.request_cert, + verify.reject_unauthorized, core::mem::size_of::<*mut c_void>() as i32, core::mem::size_of::<*mut c_void>() as i32, ) @@ -3540,8 +3302,7 @@ impl NewSocket { Some(s) => s, None => { let err = boringssl_sys::ERR_get_error(); - // `deref` runs `Drop`, which drops the owned_ctx - // ref and the handlers `Rc`. Sole owner of the fresh allocation. + // Sole owner of the fresh allocation: `deref` drops it, with its handlers. tls.get().deref(); if err != 0 { return Err(global.throw_value(boringssl_err_to_js(global, err))); @@ -3591,30 +3352,15 @@ impl NewSocket { // The `raw` half — same `us_socket_t*`, ORIGINAL pre-upgrade // *Handlers, writes bypass SSL. Dispatch reaches it via the // `ssl_raw_tap` ciphertext hook, never via the ext slot. - let raw = TLSSocket::new(TLSSocket { - ref_count: bun_ptr::RefCount::init(), - handlers: JsCell::new(raw_handlers), - socket: Cell::new(SocketHandler::::from(new_raw.as_ptr())), - owned_ssl_ctx: JsCell::new(None), - connection: JsCell::new(None), - local_binding: JsCell::new(None), - protos: JsCell::new(None), - server_name: JsCell::new(None), - // is_active so the chained `raw.on_close` → `mark_inactive` path - // releases `raw_handlers`. No poll_ref — `tls` keeps the loop - // alive. active_connections=1 was already on raw_handlers from - // `self`. - flags: Cell::new(Flags::BYPASS_TLS | Flags::IS_ACTIVE | Flags::OWNED_PROTOS), - 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), - native_callback: JsCell::new(NativeCallbacks::None), - twin: JsCell::new(None), - verify_error: JsCell::new(None), - latest_session: Cell::new(None), - }); + let raw = TLSSocket::new(raw_handlers); + raw.socket + .set(SocketHandler::::from(new_raw.as_ptr())); + // IS_ACTIVE so `raw.on_close` releases `raw_handlers`, which already count this connection from `self`. + raw.flags.set(Flags::BYPASS_TLS | Flags::IS_ACTIVE); + // Same fd as the TCP wrapper it replaces, so the count carries over. + raw.bytes_written.set(self.bytes_written.get()); + raw.bytes_read.set(self.bytes_read.get()); + tls.timeout_seconds.set(self.timeout_seconds.get()); let raw_ref = raw; raw_ref.ref_(); // SAFETY: `raw` came from `TLSSocket::new` (heap::alloc); intrusive +1 held. @@ -3623,7 +3369,7 @@ impl NewSocket { let tls_js_value = tls.get_this_value(global); let raw_js_value = raw_ref.get_this_value(global); - TLSSocket::data_set_cached(tls_js_value, global, default_data); + TLSSocket::data_set_cached(tls_js_value, global, options.data); // `raw` keeps the pre-upgrade `data` so its callbacks emit on the // original net.Socket, not the TLS one. TLSSocket::data_set_cached(raw_js_value, global, original_data); @@ -3637,12 +3383,6 @@ impl NewSocket { }); } - // A server-side adopted socket has no listen socket to hang an - // SNICallback off, so the resolver goes on the SSL itself. Must run - // before the handshake is driven. - if is_server && !tls.get_handlers().on_server_name().is_empty() { - bun_opaque::opaque_deref_mut(new_raw.as_ptr()).on_server_name(); - } // Fire onOpen with the right `this`, then send ClientHello. Doing // it before ext was repointed would have ALPN/onOpen land in the // dead TCPSocket. @@ -3658,8 +3398,9 @@ impl NewSocket { // Feed bytes that arrived before the upgrade (already pulled off the fd // by the plain-TCP layer) into the TLS engine exactly as if they had // just been received — for a server-side wrap this is the ClientHello. - if !initial_data.is_empty() { - bun_opaque::opaque_deref_mut(new_raw.as_ptr()).tls_feed(initial_data.as_slice()); + if !options.initial_data.is_empty() { + bun_opaque::opaque_deref_mut(new_raw.as_ptr()) + .tls_feed(options.initial_data.as_slice()); } // After the feed: `raw` handed those bytes over, it is not shown them again. bun_opaque::opaque_deref_mut(new_raw.as_ptr()).set_ssl_raw_tap(true); @@ -3674,90 +3415,6 @@ impl NewSocket { pub(crate) type TCPSocket = NewSocket; pub(crate) type TLSSocket = NewSocket; -/// The TLS members of a plain socket, see `tls_socket_functions.rs`. -impl TCPSocket { - pub(crate) fn disable_renegotiation(&self) {} - pub(crate) fn is_session_reused(&self) -> bool { - false - } - pub(crate) fn set_verify_mode( - &self, - _: &mut JsScope<'_>, - _: JsLocal<'_>, - _: JsLocal<'_>, - ) -> JsResult<()> { - Ok(()) - } - pub(crate) fn renegotiate(&self, _: &mut JsScope<'_>) -> JsResult<()> { - Ok(()) - } - pub(crate) fn get_tls_ticket(&self, _: &mut JsScope<'_>) -> JsResult { - Ok(JSValue::UNDEFINED) - } - pub(crate) fn set_session(&self, _: &mut JsScope<'_>, _: JsLocal<'_>) -> JsResult<()> { - Ok(()) - } - pub(crate) fn get_session(&self, _: &mut JsScope<'_>) -> JsResult { - Ok(JSValue::UNDEFINED) - } - pub(crate) fn get_alpn_protocol(&self, _: &mut JsScope<'_>) -> JsResult { - Ok(JSValue::FALSE) - } - pub(crate) fn set_key_cert(&self, _: &mut JsScope<'_>, _: JsLocal<'_>) -> JsResult<()> { - Ok(()) - } - pub(crate) fn export_keying_material( - &self, - _: &mut JsScope<'_>, - _: JsLocal<'_>, - _: JsLocal<'_>, - _: JsLocal<'_>, - ) -> JsResult { - Ok(JSValue::UNDEFINED) - } - pub(crate) fn get_ephemeral_key_info(&self, _: &mut JsScope<'_>) -> JsResult { - Ok(JSValue::NULL) - } - pub(crate) fn get_cipher(&self, _: &mut JsScope<'_>) -> JsResult { - Ok(JSValue::UNDEFINED) - } - pub(crate) fn get_tls_peer_finished_message(&self, _: &mut JsScope<'_>) -> JsResult { - Ok(JSValue::UNDEFINED) - } - pub(crate) fn get_tls_finished_message(&self, _: &mut JsScope<'_>) -> JsResult { - Ok(JSValue::UNDEFINED) - } - pub(crate) fn get_shared_sigalgs(&self, _: &mut JsScope<'_>) -> JsResult { - Ok(JSValue::UNDEFINED) - } - pub(crate) fn get_tls_version(&self, _: &mut JsScope<'_>) -> JsResult { - Ok(JSValue::NULL) - } - pub(crate) fn set_max_send_fragment( - &self, - _: &mut JsScope<'_>, - _: JsLocal<'_>, - ) -> JsResult { - Ok(JSValue::FALSE) - } - pub(crate) fn get_peer_certificate( - &self, - _: &mut JsScope<'_>, - _: JsLocal<'_>, - ) -> JsResult { - Ok(JSValue::NULL) - } - pub(crate) fn get_certificate(&self, _: &mut JsScope<'_>) -> JsResult { - Ok(JSValue::UNDEFINED) - } - pub(crate) fn get_servername(&self, _: &mut JsScope<'_>) -> JsResult { - Ok(JSValue::UNDEFINED) - } - pub(crate) fn set_servername(&self, _: &mut JsScope<'_>, _: JsLocal<'_>) -> JsResult<()> { - Ok(()) - } -} - // ────────────────────────────────────────────────────────────────────────── // NativeCallbacks — direct callbacks on HTTP2 when available // ────────────────────────────────────────────────────────────────────────── @@ -3790,6 +3447,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, + } + } } // ────────────────────────────────────────────────────────────────────────── @@ -3798,7 +3462,45 @@ impl NativeCallbacks { enum WriteResult { Fail, - Success { wrote: i32, total: usize }, + Success { wrote: isize, total: usize }, +} + +/// The end of a write that has to come from the event loop. +pub(crate) struct DeferredWriteCompletion { + socket: RefPtr>, + /// A closed socket holds its wrapper weakly. + this_value: jsc::Strong, + handlers: Rc, + /// `None`: close the socket if its reads are still stopped. `on_close` reports the failed write. + status: Option, +} + +impl DeferredWriteCompletion { + #[allow(clippy::boxed_local, reason = "reclaim point for the boxed task")] + pub(crate) fn run(self: Box) { + if let Some(status) = self.status { + self.handlers + .call_write_complete(self.this_value.get(), status); + } else if self.socket.lost_write_of_stopped_reader() { + self.socket.socket.get().close(uws::CloseCode::Failure); + } + } +} + +impl bun_event_loop::Taskable for DeferredWriteCompletion { + const TAG: bun_event_loop::TaskTag = if SSL { + bun_event_loop::task_tag::TLSSocketWriteCompletion + } else { + bun_event_loop::task_tag::TCPSocketWriteCompletion + }; + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract. Boxed in `complete_write_later`. + drop(unsafe { bun_core::heap::take(this) }); + } + unsafe fn context(this: *const Self) -> bun_event_loop::ContextId { + // SAFETY: fn contract. The queued task is live. + unsafe { (*this).handlers.context() } + } } pub(crate) struct StoredVerifyError { @@ -3812,7 +3514,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; @@ -3820,9 +3522,9 @@ bitflags::bitflags! { const HANDSHAKE_COMPLETE = 1 << 3; const EMPTY_PACKET_PENDING = 1 << 4; 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. @@ -3830,7 +3532,6 @@ bitflags::bitflags! { /// The peer failed verification under an enforcing policy: the socket /// refuses writes and is closed right after the handshake callback. const REJECTED = 1 << 10; - const HOSTNAME_MISMATCH = 1 << 11; const REJECT_UNAUTHORIZED = 1 << 12; /// Set only by the node:net / node:tls socket constructors: their JS /// layer owns server-identity policy (`checkServerIdentity`), so the @@ -3842,17 +3543,175 @@ 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 `us_dispatch_alpn_select` 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; + /// node:net's `fdIsWriteOnly`: `do_connect` gives the descriptor to an `FdPipe`, not to uSockets. + const WRITE_ONLY_FD = 1 << 18; + /// C++ points at this socket for a node:http connection, or for the `ws` WebSocket made of it, until `Bun__NodeHTTPSocket__onClose`. The ext of the `us_socket_t` is uWS's. + const HELD_BY_SERVER = 1 << 19; + /// TCP_NODELAY is known to be on, so asking for it again costs no syscall. node:net asks for each connection of an http.Server, and the accept has set it. + const NO_DELAY = 1 << 30; + /// The `ws` package's WebSocket on this connection has closed, and the kernel had taken everything it queued. + const CLOSED_WITH_ALL_DATA_SENT = 1 << 31; } } impl Flags { fn initial(reject_unauthorized: bool) -> Flags { - let mut flags = Flags::default(); + let mut flags = Flags::empty(); flags.set(Flags::REJECT_UNAUTHORIZED, reject_unauthorized); flags } } +/// What `upgradeTLS()` and node:tls's two forms of it, over a descriptor and over a stream, read from their options. +struct UpgradeOptions { + is_server: bool, + handlers: Rc, + /// Nothing else holds the handlers' callback cell until the TLS socket's wrapper does. + _cell_root: jsc::Strong, + /// Bytes read off the wire before the upgrade, such as a ClientHello in the readable buffer of the wrapped socket. + initial_data: Vec, + ctx: boringssl_sys::OwnedSslCtx, + /// The server name, the ALPN protocols and the peer-certificate policy. `None` for a bare `secureContext`. + config: Option, + data: JSValue, +} + +impl UpgradeOptions { + /// An explicit `isServer` wins over how the wrapped socket was created, as in Node. + fn from_js( + global: &JSGlobalObject, + opts: JSValue, + is_server_by_default: bool, + ) -> JsResult { + if !opts.is_object() { + return Err(global.throw(format_args!("Expected options object"))); + } + let is_server = match opts.get_truthy(global, "isServer")? { + Some(value) => value.to_boolean(), + None => is_server_by_default, + }; + let socket_obj = opts + .get(global, "socket")? + .ok_or_else(|| global.throw(format_args!("Expected \"socket\" option")))?; + let initial_data: Vec = match opts.get_truthy(global, "initialData")? { + Some(v) => StringOrBuffer::from_js(global, v)? + .map(|data| data.slice().to_vec()) + .unwrap_or_default(), + None => Vec::new(), + }; + // Client mode: no listener owns these handlers. `Flags::TLS_SERVER_ROLE` carries the server role. + let handlers = Handlers::from_js(global, socket_obj, SocketMode::Client)?; + let cell_root = handlers.root_cell(global); + let vm = handlers.vm; + + // node:net passes what `[buntls]` returns as `tls`, so its SecureContext is `tls.secureContext`. Userland may pass it top-level. + let mut given_ctx = SecureContext::ctx_from_options(global, opts)?; + let tls_js = opts.get_truthy(global, "tls")?; + if given_ctx.is_none() + && let Some(tls_js) = tls_js + { + given_ctx = SecureContext::ctx_from_options(global, tls_js)?; + } + let (ctx, config) = if let Some(ctx) = given_ctx { + let config = match tls_js { + Some(t) if !t.is_boolean() => { + ParsedTls::from_js(vm, global, t, is_server)?.or_none() + } + _ => None, + }; + (ctx, config) + } else if let Some(tls_js) = tls_js { + let mut config = if tls_js.is_boolean() { + vm.tls_config(Default::default(), is_server) + } else { + let parsed = ParsedTls::from_js(vm, global, tls_js, is_server)?; + if is_server { + parsed + .or_none() + .ok_or_else(|| global.throw(format_args!("Expected \"tls\" option")))? + } else { + // A client's object that sets no option is `true`, as in `Bun.connect`. + parsed.or_defaults(vm, false) + } + }; + (Self::cached_ctx(global, &mut config)?, Some(config)) + } else { + return Err(global.throw(format_args!("Expected \"tls\" option"))); + }; + + let data = opts + .fast_get(global, jsc::BuiltinName::Data)? + .unwrap_or_default(); + Ok(Self { + is_server, + handlers, + _cell_root: cell_root, + initial_data, + ctx, + config, + data, + }) + } + + /// The per-VM cache: `tls: true` and `{ servername }` share the context of `Bun.connect`, and upgrades that name one CA share theirs. + fn cached_ctx( + global: &JSGlobalObject, + config: &mut SSLConfig, + ) -> JsResult { + let mut create_err = uws::create_bun_socket_error_t::none; + if let Some(ctx) = SSLContextCache::get_or_create(config, &mut create_err) { + return Ok(ctx); + } + // Only the CA and cipher cases set the error. A bad cert, key or DH leaves the detail on BoringSSL's error queue. + Err( + global.throw_value(if create_err != uws::create_bun_socket_error_t::none { + crate::socket::uws_jsc::create_bun_socket_error_to_js(create_err, global) + } else { + boringssl_err_to_js(global, boringssl_sys::ERR_get_error()) + }), + ) + } + + /// A server's `requestCert` and `rejectUnauthorized`. A bare `secureContext` has them in its verify mode. + fn server_verify(&self) -> ServerVerify { + let ctx = Some(self.ctx.as_ptr()); + match &self.config { + Some(c) => ServerVerify { + request_cert: c.request_cert != 0, + reject_unauthorized: c.reject_unauthorized != 0, + }, + None => ServerVerify { + request_cert: server_ctx_requests_cert(ctx), + reject_unauthorized: server_ctx_rejects_unauthorized(ctx), + }, + } + } + + /// The TLS socket of the upgrade. It has no transport yet. + fn new_socket(&self, defers_server_identity: bool) -> bun_ptr::ThisPtr { + let config = self.config.as_ref(); + let mut flags = Flags::initial(upgrade_reject_policy( + self.handlers.vm, + config, + self.is_server, + Some(self.ctx.as_ptr()), + )); + flags.set(Flags::DEFERS_SERVER_IDENTITY, defers_server_identity); + flags.set(Flags::TLS_SERVER_ROLE, self.is_server); + let tls = TLSSocket::new(Some(Rc::clone(&self.handlers))); + tls.protos + .set(config.and_then(|c| c.protos_bytes().map(Box::<[u8]>::from))); + tls.server_name + .set(config.and_then(|c| c.server_name_bytes().map(Box::<[u8]>::from))); + tls.flags.set(flags); + tls + } +} + /// Reject policy for an `upgradeTLS`/duplex socket: a parsed config wins. fn upgrade_reject_policy( vm: &VirtualMachine, @@ -3883,13 +3742,6 @@ pub(crate) fn server_ctx_requests_cert(ctx: Option<*mut SSL_CTX>) -> bool { unsafe { boringssl_sys::SSL_CTX_get_verify_mode(ctx) & boringssl_sys::SSL_VERIFY_PEER != 0 } } -impl Default for Flags { - fn default() -> Self { - // Default: `owned_protos` true, all others false. - Flags::OWNED_PROTOS - } -} - // ────────────────────────────────────────────────────────────────────────── // SocketMode // ────────────────────────────────────────────────────────────────────────── @@ -3901,472 +3753,12 @@ pub enum SocketMode { Client, /// Listener-owned server. TLS (if any) configured at the listener level. Server, - /// Duplex upgraded to TLS server role. Not listener-owned — - /// mark_inactive uses client lifecycle path. - DuplexServer, } impl SocketMode { /// Returns true for any mode that acts as a TLS server (ALPN, handshake direction). - /// Both .server and .duplex_server present as server to peers. pub(crate) fn is_server(self) -> bool { - matches!(self, SocketMode::Server | SocketMode::DuplexServer) - } -} - -// ────────────────────────────────────────────────────────────────────────── -// DuplexUpgradeContext -// ────────────────────────────────────────────────────────────────────────── - -impl bun_event_loop::Taskable for DuplexUpgradeContext { - const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::DuplexUpgradeContext; - /// The context's one queued hop will not run. If the TLSSocket is still - /// attached (a `StartTLS` that never ran: no wrapper was created, so no - /// close ever detached it), route it through its close first — that - /// consumes our +1 and detaches it from the duplex, so its finalizer - /// during ~VM finds nothing to reach into. Then free the context, unless - /// an entry point holds it: the release of that ref queues the free. - unsafe fn release_unrun(this: *mut Self) { - // SAFETY: fn contract; the single queue entry for `this`. - let ctx = unsafe { bun_ptr::ThisPtr::new(this) }; - ctx.queued.set(false); - if let Some(tls) = ctx.tls.replace(None) { - // An entry point on the stack keeps the context past this call. - // Neuter the listener thunks now, while the wrapper is still - // strongly held. - ctx.upgrade.teardown(); - let socket = ctx.duplex_socket(); - // `Err` is left pending for the release dispatcher's fold. - let _ = TLSSocket::on_close(tls.into_this_ptr(), socket, 0, None); - } - // Not `release_connection`: at zero that queues the hop this call stands in for. - if ctx.take_connection_ref() { - ctx.ref_count.set(ctx.ref_count.get() - 1); - } - if ctx.ref_count.get() == 0 { - // SAFETY: fn contract; nothing holds the context and no borrow of it is live. - unsafe { Self::deinit(this) }; - } - } - /// The hop continues the script that upgraded the duplex: once that context has stopped, a - /// `StartTLS` that has not run yet must not start (`release_unrun` closes what there is). - unsafe fn context(this: *const Self) -> bun_event_loop::ContextId { - // SAFETY: fn contract — the queued context. - unsafe { (*this).context } - } -} - -/// Live contexts, read by `bun:internal-for-testing` leak tests. -pub(crate) static DUPLEX_UPGRADE_CONTEXT_LIVE_COUNT: core::sync::atomic::AtomicUsize = - core::sync::atomic::AtomicUsize::new(0); - -#[derive(bun_ptr::CellRefCounted)] -#[ref_count(destroy = Self::queue_free)] -pub(crate) struct DuplexUpgradeContext { - /// One ref for the open connection (`connection_ref`) and one per entry - /// point on the stack ([`UpgradedDuplex::keep_alive`]). At zero the - /// `Close` hop is queued, which frees the context. No ref is taken after - /// that. - ref_count: Cell, - /// The connection's ref is still held. Cleared by [`Self::take_connection_ref`]. - connection_ref: Cell, - pub upgrade: UpgradedDuplex, - // We only us a tls and not a raw socket when upgrading a Duplex, Duplex dont support socketpairs - pub tls: JsCell>>, - /// JSC_BORROW: process-lifetime per-thread VM (immortal). Stored as - /// `&'static` so [`enqueue_self_task`](Self::enqueue_self_task) routes - /// through the safe `event_loop_mut()` accessor instead of a raw deref. - pub vm: &'static VirtualMachine, - pub(in crate::socket) task_event: Cell, - /// A `task_event` hop is in the VM's queue. The context is enqueued by - /// pointer, so at most one entry may exist: `StartTLS` first, and `Close` - /// once the ref count has reached zero. - queued: Cell, - /// Config to build a fresh `SSL_CTX` from (legacy `{ca,cert,key}` callers). - /// Mutually exclusive with `owned_ctx` — `runEvent` prefers `owned_ctx`. - pub ssl_config: JsCell>, - /// Prebuilt `SSL_CTX` (from `opts.tls.secureContext` — the memoised - /// `tls.createSecureContext` path). Moved into `start_tls_with_ctx`; - /// dropped in `deinit` if Close races ahead of StartTLS. - pub owned_ctx: JsCell>, - pub is_open: Cell, - /// Server-side `requestCert`/`rejectUnauthorized`, applied to the `SSL*` - /// when `StartTLS` builds it. Unused for client upgrades. - pub server_verify: crate::socket::upgraded_duplex::ServerVerify, - mode: SocketMode, - /// A TLS socket over a JS duplex is in no uSockets group, so the context - /// that upgraded it closes it through this owner when it stops. - abort_handle: bun_jsc::AbortHandle, - /// That context. - context: bun_jsc::ContextId, -} - -// `close` may re-enter (`on_close`) and schedule the free of `this`. -bun_jsc::impl_abort_handle_owner!(DuplexUpgradeContext, abort_handle, |this, _cause| { - // SAFETY: trait contract — `this` is live (armed ⇒ `deinit` has not run). - unsafe { bun_ptr::ThisPtr::new(this) }.upgrade.close() -}); - -#[repr(u8)] -#[derive(Clone, Copy, PartialEq, Eq)] -pub(super) enum EventState { - StartTLS, - Close, -} - -impl DuplexUpgradeContext { - #[inline(always)] - // The `on_*` callbacks take `ThisPtr`, not `&mut self`: they are - // dispatched from `UpgradedDuplex`'s engine (`start_tls` fires `on_open` - // synchronously) while `run_event`'s borrow of `upgrade` is live across - // that call. Mutable state lives in `Cell`/`JsCell` fields so every - // access is a short shared borrow. - fn duplex_socket(&self) -> SocketHandler { - from_duplex::(&self.upgrade) - } - - #[inline] - fn tls_this_ptr(&self) -> Option> { - self.tls.get().as_ref().map(|t| t.this_ptr()) - } - - /// A handler can close the socket and turn the event loop before it - /// returns, so every dispatch runs under an entry point's - /// [`UpgradedDuplex::keep_alive`]. - #[inline] - pub(in crate::socket) fn debug_assert_kept_alive(&self) { - debug_assert!( - self.ref_count.get() > u32::from(self.connection_ref.get()), - "DuplexUpgradeContext dispatch with no keep_alive() on the stack" - ); - } - - /// Holds the context until the returned guard drops: see - /// [`UpgradedDuplex::keep_alive`]. - pub(in crate::socket) fn keep_alive(this: bun_ptr::ThisPtr) -> RefPtr { - debug_assert!( - this.ref_count.get() > 0, - "DuplexUpgradeContext entered after its last ref was released" - ); - RefPtr::from_this(this) - } - - /// The connection is over. True the first time: the caller then releases - /// the connection's ref. - fn take_connection_ref(&self) -> bool { - if !self.connection_ref.replace(false) { - return false; - } - // Nothing is left for the owning context to close, and its hook would - // take a ref. - self.abort_handle.leave(); - true - } - - /// The transport closed, or `StartTLS` could not start it. - fn release_connection(this: bun_ptr::ThisPtr) { - if this.take_connection_ref() { - // SAFETY: `this` is live and the ref released is the connection's. - unsafe { Self::deref(this.as_ptr()) }; - } - } - - /// The last ref is gone. It is usually an entry point's guard, which - /// drops while that entry point's `&self` is live, so the context is not - /// freed in place: the queued hop frees it. - fn queue_free(this: *mut Self) { - // SAFETY: the count just reached zero; `this` is live until that hop runs. - let this = unsafe { bun_ptr::ThisPtr::new(this) }; - debug_assert!( - !this.queued.get(), - "DuplexUpgradeContext released its last ref with a hop still queued" - ); - this.task_event.set(EventState::Close); - Self::enqueue_self_task(this); - } - - fn on_open(this: bun_ptr::ThisPtr) { - this.debug_assert_kept_alive(); - this.is_open.set(true); - let socket = this.duplex_socket(); - if let Some(tls) = this.tls_this_ptr() { - crate::dispatch::fold(TLSSocket::on_open(tls, socket)); - } - } - - fn on_data(this: bun_ptr::ThisPtr, decoded_data: &[u8]) { - this.debug_assert_kept_alive(); - let socket = this.duplex_socket(); - if let Some(tls) = this.tls_this_ptr() { - crate::dispatch::fold(TLSSocket::on_data(tls, socket, decoded_data)); - } - } - - fn on_session(this: bun_ptr::ThisPtr, session: &[u8]) { - this.debug_assert_kept_alive(); - if let Some(tls) = this.tls_this_ptr() { - crate::dispatch::fold(TLSSocket::on_session(tls, session)); - } - } - - fn on_keylog(this: bun_ptr::ThisPtr, line: &[u8]) { - this.debug_assert_kept_alive(); - if let Some(tls) = this.tls_this_ptr() { - crate::dispatch::fold(TLSSocket::on_keylog(tls, line)); - } - } - - fn server_identity( - this: bun_ptr::ThisPtr, - ssl: &mut boringssl_sys::SSL, - ) -> bun_boringssl::ServerIdentity { - this.debug_assert_kept_alive(); - this.tls_this_ptr() - .map_or(bun_boringssl::ServerIdentity::Unchecked, |tls| { - tls.server_identity(ssl) - }) - } - - fn on_handshake( - this: bun_ptr::ThisPtr, - success: bool, - ssl_error: uws::us_bun_verify_error_t, - ) { - this.debug_assert_kept_alive(); - let socket = this.duplex_socket(); - if let Some(tls) = this.tls_this_ptr() { - crate::dispatch::fold(TLSSocket::on_handshake( - tls, - socket, - success as i32, - ssl_error, - )); - } - } - - fn on_end(this: bun_ptr::ThisPtr) { - this.debug_assert_kept_alive(); - let socket = this.duplex_socket(); - if let Some(tls) = this.tls_this_ptr() { - crate::dispatch::fold(TLSSocket::on_end(tls, socket)); - } - } - - fn on_writable(this: bun_ptr::ThisPtr) { - this.debug_assert_kept_alive(); - let socket = this.duplex_socket(); - if let Some(tls) = this.tls_this_ptr() { - crate::dispatch::fold(TLSSocket::on_writable(tls, socket)); - } - } - - fn on_error(this: bun_ptr::ThisPtr, err_value: JSValue) { - this.debug_assert_kept_alive(); - if this.is_open.get() { - if let Some(tls) = this.tls_this_ptr() { - crate::dispatch::fold(tls.handle_error(err_value)); - return; - } - } else if let Some(tls) = this.tls.replace(None) { - // Pre-open error (e.g. the duplex emitted non-Buffer data - // before the queued `.StartTLS` task ran). `handle_connect_error` - // → `mark_inactive` releases `tls.handlers`; null `tls` so the - // still-queued `.StartTLS` → `onOpen` — and any further - // duplex events — skip the TLSSocket instead of hitting - // `has_handlers() == false` in `onOpen`. - // - // Refcount: `tls.socket` is `InternalSocket::UpgradedDuplex` - // here (assigned in `upgrade_duplex_to_tls` *before* - // `start_tls()` enqueues anything and before any duplex - // callback can dispatch), so `handle_connect_error`'s - // `needs_deref = !is_detached()` is `true` and it consumes - // the owner's +1 we hold. Do NOT let `RefPtr::Drop` - // fire on top of that (over-deref → UAF on the JS wrapper's - // pointee). - // - // Neuter the JS listener thunks now, while the wrapper is still - // strongly held, so the `deinit` → `Drop` teardown later (the - // queued `StartTLS` releases the connection's ref, after - // `handle_connect_error` downgrades the wrapper) is a no-op on - // already-cleared shadows. - this.upgrade.teardown(); - let p = tls.into_this_ptr(); - crate::dispatch::fold(TLSSocket::handle_connect_error( - p, - sys::SystemErrno::ECONNREFUSED as c_int, - 0, - )); - return; - } - // The socket has closed: uncaught, like what a transport method throws in node. - let vm = this.vm; - let _ = vm - .as_mut() - .uncaught_exception(vm.global(), err_value, false); - } - - fn on_timeout(this: bun_ptr::ThisPtr) { - this.debug_assert_kept_alive(); - let socket = this.duplex_socket(); - if let Some(tls) = this.tls_this_ptr() { - crate::dispatch::fold(TLSSocket::on_timeout(tls, socket)); - } - } - - fn on_close(this: bun_ptr::ThisPtr) { - this.debug_assert_kept_alive(); - let socket = this.duplex_socket(); - if let Some(tls) = this.tls.replace(None) { - // `TLSSocket::on_close` consumes the +1 we hold (its scope-exit deref - // is the ext-slot/owner pin). Null our pointer first so the - // `queue_free` → `deinit` path doesn't deref it a second - // time — that's the over-deref behind the cross-file - // `TLSSocket::finalize` use-after-poison. It also means a throw - // from `duplex.end()` (called right after this returns via - // `UpgradedDuplex::on_close` → `call_write_or_end`) hits the null-check - // in `on_error` instead of reading the Handlers that `TLSSocket::on_close` - // → `mark_inactive` just released. - let p = tls.into_this_ptr(); - crate::dispatch::fold(TLSSocket::on_close(p, socket, 0, None)); - } - - Self::release_connection(this); - } - - /// Takes `ThisPtr` because it may free `this` (via - /// [`Self::deinit`]); nothing touches `this` after that call. - pub(crate) fn run_event(this: bun_ptr::ThisPtr) { - this.queued.set(false); - match this.task_event.get() { - EventState::StartTLS => { - // `start_tls` dispatches `on_open` and the first handshake pass. - let _keep_alive = Self::keep_alive(this); - // A pre-open error (onError's `!is_open` branch) may have - // already fired the connect-error callback, freed - // `tls.handlers`, and nulled `tls` while this task was - // queued. There is nothing to open in that case — and - // nothing else will release the connection's ref since the - // SSLWrapper (whose close callback normally does) is never - // created — so release it here instead of - // spinning up a wrapper that would dispatch `onOpen` into - // the dead socket. - if this.tls.get().is_none() { - Self::release_connection(this); - return; - } - // The transport closed while this task was queued: an engine - // started now could never handshake, and nothing would free it. - if this.upgrade.pending_close.replace(false) { - this.upgrade.finish_close(); - return; - } - log!( - "DuplexUpgradeContext.startTLS mode={}", - <&'static str>::from(this.mode) - ); - let is_client = this.mode == SocketMode::Client; - let verify = this.server_verify; - let started: crate::Result<()> = if let Some(ctx) = this.owned_ctx.take() { - this.upgrade.start_tls_with_ctx(ctx, is_client, verify) - } else if let Some(config) = this.ssl_config.replace(None) { - // Lent out of the cell: `start_tls` dispatches `on_open` synchronously. - let result = this.upgrade.start_tls(&config, is_client, verify); - this.ssl_config.set(Some(config)); - result - } else { - Ok(()) - }; - if let Err(err) = started { - if matches!(err, crate::Error::Alloc(_)) { - bun_core::out_of_memory(); - } - let errno = sys::SystemErrno::ECONNREFUSED as c_int; - if let Some(tls) = this.tls.replace(None) { - // `handle_connect_error` consumes our +1 — `tls.socket` - // is `InternalSocket::UpgradedDuplex` (set before - // `start_tls()` was queued), so `needs_deref = - // !is_detached()` is true — and detaches. Null - // `this.tls` so `deinit` doesn't deref again. - // - // Neuter the JS listener thunks now, while the wrapper - // is still strongly held, so the `deinit` → `Drop` - // teardown later is a no-op on already-cleared shadows. - this.upgrade.teardown(); - let p = tls.into_this_ptr(); - crate::dispatch::fold(TLSSocket::handle_connect_error(p, errno, 0)); - } - // `start_tls`/`start_tls_with_ctx` failed before the - // SSLWrapper was assigned, so its close callback - // was never registered and nothing else will release - // the connection's ref. Same as the `tls == null` - // early-return above. - Self::release_connection(this); - return; - } - this.ssl_config.set(None); // Drop frees. - // Bytes the peer sent while this task was still queued were - // staged by `UpgradedDuplex::on_internal_receive_data` (the - // engine did not exist yet to receive them). Feed them now - // that StartTLS is fully done, so the replay is ordered - // exactly like an ordinary post-start delivery. Without this - // the bytes are dropped and the handshake never advances - - // e.g. both ends of a `duplexPair()` wrapped in-process, where - // the peer's ClientHello lands before this side starts. - this.upgrade.drain_pending(); - } - // Previously this only called `upgrade.close()` and never `deinit`, - // leaking the SSLWrapper, the strong refs, and this struct itself - // for every duplex-upgraded TLS socket. - // SAFETY: `this` is the task's live context; its last ref queued this hop and no borrow of `*this` is live. - EventState::Close => unsafe { Self::deinit(this.as_ptr()) }, - } - } - - /// process-lifetime per-thread singleton stored at construction - /// (`upgrade_duplex_to_tls`); `event_loop_mut()` is the safe accessor. - /// Takes `ThisPtr` so the queued task carries the allocation's root - /// pointer (the task's `run_event` may free it). - #[inline] - fn enqueue_self_task(this: bun_ptr::ThisPtr) { - if this.queued.replace(true) { - // Already in the queue: that entry runs the updated `task_event`. - return; - } - this.vm - .event_loop_mut() - .enqueue_task(jsc::Task::init(this.as_ptr())); - } - - fn start_tls(this: bun_ptr::ThisPtr) { - this.task_event.set(EventState::StartTLS); - Self::enqueue_self_task(this); - } - - /// # Safety - /// `this` must be the unique live pointer to the heap allocation produced - /// in `upgrade_duplex_to_tls`, with a ref count of zero. Frees the - /// allocation; callers must not - /// hold a `&`/`&mut Self` across this call (taking `&mut self` here would - /// be a Stacked Borrows protector violation when the backing `Box` is - /// reclaimed below). - unsafe fn deinit(this: *mut Self) { - { - // SAFETY: `this` is live; this borrow ends with the block, before the `heap::take` free below. - let ctx = unsafe { &*this }; - debug_assert_eq!(ctx.ref_count.get(), 0); - DUPLEX_UPGRADE_CONTEXT_LIVE_COUNT.fetch_sub(1, core::sync::atomic::Ordering::Relaxed); - ctx.abort_handle.leave(); - ctx.tls.set(None); - // Close raced ahead of StartTLS — drop the unconsumed config / ctx. - ctx.ssl_config.set(None); - ctx.owned_ctx.set(None); - } - // `UpgradedDuplex` cleanup - // runs via `Drop` when `heap::take(this)` frees the containing - // struct below; an explicit call here would double-free. - // SAFETY: heap-allocated in `upgrade_duplex_to_tls`; this is the - // matching free. No `&`/`&mut Self` survives past this point. - drop(unsafe { bun_core::heap::take(this) }); + self == SocketMode::Server } } @@ -4415,291 +3807,53 @@ pub(crate) fn first_flight_before_fin(socket: SocketHandle<'_>) { } } +/// node:tls over a stream that has no descriptor to adopt. Returns the TLS socket and the listeners for the stream's 'data', 'end' and 'close'. pub(crate) fn upgrade_duplex_to_tls<'a>( cx: &mut JsScope<'a>, duplex: JsLocal<'a>, - options: &DuplexTLSOptions<'a>, + options: JsLocal<'a>, ) -> JsResult { - jsc::mark_binding!(); let global = cx.global(); // The upgraded socket is the calling script's. let context = global.bun_vm().context_of_caller(); - - let duplex = duplex.to_js(); - // TODO: do better type checking - if duplex.is_empty_or_undefined_or_null() { - return Err(global.throw(format_args!("Expected a Duplex instance"))); - } - - let is_server = options.is_server; - // `DuplexServer` mode makes `TLSSocket.isServer()` report the server role - // for ALPN without claiming a listener parent — these handlers have none, - // so `mark_inactive` must take the client path. The TLS direction itself is - // controlled by DuplexUpgradeContext.mode. - let handlers = Handlers::from_generated( + let options = UpgradeOptions::from_js(global, options.to_js(), false)?; + // node:tls owns server-identity policy in its JS layer. + let tls = options.new_socket(true); + let tls_js_value = tls.get_this_value(global); + TLSSocket::data_set_cached(tls_js_value, global, options.data); + let server_verify = options.server_verify(); + // SAFETY: the engine takes over the ref `TLSSocket::new` returned. + let engine_ref = unsafe { RefPtr::from_raw(tls.as_ptr()) }; + let engine = UpgradedDuplex::upgrade( global, - &options.socket, - if is_server { - crate::socket::SocketMode::DuplexServer - } else { - crate::socket::SocketMode::Client - }, - )?; - // Nothing holds the callback cell until the TLS wrapper below does. - let _cell_root = handlers.root_cell(global); - - // Resolve the `SSL_CTX*`. Prefer a passed `SecureContext` (the memoised - // `tls.createSecureContext` path — what `[buntls]` now returns) so the - // duplex/named-pipe path shares one `SSL_CTX_new` with everyone else. - // node:net wraps `[buntls]`'s return as `opts.tls.secureContext`; userland - // may also pass it top-level. Same lookup as `upgradeTLS` above. - let mut owned_ctx: Option = None; - let sc_js: JSValue = 'blk: { - if let Some(v) = options.secure_context { - break 'blk v.to_js(); - } - if let Some(t) = options.tls { - let t = t.to_js(); - if t.is_object() { - if let Some(v) = t.get_truthy(global, "secureContext")? { - break 'blk v; - } - } - } - JSValue::ZERO - }; - if !sc_js.is_empty() { - let Some(sc) = sc_js.as_class_ref::() else { - return Err(global.throw_invalid_argument_type_value( - b"secureContext", - b"SecureContext", - sc_js, - )); - }; - owned_ctx = Some(sc.ctx.clone()); - } - - // Still parse SSLConfig for servername/ALPN (those live on the JS-side - // wrapper, not the SSL_CTX) and as the build source when no SecureContext. - let mut ssl_opts: Option = None; - // Drop frees ssl_opts on error. - if let Some(tls) = options.tls { - let tls = tls.to_js(); - if !tls.is_boolean() { - ssl_opts = SSLConfig::from_js(handlers.vm, global, tls, is_server)?; - } else if tls.to_boolean() { - ssl_opts = Some(crate::socket::tls_true_defaults(handlers.vm, is_server)); - } - } - if owned_ctx.is_none() && ssl_opts.is_none() { - return Err(global.throw(format_args!("Expected \"tls\" option"))); - } - let socket_config: Option<&SSLConfig> = ssl_opts.as_ref(); - - let default_data = if options.data.is_undefined() { - JSValue::ZERO - } else { - options.data.to_js() - }; - - let reject_unauthorized = upgrade_reject_policy( - handlers.vm, - socket_config, - is_server, - owned_ctx.as_ref().map(|c| c.as_ptr()), + context, + duplex.to_js(), + engine_ref, + tls_js_value, + options.ctx, + !options.is_server, + server_verify, ); - // Server-side peer-cert policy, applied per-SSL once the engine exists. - // A bare `secureContext` carries no parsed config, so read `requestCert` - // back off the ctx's verify mode the same way `upgrade_reject_policy` does - // for `rejectUnauthorized`. - let server_verify = crate::socket::upgraded_duplex::ServerVerify { - request_cert: match socket_config { - Some(cfg) => cfg.request_cert != 0, - None => server_ctx_requests_cert(owned_ctx.as_ref().map(|c| c.as_ptr())), - }, - reject_unauthorized, - }; - // Client duplex upgrades come from net.ts, whose JS layer owns - // server-identity policy; http2's server upgrade also lands here, where - // the deferral is meaningless. - let initial_flags = Flags::initial(reject_unauthorized) - | if is_server { - Flags::empty() - } else { - Flags::DEFERS_SERVER_IDENTITY - }; - let tls = TLSSocket::new(TLSSocket { - ref_count: bun_ptr::RefCount::init(), - handlers: JsCell::new(Some(handlers)), - socket: Cell::new(SocketHandler::::DETACHED), - owned_ssl_ctx: JsCell::new(None), - connection: JsCell::new(None), - local_binding: JsCell::new(None), - protos: JsCell::new( - socket_config.and_then(|cfg| cfg.protos_bytes().map(Box::<[u8]>::from)), - ), - server_name: JsCell::new( - socket_config.and_then(|cfg| cfg.server_name_bytes().map(Box::<[u8]>::from)), - ), - flags: Cell::new(initial_flags), - 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), - native_callback: JsCell::new(NativeCallbacks::None), - twin: JsCell::new(None), - verify_error: JsCell::new(None), - latest_session: Cell::new(None), - }); - let tls_ref = tls; - let tls_js_value = tls_ref.get_this_value(global); - TLSSocket::data_set_cached(tls_js_value, global, default_data); - - let has_owned_ctx = owned_ctx.is_some(); - - // `DuplexUpgradeContext` is self-referential: `task.ctx` and - // `upgrade.handlers.ctx` both point at the containing allocation, and - // `UpgradedDuplex` has fn-ptr-niched fields plus a `Drop` impl, so it - // cannot be value-constructed with a placeholder and assigned later - // (`=` would Drop the placeholder; `zeroed()` is an invalid value). - // Allocate uninit, leak to a raw pointer for the stable address, then - // field-write everything in place — `upgrade` last, once the address is - // known. - let duplex_context: *mut DuplexUpgradeContext = bun_core::heap::into_raw(Box::new( - core::mem::MaybeUninit::::uninit(), - )) - .cast(); - // SAFETY: fresh heap allocation; every field is `ptr::write`-initialized - // below before any read or `&mut DuplexUpgradeContext` is formed. - unsafe { - // The connection's ref. - ptr::addr_of_mut!((*duplex_context).ref_count).write(Cell::new(1)); - ptr::addr_of_mut!((*duplex_context).connection_ref).write(Cell::new(true)); - ptr::addr_of_mut!((*duplex_context).tls) - .write(JsCell::new(Some(RefPtr::from_raw(tls.as_ptr())))); - ptr::addr_of_mut!((*duplex_context).vm).write(VirtualMachine::get()); - ptr::addr_of_mut!((*duplex_context).task_event).write(Cell::new(EventState::StartTLS)); - ptr::addr_of_mut!((*duplex_context).queued).write(Cell::new(false)); - // When `owned_ctx` is set, `runEvent` builds from it and ignores - // `ssl_config` for SSL_CTX construction; servername/ALPN already - // copied onto `tls` above so the config's only remaining use is the - // legacy build path. - ptr::addr_of_mut!((*duplex_context).ssl_config).write(JsCell::new(if has_owned_ctx { - None - } else { - ssl_opts.take() - })); - ptr::addr_of_mut!((*duplex_context).owned_ctx).write(JsCell::new(owned_ctx)); - ptr::addr_of_mut!((*duplex_context).is_open).write(Cell::new(false)); - ptr::addr_of_mut!((*duplex_context).server_verify).write(server_verify); - ptr::addr_of_mut!((*duplex_context).abort_handle) - .write(bun_jsc::AbortHandle::for_owner::()); - ptr::addr_of_mut!((*duplex_context).context).write(context.id()); - ptr::addr_of_mut!((*duplex_context).mode).write(if is_server { - SocketMode::DuplexServer - } else { - SocketMode::Client - }); - ptr::addr_of_mut!((*duplex_context).upgrade).write(UpgradedDuplex::from( - global, - tls_js_value, - duplex, - UpgradedDuplexHandlers { - // SAFETY: `c` is `ctx` below — the live `DuplexUpgradeContext` heap allocation. - on_open: |c: *mut ()| { - DuplexUpgradeContext::on_open(bun_ptr::ThisPtr::new(c.cast())) - }, - // SAFETY: `c` is `ctx` below — the live `DuplexUpgradeContext` heap allocation. - on_data: |c: *mut (), d| { - DuplexUpgradeContext::on_data(bun_ptr::ThisPtr::new(c.cast()), d) - }, - // SAFETY: `c` is `ctx` below — the live `DuplexUpgradeContext` heap allocation. - on_handshake: |c: *mut (), ok, err| { - DuplexUpgradeContext::on_handshake(bun_ptr::ThisPtr::new(c.cast()), ok, err) - }, - // SAFETY: `c` is `ctx` below — the live `DuplexUpgradeContext` heap allocation. - on_close: |c: *mut ()| { - DuplexUpgradeContext::on_close(bun_ptr::ThisPtr::new(c.cast())) - }, - // SAFETY: `c` is `ctx` below — the live `DuplexUpgradeContext` heap allocation. - on_end: |c: *mut ()| DuplexUpgradeContext::on_end(bun_ptr::ThisPtr::new(c.cast())), - // SAFETY: `c` is `ctx` below — the live `DuplexUpgradeContext` heap allocation. - on_writable: |c: *mut ()| { - DuplexUpgradeContext::on_writable(bun_ptr::ThisPtr::new(c.cast())) - }, - // SAFETY: `c` is `ctx` below — the live `DuplexUpgradeContext` heap allocation. - on_error: |c: *mut (), e| { - DuplexUpgradeContext::on_error(bun_ptr::ThisPtr::new(c.cast()), e) - }, - // SAFETY: `c` is `ctx` below — the live `DuplexUpgradeContext` heap allocation. - on_timeout: |c: *mut ()| { - DuplexUpgradeContext::on_timeout(bun_ptr::ThisPtr::new(c.cast())) - }, - // SAFETY: `c` is `ctx` below — the live `DuplexUpgradeContext` heap allocation. - on_session: |c: *mut (), s| { - DuplexUpgradeContext::on_session(bun_ptr::ThisPtr::new(c.cast()), s) - }, - // SAFETY: `c` is `ctx` below — the live `DuplexUpgradeContext` heap allocation. - on_keylog: |c: *mut (), l| { - DuplexUpgradeContext::on_keylog(bun_ptr::ThisPtr::new(c.cast()), l) - }, - // SAFETY: `c` is `ctx` below — the live `DuplexUpgradeContext` heap allocation. - server_identity: |c: *mut (), ssl| { - DuplexUpgradeContext::server_identity(bun_ptr::ThisPtr::new(c.cast()), ssl) - }, - ctx: duplex_context.cast::<()>(), - }, - )); - } - DUPLEX_UPGRADE_CONTEXT_LIVE_COUNT.fetch_add(1, core::sync::atomic::Ordering::Relaxed); - // ssl_opts is moved into duplexContext.ssl_config when owned_ctx == null; - // otherwise it was only used for protos/server_name and is freed here. - // SAFETY: every field initialized above; nothing frees the context before this fn returns. - let duplex_context_ref = unsafe { bun_ptr::ThisPtr::new(duplex_context) }; - if duplex_context_ref.ssl_config.get().is_none() { - drop(ssl_opts.take()); - } - tls_ref.ref_(); - - tls_ref.socket.set(duplex_context_ref.duplex_socket()); - tls_ref.mark_active(); - // Unlike a real socket, a TLS engine over a JS stream has no I/O of its - // own to wait for - it is driven entirely by the stream's events - so it - // must not hold the event loop open. Node's TLSWrap over a JS stream - // behaves the same way: a script that leaves a duplexPair-backed TLS pair - // dangling still exits. If the underlying stream is a real socket, that - // socket's own handle keeps the loop alive. - - // SAFETY: non-null, fully initialised, heap-pinned; leaves its context in `deinit`. - unsafe { bun_jsc::AbortHandle::arm_owner(duplex_context, context) }; - DuplexUpgradeContext::start_tls(duplex_context_ref); 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 - array.put_index( - global, - 1, - duplex_context_ref.upgrade.get_js_handlers(global)?, - )?; - + array.put_index(global, 1, engine.get_js_handlers(global)?)?; Ok(array) } pub(crate) fn is_named_pipe_socket(socket: SocketHandle<'_>) -> bool { match socket { - SocketHandle::Tcp(this) => this.socket.get().is_named_pipe(), - SocketHandle::Tls(this) => this.socket.get().is_named_pipe(), + SocketHandle::Tcp(this) => this.socket.get().is_transport(), + SocketHandle::Tls(this) => this.socket.get().is_transport(), SocketHandle::Ignored(_) => false, } } -pub(crate) fn get_buffered_amount(socket: SocketHandle<'_>) -> usize { +pub(crate) fn get_bytes_read(socket: SocketHandle<'_>) -> f64 { match socket { - SocketHandle::Tcp(this) => this.buffered_data_for_node_net.get().len(), - SocketHandle::Tls(this) => this.buffered_data_for_node_net.get().len(), - SocketHandle::Ignored(_) => 0, + SocketHandle::Tcp(this) => this.total_bytes_read() as f64, + SocketHandle::Tls(this) => this.total_bytes_read() as f64, + SocketHandle::Ignored(_) => 0.0, } } @@ -4794,12 +3948,12 @@ pub(crate) mod testing_apis { cfg!(socket_fault_injection) } - /// Live `WindowsNamedPipeContext`s (one per connected or accepted socket + /// Live `WindowsNamedPipe`s (one per connected or accepted socket /// over a Windows named pipe); always 0 elsewhere. - pub(crate) fn named_pipe_context_live_count() -> usize { + pub(crate) fn named_pipe_live_count() -> usize { #[cfg(windows)] { - crate::socket::windows_named_pipe_context::LIVE_COUNT + crate::socket::windows_named_pipe::LIVE_COUNT .load(core::sync::atomic::Ordering::Relaxed) } #[cfg(not(windows))] @@ -4808,9 +3962,25 @@ pub(crate) mod testing_apis { } } - /// Live `DuplexUpgradeContext`s (one per TLS socket over a JS stream). - pub(crate) fn duplex_upgrade_context_live_count() -> usize { - DUPLEX_UPGRADE_CONTEXT_LIVE_COUNT.load(core::sync::atomic::Ordering::Relaxed) + /// Live `UpgradedDuplex`es (one per TLS socket over a JS stream). + pub(crate) fn upgraded_duplex_live_count() -> usize { + crate::socket::upgraded_duplex::LIVE_COUNT.load(core::sync::atomic::Ordering::Relaxed) + } + + /// `sweepSocketTimers` of `bun:internal-for-testing`. + pub(crate) fn sweep_socket_timers(cx: &mut JsScope<'_>, count: u16) -> JsResult<()> { + // SAFETY: `uws::Loop::get()` returns this thread's live loop. + let loop_ = unsafe { &*uws::Loop::get() }; + if loop_.internal_loop_data.is_iterating() { + return Err(cx.global().throw(format_args!( + "sweepSocketTimers() cannot be called from a socket timeout handler" + ))); + } + for _ in 0..count { + // SAFETY: no sweep is running on the loop. + unsafe { bun_uws_sys::loop_::us_internal_timer_sweep(loop_) }; + } + Ok(()) } pub(crate) fn clear_socket_faults(cx: &mut JsScope<'_>) -> JsResult<()> { @@ -4905,7 +4075,11 @@ pub(crate) mod testing_apis { } None => 0, Some(Errno::Code(code)) => code, - Some(Errno::Name(name)) => errno_by_name(name), + Some(Errno::Name(name)) => errno_by_name(name).ok_or_else(|| { + global.throw(format_args!( + "rule.errno: unknown errno name (use a numeric value or one of: ECONNRESET, EPIPE, ETIMEDOUT, ECONNREFUSED, EAGAIN, EWOULDBLOCK, EINTR, ENOBUFS, ENOMEM, EBADF, EINVAL, ENETUNREACH, EHOSTUNREACH, EPROTOTYPE)" + )) + })?, }; let clamp_bytes = rule.bytes.unwrap_or(0); @@ -4949,11 +4123,11 @@ pub(crate) mod testing_apis { } #[cfg(socket_fault_injection)] - fn errno_by_name(name: SocketFaultErrnoName) -> c_int { + fn errno_by_name(name: SocketFaultErrnoName) -> Option { use SocketFaultErrnoName as Name; #[cfg(unix)] { - match name { + Some(match name { Name::Econnreset => libc::ECONNRESET, Name::Epipe => libc::EPIPE, Name::Etimedout => libc::ETIMEDOUT, @@ -4968,11 +4142,13 @@ pub(crate) mod testing_apis { Name::Enetunreach => libc::ENETUNREACH, Name::Ehostunreach => libc::EHOSTUNREACH, Name::Eprototype => libc::EPROTOTYPE, - } + Name::Emfile => libc::EMFILE, + Name::Eperm => libc::EPERM, + }) } #[cfg(windows)] { - match name { + Some(match name { Name::Econnreset => 10054, Name::Epipe => 10054, Name::Etimedout => 10060, @@ -4987,7 +4163,8 @@ pub(crate) mod testing_apis { Name::Enetunreach => 10051, Name::Ehostunreach => 10065, Name::Eprototype => 10041, - } + Name::Emfile | Name::Eperm => return None, + }) } } } diff --git a/src/runtime/socket/tls_socket_functions.rs b/src/runtime/socket/tls_socket_functions.rs index 2de8a66c6b30..b0ce92247a9d 100644 --- a/src/runtime/socket/tls_socket_functions.rs +++ b/src/runtime/socket/tls_socket_functions.rs @@ -9,6 +9,7 @@ use bun_jsc::bun_string_jsc; use bun_jsc::{ self as jsc, EncodedSliceJsc as _, JSGlobalObject, JSValue, JsResult, StringJsc as _, }; +use bun_uws as uws; use crate::api::bun_x509 as X509; @@ -25,6 +26,7 @@ pub(super) mod ffi { // Re-export the one decl whose `*const c_char` NUL-terminated arg keeps a // genuine caller precondition; the rest are re-declared `safe fn` below. pub(crate) use super::boringssl::SSL_set_tlsext_host_name; + pub(crate) use bun_uws_sys::ssl::*; // Opaque handles missing from boringssl_sys. bun_opaque::opaque_ffi! { @@ -53,6 +55,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`, @@ -114,7 +118,6 @@ pub(super) mod ffi { // ── SSL_SESSION ─────────────────────────────────────────────────── pub(crate) safe fn SSL_get_session(ssl: &SSL) -> *mut SSL_SESSION; - pub(crate) fn SSL_SESSION_up_ref(session: *mut SSL_SESSION) -> c_int; // Both handles are opaque-ZST refs (`UnsafeCell` body); BoringSSL bumps // `session`'s refcount internally — no caller-side precondition. pub(crate) safe fn SSL_set_session(ssl: &SSL, session: &SSL_SESSION) -> c_int; @@ -177,7 +180,6 @@ pub(super) mod ffi { out_data: &mut *const u8, out_len: &mut c_uint, ); - pub(crate) safe fn SSL_get_ex_data(ssl: &SSL, idx: c_int) -> *mut c_void; /// Save/restore the per-loop BIO routing state around in-handshake JS /// callbacks (defined in usockets' openssl.c). The caller's snapshot /// array must have exactly `us_internal_ssl_loop_state_slots()` @@ -190,58 +192,12 @@ pub(super) mod ffi { ssl: &SSL, mode: super::boringssl::ssl_renegotiate_mode_t, ); - pub(crate) safe fn SSL_set_verify( - ssl: &SSL, - mode: c_int, - callback: super::boringssl::SSL_verify_cb, - ); pub(crate) safe fn SSL_is_server(ssl: &SSL) -> c_int; - // Opaque-ZST `&SSL` + opaque `*mut c_void` payload (BoringSSL stores - // it verbatim, never derefs) ⇒ no caller-side precondition. - pub(crate) safe fn SSL_set_ex_data(ssl: &SSL, idx: c_int, data: *mut c_void) -> c_int; // Returns the borrowed parent CTX (always non-null for a live `SSL*`). pub(crate) safe fn SSL_get_SSL_CTX(ssl: &SSL) -> *mut SSL_CTX; // 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( - 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, - ) -> core::ffi::c_int; - // 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. - pub(crate) safe fn SSL_CTX_set_alpn_select_cb( - ctx: &SSL_CTX, - cb: Option< - unsafe extern "C" fn( - ssl: *mut SSL, - out: *mut *const u8, - out_len: *mut u8, - in_: *const u8, - in_len: c_uint, - arg: *mut c_void, - ) -> c_int, - >, - arg: *mut c_void, - ); // Returns the borrowed cert store of a live `SSL_CTX*`. pub(crate) safe fn SSL_CTX_get_cert_store(ctx: &SSL_CTX) -> *mut X509_STORE; // The process-wide default root store; up-refs before returning, so @@ -270,12 +226,61 @@ pub(super) mod ffi { pub(crate) fn X509_check_issued(issuer: *mut X509, subject: *mut X509) -> c_int; } } -use super::TLSSocket; +use super::NewSocket; use crate::node::StringOrBuffer; -impl TLSSocket { - pub(crate) fn get_servername(&self, cx: &mut JsScope<'_>) -> JsResult { - let Some(ssl_ptr) = self.socket.get().ssl() else { +/// Declares each member as a call of its body in `impl Members`, which is compiled once, not once per implementor. +macro_rules! members { + ($(fn $name:ident(&self $(, $arg:ident: $ty:ty)*) $(-> $ret:ty)?;)*) => {$( + fn $name(&self $(, $arg: $ty)*) $(-> $ret)? + where + Self: Sized, + { + Members(self).$name($($arg),*) + } + )*}; +} + +/// The members of a native socket object that only use the connection's TLS state. `tlsHandle` in socket_body.bind.ts lists them. +pub(crate) trait TlsHandle { + /// Detached if the connection has closed or has no TLS. Ask again after anything that can run JS. + fn tls_socket(&self) -> uws::SocketTLS; + + members! { + fn get_servername(&self, cx: &mut JsScope<'_>) -> JsResult; + fn get_peer_x509_certificate(&self, cx: &mut JsScope<'_>) -> JsResult; + fn get_x509_certificate(&self, cx: &mut JsScope<'_>) -> JsResult; + fn get_tls_version(&self, cx: &mut JsScope<'_>) -> JsResult; + fn set_max_send_fragment(&self, cx: &mut JsScope<'_>, size: JsLocal<'_>) -> JsResult; + fn get_peer_certificate(&self, cx: &mut JsScope<'_>, abbreviated: JsLocal<'_>) -> JsResult; + fn get_certificate(&self, cx: &mut JsScope<'_>) -> JsResult; + fn get_tls_finished_message(&self, cx: &mut JsScope<'_>) -> JsResult; + fn get_shared_sigalgs(&self, cx: &mut JsScope<'_>) -> JsResult; + fn get_cipher(&self, cx: &mut JsScope<'_>) -> JsResult; + fn get_tls_peer_finished_message(&self, cx: &mut JsScope<'_>) -> JsResult; + fn set_key_cert(&self, cx: &mut JsScope<'_>, context: JsLocal<'_>) -> JsResult<()>; + fn export_keying_material(&self, cx: &mut JsScope<'_>, length: JsLocal<'_>, label: JsLocal<'_>, context: JsLocal<'_>) -> JsResult; + fn get_ephemeral_key_info(&self, cx: &mut JsScope<'_>) -> JsResult; + fn get_alpn_protocol(&self, cx: &mut JsScope<'_>) -> JsResult; + fn get_session(&self, cx: &mut JsScope<'_>) -> JsResult; + fn set_session(&self, cx: &mut JsScope<'_>, session: JsLocal<'_>) -> JsResult<()>; + fn get_tls_ticket(&self, cx: &mut JsScope<'_>) -> JsResult; + fn renegotiate(&self, cx: &mut JsScope<'_>) -> JsResult<()>; + fn disable_renegotiation(&self); + fn is_session_reused(&self) -> bool; + fn resume_sni(&self, cx: &mut JsScope<'_>, context: Option<&SecureContext>, is_error: bool); + } +} + +struct Members<'a>(&'a dyn TlsHandle); + +impl Members<'_> { + fn tls_socket(&self) -> uws::SocketTLS { + self.0.tls_socket() + } + + fn get_servername(&self, cx: &mut JsScope<'_>) -> JsResult { + let Some(ssl_ptr) = self.tls_socket().ssl() else { return Ok(JSValue::UNDEFINED); }; @@ -291,46 +296,8 @@ impl TLSSocket { bun_string_jsc::create_utf8_for_js(cx.global(), slice) } - pub(crate) fn set_servername(&self, cx: &mut JsScope<'_>, name: JsLocal<'_>) -> JsResult<()> { - let global = cx.global(); - let name = name.to_js(); - if self.is_server() { - return Err(global.throw(format_args!( - "Cannot issue SNI from a TLS server-side socket" - ))); - } - - if !name.is_string() { - return Err(global.throw(format_args!("Expected \"serverName\" to be a string"))); - } - - let slice: Box<[u8]> = name - .to_bun_string(global)? - .to_owned_slice() - .into_boxed_slice(); - // Drop replaces the old value. - self.server_name.set(Some(slice)); - - let host = self.server_name.get().as_deref().unwrap(); - if !host.is_empty() { - let Some(ssl_ptr) = self.socket.get().ssl() else { - return Ok(()); - }; - - if ffi::SSL_is_init_finished(boringssl::SSL::opaque_ref(ssl_ptr)) != 0 { - // match node.js exceptions - return Err(global.throw(format_args!("Already started."))); - } - let host_z = bun_core::ZBox::from_bytes(host); - // SAFETY: `host_z` is NUL-terminated; FFI reads until NUL. - unsafe { ffi::SSL_set_tlsext_host_name(ssl_ptr, host_z.as_ptr()) }; - } - - Ok(()) - } - - pub(crate) fn get_peer_x509_certificate(&self, cx: &mut JsScope<'_>) -> JsResult { - let Some(ssl_ptr) = self.socket.get().ssl() else { + fn get_peer_x509_certificate(&self, cx: &mut JsScope<'_>) -> JsResult { + let Some(ssl_ptr) = self.tls_socket().ssl() else { return Ok(JSValue::UNDEFINED); }; let cert = ffi::SSL_get_peer_certificate(boringssl::SSL::opaque_ref(ssl_ptr)); @@ -340,23 +307,25 @@ impl TLSSocket { Ok(JSValue::UNDEFINED) } - pub(crate) fn get_x509_certificate(&self, cx: &mut JsScope<'_>) -> JsResult { - let Some(ssl_ptr) = self.socket.get().ssl() else { + fn get_x509_certificate(&self, cx: &mut JsScope<'_>) -> JsResult { + let global = cx.global(); + let Some(ssl_ptr) = self.tls_socket().ssl() else { return Ok(JSValue::UNDEFINED); }; let cert = ffi::SSL_get_certificate(boringssl::SSL::opaque_ref(ssl_ptr)); if !cert.is_null() { // X509_up_ref bumps the refcount before handing to JS. ffi::X509_up_ref(boringssl::X509::opaque_ref(cert)); - return X509::to_js_object(boringssl::X509::opaque_mut(cert), cx.global()); + return X509::to_js_object(boringssl::X509::opaque_mut(cert), global); } Ok(JSValue::UNDEFINED) } - pub(crate) fn get_tls_version(&self, cx: &mut JsScope<'_>) -> JsResult { + fn get_tls_version(&self, cx: &mut JsScope<'_>) -> JsResult { + let global = cx.global(); jsc::mark_binding(); - let Some(ssl_ptr) = self.socket.get().ssl() else { + let Some(ssl_ptr) = self.tls_socket().ssl() else { return Ok(JSValue::NULL); }; let version = ffi::SSL_get_version(boringssl::SSL::opaque_ref(ssl_ptr)); @@ -368,18 +337,17 @@ impl TLSSocket { if slice.is_empty() { return Ok(JSValue::NULL); } - bun_string_jsc::create_utf8_for_js(cx.global(), slice) + bun_string_jsc::create_utf8_for_js(global, slice) } - pub(crate) fn set_max_send_fragment( - &self, - cx: &mut JsScope<'_>, - size: JsLocal<'_>, - ) -> JsResult { + fn set_max_send_fragment(&self, cx: &mut JsScope<'_>, size: JsLocal<'_>) -> JsResult { jsc::mark_binding(); let global = cx.global(); let size = size.to_js(); + let Some(ssl_ptr) = self.tls_socket().ssl() else { + return Ok(JSValue::FALSE); + }; if !size.is_number() { return Err(global.throw(format_args!("Expected size to be a number"))); } @@ -387,10 +355,6 @@ impl TLSSocket { if !(512..=16384).contains(&size) { return Ok(JSValue::FALSE); } - - let Some(ssl_ptr) = self.socket.get().ssl() else { - return Ok(JSValue::FALSE); - }; Ok(JSValue::from( ffi::SSL_set_max_send_fragment( boringssl::SSL::opaque_ref(ssl_ptr), @@ -399,7 +363,7 @@ impl TLSSocket { )) } - pub(crate) fn get_peer_certificate( + fn get_peer_certificate( &self, cx: &mut JsScope<'_>, abbreviated: JsLocal<'_>, @@ -408,6 +372,10 @@ impl TLSSocket { let global = cx.global(); let abbreviated = abbreviated.to_js(); + let Some(ssl_ptr) = self.tls_socket().ssl() else { + return Ok(JSValue::NULL); + }; + let abbreviated = if abbreviated.is_undefined() { true } else { @@ -416,10 +384,6 @@ impl TLSSocket { } abbreviated.to_boolean() }; - - let Some(ssl_ptr) = self.socket.get().ssl() else { - return Ok(JSValue::UNDEFINED); - }; let is_server_ssl = ffi::SSL_is_server(boringssl::SSL::opaque_ref(ssl_ptr)) != 0; if abbreviated { @@ -591,21 +555,22 @@ impl TLSSocket { Ok(first_obj) } - pub(crate) fn get_certificate(&self, cx: &mut JsScope<'_>) -> JsResult { - let Some(ssl_ptr) = self.socket.get().ssl() else { + fn get_certificate(&self, cx: &mut JsScope<'_>) -> JsResult { + let global = cx.global(); + let Some(ssl_ptr) = self.tls_socket().ssl() else { return Ok(JSValue::UNDEFINED); }; let cert = ffi::SSL_get_certificate(boringssl::SSL::opaque_ref(ssl_ptr)); if !cert.is_null() { - return X509::to_js(boringssl::X509::opaque_mut(cert), cx.global()); + return X509::to_js(boringssl::X509::opaque_mut(cert), global); } Ok(JSValue::UNDEFINED) } - pub(crate) fn get_tls_finished_message(&self, cx: &mut JsScope<'_>) -> JsResult { + fn get_tls_finished_message(&self, cx: &mut JsScope<'_>) -> JsResult { let global = cx.global(); - let Some(ssl_ptr) = self.socket.get().ssl() else { + let Some(ssl_ptr) = self.tls_socket().ssl() else { return Ok(JSValue::UNDEFINED); }; // We cannot just pass nullptr to SSL_get_finished() @@ -636,11 +601,11 @@ impl TLSSocket { Ok(buffer) } - pub(crate) fn get_shared_sigalgs(&self, cx: &mut JsScope<'_>) -> JsResult { + fn get_shared_sigalgs(&self, cx: &mut JsScope<'_>) -> JsResult { jsc::mark_binding(); let global = cx.global(); - let Some(ssl_ptr) = self.socket.get().ssl() else { + let Some(ssl_ptr) = self.tls_socket().ssl() else { return Ok(JSValue::NULL); }; @@ -737,9 +702,9 @@ impl TLSSocket { Ok(array) } - pub(crate) fn get_cipher(&self, cx: &mut JsScope<'_>) -> JsResult { + fn get_cipher(&self, cx: &mut JsScope<'_>) -> JsResult { let global = cx.global(); - let Some(ssl_ptr) = self.socket.get().ssl() else { + let Some(ssl_ptr) = self.tls_socket().ssl() else { return Ok(JSValue::UNDEFINED); }; let cipher = ffi::SSL_get_current_cipher(boringssl::SSL::opaque_ref(ssl_ptr)); @@ -795,9 +760,9 @@ impl TLSSocket { Ok(result) } - pub(crate) fn get_tls_peer_finished_message(&self, cx: &mut JsScope<'_>) -> JsResult { + fn get_tls_peer_finished_message(&self, cx: &mut JsScope<'_>) -> JsResult { let global = cx.global(); - let Some(ssl_ptr) = self.socket.get().ssl() else { + let Some(ssl_ptr) = self.tls_socket().ssl() else { return Ok(JSValue::UNDEFINED); }; // We cannot just pass nullptr to SSL_get_peer_finished() @@ -831,51 +796,43 @@ impl TLSSocket { /// `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). - pub(crate) fn set_key_cert(&self, cx: &mut JsScope<'_>, context: JsLocal<'_>) -> JsResult<()> { + /// A server keeps its SSL_CTX and only takes the identity and the CAs (`us_ssl_set_key_cert`). + fn set_key_cert(&self, cx: &mut JsScope<'_>, context: JsLocal<'_>) -> JsResult<()> { let global = cx.global(); - if self.socket.get().is_detached() { + if self.tls_socket().is_detached() { return Ok(()); } let Some(sc) = SecureContext::from_js(context.to_js()) else { return Err(global.throw(format_args!("setKeyCert requires a SecureContext"))); }; - let Some(ssl_ptr) = self.socket.get().ssl() else { + let Some(ssl_ptr) = self.tls_socket().ssl() else { return Ok(()); }; // 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"))); } } } Ok(()) } - pub(crate) fn export_keying_material( + fn export_keying_material( &self, cx: &mut JsScope<'_>, length: JsLocal<'_>, @@ -884,7 +841,7 @@ impl TLSSocket { ) -> JsResult { let global = cx.global(); let (length, label, context) = (length.to_js(), label.to_js(), context.to_js()); - if self.socket.get().is_detached() { + if self.tls_socket().ssl().is_none() { return Ok(JSValue::UNDEFINED); } @@ -927,7 +884,7 @@ impl TLSSocket { let buffer = JSValue::create_buffer_from_length(global, buffer_size)?; let buffer_ptr = buffer.as_array_buffer(global).unwrap().ptr; - let Some(ssl_ptr) = self.socket.get().ssl() else { + let Some(ssl_ptr) = self.tls_socket().ssl() else { return Ok(JSValue::UNDEFINED); }; @@ -954,9 +911,9 @@ impl TLSSocket { Ok(buffer) } - pub(crate) fn get_ephemeral_key_info(&self, cx: &mut JsScope<'_>) -> JsResult { + fn get_ephemeral_key_info(&self, cx: &mut JsScope<'_>) -> JsResult { let global = cx.global(); - let Some(ssl_ptr) = self.socket.get().ssl() else { + let Some(ssl_ptr) = self.tls_socket().ssl() else { return Ok(JSValue::NULL); }; let ssl = boringssl::SSL::opaque_ref(ssl_ptr); @@ -971,25 +928,29 @@ impl TLSSocket { { 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) } - pub(crate) fn get_alpn_protocol(&self, cx: &mut JsScope<'_>) -> JsResult { + fn get_alpn_protocol(&self, cx: &mut JsScope<'_>) -> JsResult { let global = cx.global(); let mut alpn_proto: *const u8 = core::ptr::null(); let mut alpn_proto_len: u32 = 0; - let Some(ssl_ptr) = self.socket.get().ssl() else { + let Some(ssl_ptr) = self.tls_socket().ssl() else { return Ok(JSValue::FALSE); }; @@ -1013,26 +974,12 @@ impl TLSSocket { bun_string_jsc::create_utf8_for_js(global, slice) } - /// The session Node's `getSession()`/`getTLSTicket()` read: the one most - /// recently delivered to the new-session callback (the only place BoringSSL - /// surfaces a TLS 1.3 NewSessionTicket), falling back to the SSL's own. - fn current_session(&self, ssl: &boringssl::SSL) -> *mut ffi::SSL_SESSION { - if let Some(latest) = self.latest_session.get() { - return latest.as_ptr().cast(); - } - let latest = self.socket.get().wrapper_latest_session(); - if !latest.is_null() { - return latest.cast(); - } - ffi::SSL_get_session(ssl) - } - - pub(crate) fn get_session(&self, cx: &mut JsScope<'_>) -> JsResult { + fn get_session(&self, cx: &mut JsScope<'_>) -> JsResult { let global = cx.global(); - let Some(ssl_ptr) = self.socket.get().ssl() else { + let Some(ssl_ptr) = self.tls_socket().ssl() else { return Ok(JSValue::UNDEFINED); }; - let session = self.current_session(boringssl::SSL::opaque_ref(ssl_ptr)); + let session = current_session(boringssl::SSL::opaque_ref(ssl_ptr)); if session.is_null() { return Ok(JSValue::UNDEFINED); } @@ -1052,15 +999,15 @@ impl TLSSocket { Ok(buffer) } - pub(crate) fn set_session(&self, cx: &mut JsScope<'_>, session: JsLocal<'_>) -> JsResult<()> { + fn set_session(&self, cx: &mut JsScope<'_>, session: JsLocal<'_>) -> JsResult<()> { let global = cx.global(); - if self.socket.get().is_detached() { + if self.tls_socket().is_detached() { return Ok(()); } if let Some(sb) = StringOrBuffer::from_js(global, session.to_js())? { let session_slice = sb.slice(); - let Some(ssl_ptr) = self.socket.get().ssl() else { + let Some(ssl_ptr) = self.tls_socket().ssl() else { return Ok(()); }; let ssl = boringssl::SSL::opaque_ref(ssl_ptr); @@ -1091,11 +1038,11 @@ impl TLSSocket { } } - pub(crate) fn get_tls_ticket(&self, cx: &mut JsScope<'_>) -> JsResult { - let Some(ssl_ptr) = self.socket.get().ssl() else { + fn get_tls_ticket(&self, cx: &mut JsScope<'_>) -> JsResult { + let Some(ssl_ptr) = self.tls_socket().ssl() else { return Ok(JSValue::UNDEFINED); }; - let session = self.current_session(boringssl::SSL::opaque_ref(ssl_ptr)); + let session = current_session(boringssl::SSL::opaque_ref(ssl_ptr)); if session.is_null() { return Ok(JSValue::UNDEFINED); } @@ -1117,9 +1064,9 @@ impl TLSSocket { jsc::ArrayBuffer::create_buffer(cx.global(), slice) } - pub(crate) fn renegotiate(&self, cx: &mut JsScope<'_>) -> JsResult<()> { + fn renegotiate(&self, cx: &mut JsScope<'_>) -> JsResult<()> { let global = cx.global(); - let Some(ssl_ptr) = self.socket.get().ssl() else { + let Some(ssl_ptr) = self.tls_socket().ssl() else { return Ok(()); }; boringssl::ERR_clear_error(); @@ -1129,8 +1076,8 @@ impl TLSSocket { Ok(()) } - pub(crate) fn disable_renegotiation(&self) { - let Some(ssl_ptr) = self.socket.get().ssl() else { + fn disable_renegotiation(&self) { + let Some(ssl_ptr) = self.tls_socket().ssl() else { return; }; ffi::SSL_set_renegotiate_mode( @@ -1139,13 +1086,85 @@ impl TLSSocket { ); } - pub(crate) fn is_session_reused(&self) -> bool { - let Some(ssl_ptr) = self.socket.get().ssl() else { + fn is_session_reused(&self) -> bool { + let Some(ssl_ptr) = self.tls_socket().ssl() else { return false; }; ffi::SSL_session_reused(boringssl::SSL::opaque_ref(ssl_ptr)) == 1 } + /// Resumes the handshake that an asynchronous SNICallback suspended. `None` keeps the listener's context. + fn resume_sni(&self, _: &mut JsScope<'_>, context: Option<&SecureContext>, is_error: bool) { + let ctx = match context { + Some(context) if !is_error => context.ctx.clone().into_raw(), + _ => core::ptr::null_mut(), + }; + self.tls_socket().sni_resolve(ctx, is_error); + } +} + +/// What `getSession()` and `getTLSTicket()` read: the newest delivered session (a TLS 1.3 client gets its tickets only there), or else the SSL's own. +fn current_session(ssl: &boringssl::SSL) -> *mut ffi::SSL_SESSION { + let latest = ffi::us_ssl_latest_session(ssl).cast::(); + if !latest.is_null() { + return latest; + } + ffi::SSL_get_session(ssl) +} + +impl TlsHandle for NewSocket { + fn tls_socket(&self) -> uws::SocketTLS { + if SSL { + uws::SocketTLS::from_any(self.socket.get().socket) + } else { + uws::SocketTLS::DETACHED + } + } +} + +/// The TLS members that also change the socket object's own state. +impl NewSocket { + pub(crate) fn set_servername(&self, cx: &mut JsScope<'_>, name: JsLocal<'_>) -> JsResult<()> { + if !SSL { + return Ok(()); + } + let global = cx.global(); + let name = name.to_js(); + if self.is_server() { + return Err(global.throw(format_args!( + "Cannot issue SNI from a TLS server-side socket" + ))); + } + + if !name.is_string() { + return Err(global.throw(format_args!("Expected \"serverName\" to be a string"))); + } + + let slice: Box<[u8]> = name + .to_bun_string(global)? + .to_owned_slice() + .into_boxed_slice(); + // Drop replaces the old value. + self.server_name.set(Some(slice)); + + let host = self.server_name.get().as_deref().unwrap(); + if !host.is_empty() { + let Some(ssl_ptr) = self.socket.get().ssl() else { + return Ok(()); + }; + + if ffi::SSL_is_init_finished(boringssl::SSL::opaque_ref(ssl_ptr)) != 0 { + // match node.js exceptions + return Err(global.throw(format_args!("Already started."))); + } + let host_z = bun_core::ZBox::from_bytes(host); + // SAFETY: `host_z` is NUL-terminated; FFI reads until NUL. + unsafe { ffi::SSL_set_tlsext_host_name(ssl_ptr, host_z.as_ptr()) }; + } + + Ok(()) + } + pub(crate) fn set_verify_mode( &self, cx: &mut JsScope<'_>, @@ -1154,7 +1173,7 @@ impl TLSSocket { ) -> JsResult<()> { let request_cert = request_cert.to_js(); let reject_unauthorized = reject_unauthorized.to_js(); - if self.socket.get().is_detached() { + if !SSL || self.socket.get().is_detached() { return Ok(()); } @@ -1167,15 +1186,6 @@ impl TLSSocket { let request_cert = request_cert.to_boolean(); let reject_unauthorized = reject_unauthorized.to_boolean(); let acts_as_server = self.acts_as_tls_server(); - let mut verify_mode: c_int = boringssl::SSL_VERIFY_NONE; - if acts_as_server { - if request_cert { - verify_mode = boringssl::SSL_VERIFY_PEER; - if reject_unauthorized { - verify_mode |= boringssl::SSL_VERIFY_FAIL_IF_NO_PEER_CERT; - } - } - } // Keep the enforcement flag in sync with the verify mode this call installs. self.update_flags(|f| { f.set( @@ -1192,7 +1202,11 @@ impl TLSSocket { self.socket.get().set_inline_reject(); } else { // we always allow and check the SSL certificate after the handshake or renegotiation - ffi::SSL_set_verify(ssl, verify_mode, Some(always_allow_ssl_verify_callback)); + ffi::us_ssl_set_verify_mode( + ssl, + c_int::from(acts_as_server && request_cert), + c_int::from(reject_unauthorized), + ); } Ok(()) } @@ -1208,13 +1222,6 @@ impl Drop for OwnedSession { } } -extern "C" fn always_allow_ssl_verify_callback( - _preverify_ok: c_int, - _ctx: *mut boringssl::X509_STORE_CTX, -) -> c_int { - 1 -} - #[cold] #[inline(never)] fn get_ssl_exception(global: &JSGlobalObject, default_message: &[u8]) -> JSValue { diff --git a/src/runtime/socket/udp_socket.bind.ts b/src/runtime/socket/udp_socket.bind.ts index 9cbffc8bf1a2..d6a9081749d3 100644 --- a/src/runtime/socket/udp_socket.bind.ts +++ b/src/runtime/socket/udp_socket.bind.ts @@ -3,7 +3,6 @@ import * as table from "../../codegen/builtins/lib.ts"; export default b.module({ rustPath: "crate::socket::udp_socket" }); -export const dgramIsFdAdopted = b.fn({ args: { fd: b.i32 }, returns: b.bool }); export const dgramNewSocketFd = b.fn({ args: { isIPv6: b.bool, isStream: b.bool }, returns: b.i32, throws: true }); export const dgramAdoptFd = b.fn({ args: { fd: b.i32 }, throws: true }); export const dgramBindFd = b.fn({ diff --git a/src/runtime/socket/udp_socket.rs b/src/runtime/socket/udp_socket.rs index f00cecde0897..7c47f3de3f6d 100644 --- a/src/runtime/socket/udp_socket.rs +++ b/src/runtime/socket/udp_socket.rs @@ -88,12 +88,6 @@ extern "C" fn on_close(socket: *mut uws::udp::Socket) { this.poll_ref.with_mut(|p| p.disable()); this.this_value.with_mut(|r| r.downgrade()); this.socket.set(None); - // The descriptor dies with the socket: prune the registry entry so a - // recycled fd number isn't refused (or worse, accepted) later. - #[cfg(not(windows))] - if let Some(fd) = this.registered_fd.take() { - dgram_remove_fd(fd, DgramFdState::Adopted); - } } extern "C" fn on_recv_error(socket: *mut uws::udp::Socket, errno: c_int, is_errqueue: c_int) { @@ -151,6 +145,25 @@ extern "C" fn on_drain(socket: *mut uws::udp::Socket) { event_loop.exit(); } +/// `address%scope`. The scope is the interface's name where the platform has one for the index. +fn scoped_address(address: &[u8], scope_id: u32) -> BunString { + #[cfg(not(windows))] + { + let mut buffer = [0u8; IF_NAMESIZE + 1]; + // SAFETY: buffer is IF_NAMESIZE+1 bytes, NUL-terminated by zero-init. + if !unsafe { if_indextoname(scope_id, buffer.as_mut_ptr().cast::()) }.is_null() { + // SAFETY: if_indextoname wrote a NUL-terminated string. + let name = unsafe { bun_core::ffi::cstr(buffer.as_ptr().cast::()) }.to_bytes(); + return BunString::create_format(format_args!( + "{}%{}", + bstr::BStr::new(address), + bstr::BStr::new(name) + )); + } + } + BunString::create_format(format_args!("{}%{}", bstr::BStr::new(address), scope_id)) +} + extern "C" fn on_data( socket: *mut uws::udp::Socket, buf: *mut uws::udp::PacketBuffer, @@ -235,31 +248,9 @@ extern "C" fn on_data( let slice = unsafe { (*buf).get_payload(i) }; let span = hostname.unwrap(); - #[allow(unused_labels)] - let hostname_string = if let Some(id) = scope_id { - 'blk: { - #[cfg(not(windows))] - { - let mut buffer = [0u8; IF_NAMESIZE + 1]; - // SAFETY: buffer is IF_NAMESIZE+1 bytes, NUL-terminated by zero-init. - if !unsafe { if_indextoname(id, buffer.as_mut_ptr().cast::()) } - .is_null() - { - // SAFETY: if_indextoname wrote a NUL-terminated string. - let name = unsafe { bun_core::ffi::cstr(buffer.as_ptr().cast::()) } - .to_bytes(); - break 'blk BunString::create_format(format_args!( - "{}%{}", - bstr::BStr::new(span), - bstr::BStr::new(name) - )); - } - } - - BunString::create_format(format_args!("{}%{}", bstr::BStr::new(span), id)) - } - } else { - BunString::clone_utf8(span) + let hostname_string = match scope_id { + Some(id) => scoped_address(span, id), + None => BunString::clone_utf8(span), }; let loop_ = VirtualMachine::get().event_loop_mut(); @@ -525,11 +516,6 @@ pub(crate) struct UDPSocket { /// if marked as closed the socket pointer may be stale pub closed: Cell, connect_info: Cell>, - /// This socket's descriptor in the raw-fd registry (adopted or created), - /// tracked on the socket so close can prune the entry even if `reload()` - /// replaces the config. POSIX-only, like the registry itself. - #[cfg(not(windows))] - registered_fd: Cell>, /// Armed while open: the socket closes with the context that opened it. abort_handle: bun_jsc::AbortHandle, } @@ -585,8 +571,6 @@ impl UDPSocket { poll_ref: JsCell::new(KeepAlive::init()), closed: Cell::new(false), connect_info: Cell::new(None), - #[cfg(not(windows))] - registered_fd: Cell::new(None), abort_handle: bun_jsc::AbortHandle::for_owner::(), } .into_js_wrapper(cx.global()); @@ -641,25 +625,9 @@ impl UDPSocket { } } - // Reserve an adopted descriptor before creating the socket so a - // concurrent adoption of the same number fails with EEXIST (like - // libuv's uv_udp_open) instead of double-owning it. + // A wrap hands its descriptor over to the socket that adopts it. #[cfg(not(windows))] - let reservation = match config.fd { - Some(fd) => match dgram_begin_adoption(fd) { - Some(previous) => Some((fd, previous)), - None => { - return Err(cx.global().throw_value( - bun_sys::Error::from_code_int( - SystemErrno::EEXIST as c_int, - bun_sys::Tag::open, - ) - .to_js(cx.global()), - )); - } - }, - None => None, - }; + let from_wrap = config.fd.filter(|fd| dgram_take_fd(*fd)); let created = if let Some(fd) = config.fd { uws::udp::Socket::create_from_fd( @@ -682,7 +650,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::(), ) @@ -703,8 +671,8 @@ impl UDPSocket { if this.socket.get().is_none() { // The caller keeps the descriptor when adoption fails. #[cfg(not(windows))] - if let Some((fd, previous)) = reservation { - dgram_rollback_adoption(fd, previous); + if let Some(fd) = from_wrap { + dgram_register_fd(fd); } this.closed.set(true); if err != 0 { @@ -725,7 +693,7 @@ impl UDPSocket { )) }; let sys_err = SystemError { - errno: err, + errno: if is_fd { -err } else { err }, code: BunString::static_(code), message, syscall: BunString::static_(syscall), @@ -742,19 +710,6 @@ impl UDPSocket { return Err(cx.global().throw(format_args!("Failed to bind socket"))); } - // Register this socket's live descriptor (adopted or freshly created) - // so a later adoption of the same number fails with EEXIST; a wrap - // that carried it here may still read its address, but must not close - // it out from under the socket. - #[cfg(not(windows))] - { - let fd = uws::udp::Socket::opaque_mut(created).fd(); - if reservation.is_none() { - dgram_mark_fd_adopted(fd); - } - this.registered_fd.set(Some(fd)); - } - if let Some(connect) = &this.config.get().connect { let address_z = connect.address.to_owned_slice_z(); // `Socket` is an `opaque_ffi!` ZST — `opaque_mut` is the safe deref. @@ -1527,12 +1482,6 @@ impl UDPSocket { // shared borrow is sound; the (idempotent) downgrade is hoisted // because `on_close` repeats it. self.this_value.with_mut(|r| r.downgrade()); - // Unregister before close(2) so no other thread can observe a - // stale entry for the recycled number (on_close is the backstop). - #[cfg(not(windows))] - if let Some(fd) = self.registered_fd.take() { - dgram_remove_fd(fd, DgramFdState::Adopted); - } // `Socket` is an `opaque_ffi!` ZST — `opaque_mut` is the safe deref. uws::udp::Socket::opaque_mut(socket).close(); } @@ -1672,10 +1621,6 @@ impl Drop for UDPSocket { // At VM shutdown the wrapper can die with the socket open: close it so that `drain_closed_sockets()` frees it. if let Some(socket) = this.socket.take() { this.closed.set(true); - #[cfg(not(windows))] - if let Some(fd) = this.registered_fd.take() { - dgram_remove_fd(fd, DgramFdState::Adopted); - } uws::udp::Socket::opaque_mut(socket).close(); } this.poll_ref.with_mut(|p| p.disable()); @@ -1775,112 +1720,23 @@ fn bad_fd() -> bun_sys::Error { // type check, and cluster-shared sockets. POSIX-only; Windows reports ENOTSUP, // matching Node's cluster behavior for shared dgram handles. -/// Whether the raw helpers may close a registered descriptor. `Owned` means -/// the wrap layer created or received it; `Adopted` means a live `UDPSocket` -/// owns it now (read-only for the helpers, pruned when that socket closes). -#[cfg(not(windows))] -#[derive(Clone, Copy, PartialEq, Eq)] -enum DgramFdState { - Owned, - Adopted, -} - -/// One registry with atomic state transitions so builtin JS can never close a -/// descriptor this module does not own. A process-wide mutex because worker -/// threads share the descriptor space; the set stays tiny so a Vec is enough. -#[cfg(not(windows))] -static DGRAM_FDS: bun_threading::Guarded> = - bun_threading::Guarded::new(Vec::new()); - -#[cfg(not(windows))] -fn dgram_fd_state(fd: c_int) -> Option { - DGRAM_FDS - .lock() - .iter() - .find(|(known_fd, _)| *known_fd == fd) - .map(|(_, state)| *state) -} - -/// Registers a descriptor the wrap layer owns; no-op if already tracked (an -/// adopted descriptor stays read-only). -#[cfg(not(windows))] -fn dgram_register_owned_fd(fd: c_int) { - let mut fds = DGRAM_FDS.lock(); - if !fds.iter().any(|(known_fd, _)| *known_fd == fd) { - fds.push((fd, DgramFdState::Owned)); - } -} - -/// A live `UDPSocket` owns `fd`: no longer closable through the raw helpers, -/// but still known for read-only ones. Any prior entry for a number the -/// kernel just handed out is stale by construction and is overwritten. -#[cfg(not(windows))] -fn dgram_mark_fd_adopted(fd: c_int) { - let mut fds = DGRAM_FDS.lock(); - match fds.iter_mut().find(|(known_fd, _)| *known_fd == fd) { - Some(entry) => entry.1 = DgramFdState::Adopted, - None => fds.push((fd, DgramFdState::Adopted)), - } -} - -/// Atomically reserves `fd` for adoption unless a live socket already owns -/// it, returning the previous state for `dgram_rollback_adoption`; `None` -/// means the descriptor is already adopted. -#[cfg(not(windows))] -fn dgram_begin_adoption(fd: c_int) -> Option> { - let mut fds = DGRAM_FDS.lock(); - match fds.iter_mut().find(|(known_fd, _)| *known_fd == fd) { - Some(entry) => match entry.1 { - DgramFdState::Adopted => None, - DgramFdState::Owned => { - entry.1 = DgramFdState::Adopted; - Some(Some(DgramFdState::Owned)) - } - }, - None => { - fds.push((fd, DgramFdState::Adopted)); - Some(None) - } - } -} - -/// Restores the registry entry recorded by `dgram_begin_adoption` after a -/// failed socket create. +/// The raw descriptors the wrap layer created or received, so builtin JS can never bind or close any other. Process-wide: threads share the descriptor space. #[cfg(not(windows))] -fn dgram_rollback_adoption(fd: c_int, previous: Option) { - let mut fds = DGRAM_FDS.lock(); - let index = fds.iter().position(|(known_fd, _)| *known_fd == fd); - match (index, previous) { - (Some(index), Some(state)) => fds[index].1 = state, - (Some(index), None) => { - fds.swap_remove(index); - } - (None, Some(state)) => fds.push((fd, state)), - (None, None) => {} - } -} +static DGRAM_FDS: bun_threading::Guarded> = bun_threading::Guarded::new(Vec::new()); -/// Registers a descriptor `socket(2)` just returned to the wrap layer: any -/// existing entry for that number is stale by construction. #[cfg(not(windows))] -fn dgram_register_created_fd(fd: c_int) { +fn dgram_register_fd(fd: c_int) { let mut fds = DGRAM_FDS.lock(); - match fds.iter_mut().find(|(known_fd, _)| *known_fd == fd) { - Some(entry) => entry.1 = DgramFdState::Owned, - None => fds.push((fd, DgramFdState::Owned)), + if !fds.contains(&fd) { + fds.push(fd); } } -/// Removes `fd` from the registry if it is in `state`, returning whether it -/// was: a removed `Owned` descriptor becomes closable by the caller, a -/// removed `Adopted` one is just forgotten when its owning socket closes. +/// Removes `fd` from the registry. Returns true if it was there, and the caller owns it now. #[cfg(not(windows))] -fn dgram_remove_fd(fd: c_int, state: DgramFdState) -> bool { +fn dgram_take_fd(fd: c_int) -> bool { let mut fds = DGRAM_FDS.lock(); - match fds - .iter() - .position(|(known_fd, known_state)| *known_fd == fd && *known_state == state) - { + match fds.iter().position(|known_fd| *known_fd == fd) { Some(index) => { fds.swap_remove(index); true @@ -1891,20 +1747,7 @@ fn dgram_remove_fd(fd: c_int, state: DgramFdState) -> bool { #[cfg(not(windows))] fn dgram_owned_fd(fd: c_int) -> bun_sys::Result { - if fd < 0 || dgram_fd_state(fd) != Some(DgramFdState::Owned) { - return Err(bun_sys::Error::from_code_int( - SystemErrno::EBADF as c_int, - bun_sys::Tag::open, - )); - } - Ok(fd) -} - -/// Like `dgram_owned_fd`, but also accepts descriptors a `UDPSocket` has -/// adopted — a wrap that shared its fd may still ask for its address. -#[cfg(not(windows))] -fn dgram_known_fd(fd: c_int) -> bun_sys::Result { - if fd < 0 || dgram_fd_state(fd).is_none() { + if !DGRAM_FDS.lock().contains(&fd) { return Err(bun_sys::Error::from_code_int( SystemErrno::EBADF as c_int, bun_sys::Tag::open, @@ -1918,23 +1761,6 @@ fn dgram_not_supported() -> bun_sys::Error { bun_sys::Error::from_code_int(SystemErrno::ENOTSUP as c_int, bun_sys::Tag::open) } -/// `(fd)` → whether a live `UDPSocket` already owns this descriptor. Backs -/// node:dgram's synchronous EEXIST check on `bind({ fd })` so it does not have -/// to keep a second, per-VM copy of DGRAM_FDS. -pub(crate) fn dgram_is_fd_adopted(fd: i32) -> bool { - #[cfg(not(windows))] - { - fd >= 0 && dgram_fd_state(fd) == Some(DgramFdState::Adopted) - } - #[cfg(windows)] - { - // The registry is POSIX-only; on Windows the async adoption path - // reports EEXIST itself and Node skips the sync-throw test. - let _ = fd; - false - } -} - /// `(isIPv6, isStream)` → a fresh unbound SOCK_DGRAM (or SOCK_STREAM) /// descriptor (CLOEXEC + non-blocking + SO_NOSIGPIPE), created through /// bsd_create_socket so this doesn't fork its platform gate/EINTR loop. @@ -1957,8 +1783,10 @@ pub(crate) fn dgram_new_socket_fd(is_ipv6: bool, is_stream: bool) -> bun_sys::Re if fd < 0 { return Err(bun_sys::Error::from_code_int(err, bun_sys::Tag::open)); } + // No socket owns a raw descriptor, so one can adopt it later. + uws::udp::raw::bsd_disown_socket(fd); - dgram_register_created_fd(fd); + dgram_register_fd(fd); Ok(fd) } #[cfg(windows)] @@ -1975,15 +1803,15 @@ pub(crate) fn dgram_new_socket_fd(is_ipv6: bool, is_stream: bool) -> bun_sys::Re pub(crate) fn dgram_adopt_fd(fd: i32) -> bun_sys::Result<()> { #[cfg(not(windows))] { - if fd < 0 { + if uws::udp::raw::bsd_adopt_socket(fd, libc::SOCK_DGRAM) != 0 { return Err(bun_sys::Error::from_code_int( - SystemErrno::EBADF as c_int, + bun_sys::last_errno(), bun_sys::Tag::open, )); } - // No-op for tracked descriptors: an adopted fd stays read-only rather - // than becoming closable again through a second wrap. - dgram_register_owned_fd(fd); + // An idle wrap isn't the owner: the socket that adopts its descriptor is. + uws::udp::raw::bsd_disown_socket(fd); + dgram_register_fd(fd); // libuv's uv_udp_open unconditionally sets SO_REUSEADDR (kept for // backwards compat, libuv#4551); best-effort like it is there. let _ = uws::udp::raw::bsd_set_reuseaddr(fd); @@ -2053,7 +1881,6 @@ pub(crate) fn dgram_get_sock_name_fd<'a>( ) -> bindgen::Result<'a, JSValue> { #[cfg(not(windows))] { - let fd = dgram_known_fd(fd).map_err(|err| err.into_error(&cx.heap))?; let mut storage: sockaddr_storage = bun_core::ffi::zeroed(); let mut len = size_of::() as libc::socklen_t; // SAFETY: getsockname(2) writes at most `len` bytes into storage. @@ -2195,9 +2022,7 @@ pub(crate) fn dgram_listen_fd(fd: i32) -> bun_sys::Result<()> { pub(crate) fn dgram_close_fd(fd: i32) -> bun_sys::Result<()> { #[cfg(not(windows))] { - // Adopted descriptors belong to a live UDPSocket (its close prunes - // them); only owned ones are closed here. - if dgram_remove_fd(fd, DgramFdState::Owned) { + if dgram_take_fd(fd) { uws::udp::raw::bsd_close_socket(fd); } Ok(()) diff --git a/src/runtime/socket/uws_dispatch.rs b/src/runtime/socket/uws_dispatch.rs index 154e0c6bb85d..46174ba420c0 100644 --- a/src/runtime/socket/uws_dispatch.rs +++ b/src/runtime/socket/uws_dispatch.rs @@ -183,8 +183,6 @@ us_dispatch_shims! { = vt.on_close(s, code, reason) or s; fn us_dispatch_timeout(s: *mut us_socket_t) -> *mut us_socket_t = vt.on_timeout(s) or s; - fn us_dispatch_long_timeout(s: *mut us_socket_t) -> *mut us_socket_t - = vt.on_long_timeout(s) or s; fn us_dispatch_end(s: *mut us_socket_t) -> *mut us_socket_t = vt.on_end(s) or s; fn us_dispatch_connect_error(s: *mut us_socket_t, code: c_int) -> *mut us_socket_t @@ -242,38 +240,41 @@ unsafe extern "C" fn us_dispatch_ssl_raw_tap( s } -/// A new (resumable) TLS session is ready. BoringSSL's new-session callback -/// parks the serialized session while `SSL_read`/`SSL_do_handshake` runs; -/// `ssl_flush_pending_session()` dispatches it here once that stack has -/// unwound. Mirrors Node's `NewSessionCallback` → `onnewsession` flow. Only -/// `bun_socket_tls` sockets reach this. +/// A session or a keylog line that BoringSSL delivered inside `SSL_read` / `SSL_do_handshake`, for either TLS engine. Returns 0 once the socket has closed. /// /// # Safety -/// `openssl.c` must pass a live, non-null `s` whose ext slot holds a valid -/// `*mut TLSSocket`, and `data` must point to `len` readable bytes. +/// `bun_socket` is the live `TLSSocket` its `on_open` gave to `us_ssl_set_bun_socket`, and `data` points to `len` readable bytes. #[unsafe(no_mangle)] -pub(crate) unsafe extern "C" fn us_dispatch_session( - s: *mut us_socket_t, +pub(crate) unsafe extern "C" fn us_dispatch_tls_event( + bun_socket: *mut c_void, + is_keylog: c_int, data: *const u8, - len: c_int, -) { - let s_ref = us_socket_t::opaque_mut(s); - if s_ref.kind() != SocketKind::BunSocketTls { - return; - } + len: core::ffi::c_uint, +) -> c_int { type TLSSocket = super::NewSocket; - let Some(tls) = *s_ref.ext::>>() else { - return; - }; - // A negative length from the C side means there is nothing to deliver; - // never panic across the `extern "C"` boundary. - let Ok(len) = usize::try_from(len) else { - return; - }; - // SAFETY: `data` points to `len` readable bytes owned by the caller for the - // duration of this call. - let slice = unsafe { core::slice::from_raw_parts(data, len) }; - crate::dispatch::fold(TLSSocket::on_session(tls, slice)); + // SAFETY: fn contract. + let tls = unsafe { bun_ptr::ThisPtr::new(bun_socket.cast::()) }; + // The handler can close the socket, which drops the connection's ref. + let _guard = bun_ptr::RefPtr::from_this(tls); + // SAFETY: fn contract. + let slice = unsafe { core::slice::from_raw_parts(data, len as usize) }; + crate::dispatch::fold(if is_keylog != 0 { + TLSSocket::on_keylog(tls, slice) + } else { + TLSSocket::on_session(tls, slice) + }); + c_int::from(!tls.socket.get().is_detached()) +} + +/// The name check of the verify step for a Bun socket, over either TLS engine: a `US_IDENTITY_*` verdict. +#[unsafe(no_mangle)] +pub(crate) extern "C" fn us_dispatch_bun_socket_identity( + bun_socket: *mut c_void, + ssl: *mut bun_boringssl_sys::SSL, +) -> c_int { + // SAFETY: openssl.c passes the live `TLSSocket` whose `on_open` called `us_ssl_set_bun_socket`. + let tls = unsafe { &*bun_socket.cast::>() }; + tls.server_identity(bun_boringssl_sys::SSL::opaque_mut(ssl)) as c_int } /// The name check of the verify step, routed to the owner of the socket in the handshake: a `US_IDENTITY_*` verdict. @@ -287,9 +288,6 @@ pub(crate) extern "C" fn us_dispatch_server_identity( let s_ref = us_socket_t::opaque_mut(s); let ssl = bun_boringssl_sys::SSL::opaque_mut(ssl); let verdict = match s_ref.kind() { - SocketKind::BunSocketTls => s_ref - .ext::>>>() - .map_or(Unchecked, |tls| tls.server_identity(ssl)), SocketKind::HttpClientTls => s_ref .ext::>>() .map_or(Unchecked, |ext| { @@ -316,58 +314,3 @@ pub(crate) extern "C" fn us_dispatch_server_identity( }; verdict as c_int } - -/// BoringSSL's new-session callback, routed to the owner of the socket in `SSL_read`: 1 asks for `us_dispatch_session`. -#[unsafe(no_mangle)] -pub(crate) extern "C" fn us_dispatch_new_session( - s: *mut us_socket_t, - session: *mut bun_boringssl_sys::SSL_SESSION, -) -> c_int { - let s_ref = us_socket_t::opaque_mut(s); - match s_ref.kind() { - SocketKind::HttpClientTls => { - bun_http::session_cache::on_new_session(s_ref, session); - 0 - } - SocketKind::BunSocketTls => { - type TLSSocket = super::NewSocket; - let Some(tls) = *s_ref.ext::>>() else { - return 0; - }; - tls.set_latest_session(session); - 1 - } - _ => 0, - } -} - -/// Hands an NSS key-log line parked by the keylog callback to the JS -/// `keylog` handler. -/// -/// # Safety -/// `openssl.c` must pass a live, non-null `s` whose ext slot holds a valid -/// `*mut TLSSocket`, and `data` must point to `len` readable bytes. -#[unsafe(no_mangle)] -pub(crate) unsafe extern "C" fn us_dispatch_keylog( - s: *mut us_socket_t, - data: *const u8, - len: c_int, -) { - let s_ref = us_socket_t::opaque_mut(s); - if s_ref.kind() != SocketKind::BunSocketTls { - return; - } - type TLSSocket = super::NewSocket; - let Some(tls) = *s_ref.ext::>>() else { - return; - }; - // A negative length from the C side means there is nothing to deliver; - // never panic across the `extern "C"` boundary. - let Ok(len) = usize::try_from(len) else { - return; - }; - // SAFETY: `data` points to `len` readable bytes owned by the caller for the - // duration of this call. - let slice = unsafe { core::slice::from_raw_parts(data, len) }; - crate::dispatch::fold(TLSSocket::on_keylog(tls, slice)); -} diff --git a/src/runtime/socket/uws_handlers.rs b/src/runtime/socket/uws_handlers.rs index 16d6d7b2565b..f2e789a85bc4 100644 --- a/src/runtime/socket/uws_handlers.rs +++ b/src/runtime/socket/uws_handlers.rs @@ -69,9 +69,6 @@ pub(crate) trait RawSocketEvents: Sized { fn on_timeout(_this: ThisPtr, _s: NewSocketHandler) -> JsResult<()> { Ok(()) } - fn on_long_timeout(_this: ThisPtr, _s: NewSocketHandler) -> JsResult<()> { - Ok(()) - } fn on_end(_this: ThisPtr, _s: NewSocketHandler) -> JsResult<()> { Ok(()) } @@ -105,7 +102,6 @@ where const HAS_ON_WRITABLE: bool = true; const HAS_ON_CLOSE: bool = true; const HAS_ON_TIMEOUT: bool = true; - const HAS_ON_LONG_TIMEOUT: bool = true; const HAS_ON_END: bool = true; const HAS_ON_CONNECT_ERROR: bool = true; const HAS_ON_CONNECTING_ERROR: bool = true; @@ -136,10 +132,6 @@ where let Some(this) = *ext else { return }; fold(T::on_timeout(this, wrap::(s))); } - fn on_long_timeout(ext: &mut Self::Ext, s: *mut us_socket_t) { - let Some(this) = *ext else { return }; - fold(T::on_long_timeout(this, wrap::(s))); - } fn on_end(ext: &mut Self::Ext, s: *mut us_socket_t) { let Some(this) = *ext else { return }; fold(T::on_end(this, wrap::(s))); @@ -200,10 +192,6 @@ where Self::handle_timeout(this, s); Ok(()) } - fn on_long_timeout(this: ThisPtr, s: NewSocketHandler) -> JsResult<()> { - Self::handle_timeout(this, s); - Ok(()) - } fn on_end(this: ThisPtr, s: NewSocketHandler) -> JsResult<()> { Self::handle_end(this, s); Ok(()) @@ -251,11 +239,6 @@ impl RawSocketEvents for websocket_client::WebSocket this.handle_timeout(s); Ok(()) } - fn on_long_timeout(this: ThisPtr, s: NewSocketHandler) -> JsResult<()> { - let _guard = RefPtr::from_this(this); - this.handle_timeout(s); - Ok(()) - } fn on_handshake( this: ThisPtr, s: NewSocketHandler, @@ -298,9 +281,6 @@ impl LiftJsResult for JsResult<()> { /// [`LiftJsResult`]) so one expansion covers postgres, mysql and valkey, and the dispatcher /// (`NsHandler: VHandler`) folds the result at the entry. /// -/// `on_long_timeout` is intentionally NOT forwarded — no driver defines it, -/// so the trait default fires. -/// /// `on_handshake` reads the inherent `ON_HANDSHAKE: Option` const — /// `None` means "leave the slot unbound" so the dispatcher's /// no-op default fires for plain TCP. @@ -375,30 +355,19 @@ impl_ns_socket_events_forward!(js_valkey::JSValkeyClient, js_valkey::SocketHandl // `ThisPtr`. pub(crate) type BunSocket = RawPtrHandler, SSL>; -/// Listener accept path: the ext is uninitialised at on_open time (the C accept -/// loop just calloc'd it), so we read the `*Listener` off `group->ext` and let -/// `on_create` allocate the `NewSocket` and stash it in the ext. After that the -/// socket is re-stamped as `.bun_socket_{tcp,tls}` and routes through -/// `BunSocket` above. +/// An accepted socket's first event. `on_create` fills its ext and re-stamps it `.bun_socket_{tcp,tls}`, so the rest go to `BunSocket`. pub(crate) struct BunListener; impl VHandler for BunListener where api::NewSocket: RawSocketEvents, { - // No `Ext` decl — owner comes from `s.group().owner(Listener)`. + // The ext slot is still uninitialized. The owner comes from `s.group().owner(Listener)`. type Ext = (); - const HAS_EXT: bool = false; const HAS_ON_OPEN: bool = true; - const HAS_ON_DATA: bool = true; - const HAS_ON_WRITABLE: bool = true; - const HAS_ON_CLOSE: bool = true; - const HAS_ON_TIMEOUT: bool = true; - const HAS_ON_END: bool = true; - const HAS_ON_HANDSHAKE: bool = true; - fn on_open_no_ext(s: *mut us_socket_t, _is_client: bool, _ip: &[u8]) { + fn on_open(_ext: &mut (), s: *mut us_socket_t, _is_client: bool, _ip: &[u8]) { // `us_socket_t` is an `opaque_ffi!` ZST — `opaque_mut` is the safe // deref; `group()` is non-null for accepted sockets and `owner` was // stashed at listen time. @@ -416,52 +385,6 @@ where let ns = api::Listener::on_create::(unsafe { &*listener }, wrap::(s)); fold(api::NewSocket::on_open(ns, wrap::(s))); } - // Accepted sockets reach the remaining events as `.bun_socket_*` once - // on_create has restamped them; if anything fires before that, route to - // the freshly stashed NewSocket. - fn on_close_no_ext(s: *mut us_socket_t, code: i32, reason: Option<*mut c_void>) { - if let Some(ns) = *us_socket_t::opaque_mut(s).ext::>>>() - { - // `ns` is the live heap `NewSocket` stashed by `on_create`. The - // `on_*` handlers may free it, so they take `ThisPtr`, never `&mut`. - fold(api::NewSocket::on_close(ns, wrap::(s), code, reason)); - } - } - fn on_data_no_ext(s: *mut us_socket_t, data: &[u8]) { - if let Some(ns) = *us_socket_t::opaque_mut(s).ext::>>>() - { - fold(api::NewSocket::on_data(ns, wrap::(s), data)); - } - } - fn on_writable_no_ext(s: *mut us_socket_t) { - if let Some(ns) = *us_socket_t::opaque_mut(s).ext::>>>() - { - fold(api::NewSocket::on_writable(ns, wrap::(s))); - } - } - fn on_end_no_ext(s: *mut us_socket_t) { - if let Some(ns) = *us_socket_t::opaque_mut(s).ext::>>>() - { - fold(api::NewSocket::on_end(ns, wrap::(s))); - } - } - fn on_timeout_no_ext(s: *mut us_socket_t) { - if let Some(ns) = *us_socket_t::opaque_mut(s).ext::>>>() - { - fold(api::NewSocket::on_timeout(ns, wrap::(s))); - } - } - fn on_handshake_no_ext(s: *mut us_socket_t, ok: bool, err: us_bun_verify_error_t) { - if let Some(ns) = *us_socket_t::opaque_mut(s).ext::>>>() - { - fold(api::NewSocket::on_handshake( - ns, - wrap::(s), - ok as i32, - err, - )); - } - } } /// The callbacks live on a separate namespace `H` (the driver's pre-existing @@ -498,9 +421,6 @@ trait NsSocketEvents { fn on_timeout(_this: &mut Owner, _s: NewSocketHandler) -> bun_jsc::JsResult<()> { Ok(()) } - fn on_long_timeout(_this: &mut Owner, _s: NewSocketHandler) -> bun_jsc::JsResult<()> { - Ok(()) - } fn on_end(_this: &mut Owner, _s: NewSocketHandler) -> bun_jsc::JsResult<()> { Ok(()) } @@ -536,7 +456,6 @@ where const HAS_ON_WRITABLE: bool = true; const HAS_ON_CLOSE: bool = true; const HAS_ON_TIMEOUT: bool = true; - const HAS_ON_LONG_TIMEOUT: bool = true; const HAS_ON_END: bool = true; const HAS_ON_CONNECT_ERROR: bool = true; const HAS_ON_CONNECTING_ERROR: bool = true; @@ -562,10 +481,6 @@ where let Some(this) = ext.owner_mut() else { return }; fold(H::on_timeout(this, wrap::(s))); } - fn on_long_timeout(ext: &mut Self::Ext, s: *mut us_socket_t) { - let Some(this) = ext.owner_mut() else { return }; - fold(H::on_long_timeout(this, wrap::(s))); - } fn on_end(ext: &mut Self::Ext, s: *mut us_socket_t) { let Some(this) = ext.owner_mut() else { return }; fold(H::on_end(this, wrap::(s))); @@ -621,7 +536,6 @@ impl VHandler for HTTPClient { const HAS_ON_WRITABLE: bool = true; const HAS_ON_CLOSE: bool = true; const HAS_ON_TIMEOUT: bool = true; - const HAS_ON_LONG_TIMEOUT: bool = true; const HAS_ON_END: bool = true; const HAS_ON_CONNECT_ERROR: bool = true; const HAS_ON_CONNECTING_ERROR: bool = true; @@ -649,10 +563,6 @@ impl VHandler for HTTPClient { let Some(owner) = *ext else { return }; HttpH::::on_timeout(owner.as_ptr(), wrap::(s)); } - fn on_long_timeout(ext: &mut Self::Ext, s: *mut us_socket_t) { - let Some(owner) = *ext else { return }; - HttpH::::on_long_timeout(owner.as_ptr(), wrap::(s)); - } fn on_end(ext: &mut Self::Ext, s: *mut us_socket_t) { let Some(owner) = *ext else { return }; HttpH::::on_end(owner.as_ptr(), wrap::(s)); diff --git a/src/runtime/socket/uws_jsc.rs b/src/runtime/socket/uws_jsc.rs index 076b76560092..6a349262001b 100644 --- a/src/runtime/socket/uws_jsc.rs +++ b/src/runtime/socket/uws_jsc.rs @@ -2,43 +2,114 @@ //! Exports here are referenced via aliases on the original structs so call //! sites do not change. -use bun_jsc::{JSGlobalObject, JSValue}; -use bun_uws::{ - AnyWebSocket, RawWebSocket, create_bun_socket_error_t, us_socket_stream_buffer_t, us_socket_t, -}; +use bun_jsc::{JSGlobalObject, JSValue, JsError}; +use bun_uws::{AnyWebSocket, RawWebSocket, create_bun_socket_error_t}; -use crate::node::{BlobOrStringOrBuffer, StringOrBuffer}; +/// What a failed `listen()` was asked to listen on. +pub(crate) enum ListenTarget<'a> { + Tcp { + hostname: Option<&'a [u8]>, + port: u16, + }, + Unix(&'a [u8]), + Fd, +} + +impl<'a> ListenTarget<'a> { + fn address(&self) -> Option<&'a [u8]> { + match *self { + ListenTarget::Tcp { hostname, .. } => hostname, + ListenTarget::Unix(path) => Some(path), + ListenTarget::Fd => None, + } + } +} -// ── local extension: StreamBuffer accessors (upstream `bun_uws_sys::us_socket::StreamBuffer` -// is a bare `{ list: Vec, cursor: usize }`; mirror `bun_io::StreamBuffer` API here) ── -trait StreamBufferExt { - fn is_not_empty(&self) -> bool; - fn slice(&self) -> &[u8]; - fn wrote(&mut self, amount: usize); - fn write(&mut self, buffer: &[u8]); +/// Each API keeps the message it has always thrown. +#[derive(Clone, Copy)] +pub(crate) enum ListenApi { + BunServe, + BunListen, } -impl StreamBufferExt for bun_uws_sys::us_socket::StreamBuffer { - #[inline] - fn is_not_empty(&self) -> bool { - self.list.len() > self.cursor + +/// Throws the error of a failed `Bun.serve()` or `Bun.listen()`. node:http and node:net build theirs from its `code` and `syscall`. +#[cold] +pub(crate) fn throw_listen_error( + global: &JSGlobalObject, + api: ListenApi, + target: &ListenTarget<'_>, + failure: bun_uws_sys::app::ListenError, +) -> JsError { + use bun_sys::SystemErrno; + + if let Some(err) = crate::dns_jsc::cares_jsc::getaddrinfo_error( + failure.dns_error, + target.address().unwrap_or(b""), + ) { + return global.throw_value(err.to_error_instance(global)); } - #[inline] - fn slice(&self) -> &[u8] { - &self.list[self.cursor..] + + let errno = bun_uws_sys::SocketGroup::errno(failure.error).unwrap_or(SystemErrno::EUNKNOWN); + throw_listen_errno(global, api, target, errno) +} + +/// [`throw_listen_error`] for a `listen()` that isn't uSockets': a Windows named pipe's. +#[cold] +pub(crate) fn throw_listen_errno( + global: &JSGlobalObject, + api: ListenApi, + target: &ListenTarget<'_>, + errno: bun_sys::SystemErrno, +) -> JsError { + use bun_sys::SystemErrno; + + let address = target.address(); + // Winsock has no privileged ports: WSAEACCES means the port is held or in an excluded range. + #[cfg(windows)] + let errno = match (api, target, errno) { + (ListenApi::BunServe, ListenTarget::Tcp { .. }, SystemErrno::EACCES) => { + SystemErrno::EADDRINUSE + } + _ => errno, + }; + + let mut sys_error = bun_sys::Error::new(errno, bun_sys::Tag::listen); + if let ListenTarget::Unix(path) = *target { + sys_error = sys_error.with_path(path); } - #[inline] - fn wrote(&mut self, amount: usize) { - self.cursor += amount; + let mut err = bun_jsc::SystemError::from(sys_error.to_system_error()); + match (api, target, errno) { + (ListenApi::BunListen, ..) => { + err.message = bun_core::String::create_format(format_args!( + "Failed to listen at {}", + bstr::BStr::new(address.unwrap_or(b"")) + )); + } + (ListenApi::BunServe, ListenTarget::Tcp { port, .. }, SystemErrno::EADDRINUSE) => { + err.message = bun_core::String::create_format(format_args!( + "Failed to start server. Is port {port} in use?" + )); + } + (ListenApi::BunServe, ListenTarget::Tcp { port, .. }, SystemErrno::EACCES) => { + err.message = bun_core::String::create_format(format_args!( + "permission denied {}:{port}", + bstr::BStr::new(address.unwrap_or(b"0.0.0.0")) + )); + } + (ListenApi::BunServe, ..) => {} } - #[inline] - fn write(&mut self, buffer: &[u8]) { - // Same rule as `bun_io::StreamBuffer::compact`. - if self.cursor > 0 && self.cursor >= self.list.len() - self.cursor { - self.list.drain(..self.cursor); - self.cursor = 0; + + let error = err.to_error_instance(global); + if let Some(address) = address { + match bun_jsc::bun_string_jsc::create_utf8_for_js(global, address) { + Ok(address) => error.put(global, b"address", address), + Err(thrown) => return thrown, } - self.list.extend_from_slice(buffer); } + if let ListenTarget::Tcp { port, .. } = *target { + error.put(global, b"port", JSValue::js_number(f64::from(port))); + } + global.throw_value(error) } // ── create_bun_socket_error_t.toJS / us_bun_verify_error_t.toJS ──────────── @@ -124,149 +195,3 @@ pub(crate) fn any_web_socket_get_topics_as_js_array( }; uws_ws_get_topics_as_js_array(ssl, ws, global_object) } - -unsafe extern "C" { - /// JSNodeHTTPServerSocket.cpp. Writes through the uWS buffer of the connection and returns whether uWS still holds bytes. - fn Bun__NodeHTTPServerSocket__writeBehindResponse( - socket: *mut us_socket_t, - ssl: bool, - data: *const u8, - length: usize, - ) -> bool; -} - -// ── us_socket_buffered_js_write (C-exported, called from JSNodeHTTPServerSocket.cpp) ── -/// # Safety -/// `socket` and `buffer` must be valid, non-null pointers for the duration of the call -/// (guaranteed by the C++ caller `JSNodeHTTPServerSocket.cpp`). -#[unsafe(no_mangle)] -unsafe extern "C" fn us_socket_buffered_js_write( - socket: *mut us_socket_t, - ssl: bool, - ended: bool, - // uWS still holds response bytes: write through its buffer. The caller defers a shutdown (`shutdownAfterResponseDrains`). - hold: bool, - // A drain call flushes `buffer` (a tunnel). On any other socket the uWS buffer takes what the kernel does not. - flushes_buffer_on_drain: bool, - buffer: *mut us_socket_stream_buffer_t, - global_object: &JSGlobalObject, - data: JSValue, - encoding: JSValue, -) -> JSValue { - // NOTE: `socket`/`buffer` are kept as raw `*mut` for the function lifetime and only - // dereferenced to `&mut` at each point of use. The JS calls below - // (`from_js_with_encoding_value_allow_request_response`, `throw_*`) can re-enter - // `JSNodeHTTPServerSocket.write` on the same socket, which would alias a long-lived - // `&mut *socket` / `&mut *buffer` under Stacked Borrows, so raw pointers with - // no uniqueness assertion are used throughout. - - // Convert `data`/`encoding` BEFORE materializing the stream buffer into an owning - // `Vec`: the conversion can run arbitrary JS (toString/Symbol.toPrimitive, - // Request/Response body coercion) which can re-enter this function on the same - // socket. Taking the buffer first would leave two owning `Vec`s over the same - // `list_ptr`; the inner call's realloc would free the allocation out from under - // the outer frame (use-after-free). - let node_buffer: BlobOrStringOrBuffer = if data.is_undefined() { - BlobOrStringOrBuffer::StringOrBuffer(StringOrBuffer::EMPTY) - } else { - match BlobOrStringOrBuffer::from_js_with_encoding_value_allow_request_response( - global_object, - data, - encoding, - true, - ) { - Err(_) => return JSValue::ZERO, - Ok(Some(v)) => v, - Ok(None) => { - if !global_object.has_exception() { - let _ = global_object.throw_invalid_argument_type_value( - "data", - "string, buffer, or blob", - data, - ); - } - return JSValue::ZERO; - } - } - }; - - if let BlobOrStringOrBuffer::Blob(ref blob) = node_buffer { - if blob.needs_to_read_file() { - let _ = global_object.throw(format_args!( - "File blob not supported yet in this function." - )); - return JSValue::ZERO; - } - } - - // SAFETY: caller (JSNodeHTTPServerSocket.cpp) guarantees `buffer` is valid for the call. - // No JS executes between here and the `update()` below, so this owning `Vec` is the - // sole owner of `list_ptr` for the remainder of the function. - let mut stream_buffer = unsafe { &mut *buffer }.to_stream_buffer(); - let mut total_written: usize = 0; - - // Labeled block + post-block cleanup so the `buffer.update` / `buffer.wrote` - // side effects run on every - // exit path without a scopeguard borrow conflict. - let result: JSValue = 'body: { - let data_slice = node_buffer.slice(); - // `us_socket_t` is an `opaque_ffi!` ZST — `opaque_mut` is the safe deref. - // No JS executes between here and `JSValue::TRUE/FALSE` below, so the - // single `&mut` does not alias the re-entrant write path documented at - // the top of this fn (raw `socket` is still kept for that reason). - let socket_ref = us_socket_t::opaque_mut(socket); - // SAFETY: the caller guarantees `socket` is live for the call; the slice is valid for its length. - let write_behind_response = |bytes: &[u8]| unsafe { - Bun__NodeHTTPServerSocket__writeBehindResponse(socket, ssl, bytes.as_ptr(), bytes.len()) - }; - if hold { - // What an earlier write still owes goes first. - let owed = stream_buffer.slice().len(); - write_behind_response(stream_buffer.slice()); - let still_held = write_behind_response(data_slice); - stream_buffer.wrote(owed); - total_written = owed.saturating_add(data_slice.len()); - break 'body JSValue::js_boolean(!still_held); - } - if stream_buffer.is_not_empty() { - let to_flush = stream_buffer.slice(); - let to_flush_len = to_flush.len(); - let written: u32 = u32::try_from(socket_ref.write(to_flush).max(0)).unwrap(); - stream_buffer.wrote(written as usize); - total_written = total_written.saturating_add(written as usize); - if (written as usize) < to_flush_len { - if !data_slice.is_empty() { - stream_buffer.write(data_slice); - } - break 'body JSValue::FALSE; - } - } - - if !data_slice.is_empty() { - let written: u32 = u32::try_from(socket_ref.write(data_slice).max(0)).unwrap(); - total_written = total_written.saturating_add(written as usize); - if (written as usize) < data_slice.len() { - let rest = &data_slice[written as usize..]; - if flushes_buffer_on_drain { - stream_buffer.write(rest); - } else { - write_behind_response(rest); - total_written = total_written.saturating_add(rest.len()); - } - break 'body JSValue::FALSE; - } - } - if ended { - socket_ref.shutdown(); - } - JSValue::TRUE - }; - - // SAFETY: caller guarantees `buffer` is valid for the call; no JS executes between here - // and return, so no re-entrancy aliasing. - unsafe { - (*buffer).update(stream_buffer); - (*buffer).wrote(total_written); - } - result -} diff --git a/src/runtime/test_runner/timers/FakeTimers.bind.ts b/src/runtime/test_runner/timers/FakeTimers.bind.ts index e05e8a2a0612..da7950017eae 100644 --- a/src/runtime/test_runner/timers/FakeTimers.bind.ts +++ b/src/runtime/test_runner/timers/FakeTimers.bind.ts @@ -24,6 +24,9 @@ export const viUseFakeTimers = b.fn({ ...useFakeTimers, export: "Bun__FakeTimers export const jestUseFakeTimers = b.fn({ ...useFakeTimers, export: "Bun__FakeTimers__jestUseFakeTimers" }); export const vitestUseFakeTimers = b.fn({ ...useFakeTimers, export: "Bun__FakeTimers__vitestUseFakeTimers" }); +/** While on, fake timers also take what a built-in module schedules with `setTimeout()`, such as the timeout of a socket. */ +export const fakeLibraryTimers = b.fn({ args: { enabled: b.bool } }); + export const useRealTimers = b.fn({ ...chainable, export: "Bun__FakeTimers__useRealTimers" }); export const advanceTimersToNextTimer = b.fn({ ...chainable, diff --git a/src/runtime/test_runner/timers/FakeTimers.rs b/src/runtime/test_runner/timers/FakeTimers.rs index ff6d74f24345..1f4962356061 100644 --- a/src/runtime/test_runner/timers/FakeTimers.rs +++ b/src/runtime/test_runner/timers/FakeTimers.rs @@ -475,6 +475,8 @@ pub(crate) struct FakeTimers { /// Empty while inactive. faked: ApiSet, fakes_abort_signal_timeout: bool, + /// `fakeLibraryTimers()` of `bun:internal-for-testing`. + fakes_library_timers: bool, /// A preload script set up the current mock, so `on_test_file_end` keeps it. from_preload: bool, /// Bumped by `activate` and `forget_installation`. If it changes mid-run, that run's clock is gone. @@ -507,6 +509,7 @@ impl Default for FakeTimers { active: false, faked: ApiSet::default(), fakes_abort_signal_timeout: false, + fakes_library_timers: false, from_preload: false, installs: 0, firing: 0, @@ -637,6 +640,15 @@ impl FakeTimers { } } + /// The clock of the `setTimeout()` that a fake replaces. Built-in modules keep calling it. + pub(crate) fn replaced_set_timeout_clock(&self) -> Clock { + if self.fakes_library_timers { + self.set_timeout_clock() + } else { + Clock::Real + } + } + /// Like `setTimeout()` for "bun:test". Vitest has nothing that fakes it, so tests written for it wait for one with fake timers on. pub(crate) fn abort_signal_timeout_clock(&self) -> Clock { if self.fakes_abort_signal_timeout { @@ -1951,6 +1963,11 @@ pub(crate) fn clear_all_timers(cx: &mut JsScope<'_>, this_value: JsLocal<'_>) -> Ok(chainable_this(global, this_value)) } +pub(crate) fn fake_library_timers(enabled: bool) { + // SAFETY: per-thread `timer::All`, live for the VM lifetime. + unsafe { (*timer_all()).fake_timers.fakes_library_timers = enabled }; +} + pub(crate) fn is_fake_timers() -> bool { is_active() } diff --git a/src/runtime/timer/Timer.rs b/src/runtime/timer/Timer.rs index 4da7bf3c3a08..6ee2b5a6ca53 100644 --- a/src/runtime/timer/Timer.rs +++ b/src/runtime/timer/Timer.rs @@ -553,7 +553,7 @@ pub(crate) fn set_timeout_export( callback, arguments, countdown, - Clock::Real, + timer_all_mut().fake_timers.replaced_set_timeout_clock(), ) } diff --git a/src/runtime/timer/mod.rs b/src/runtime/timer/mod.rs index d12be211da9b..d8d5a2e2c813 100644 --- a/src/runtime/timer/mod.rs +++ b/src/runtime/timer/mod.rs @@ -933,8 +933,7 @@ impl All { /// took, if any, for the caller to share with the tick (see `NOW_NS_UNKNOWN`). /// /// Note (b2): `vm` is erased per §Dispatch (the caller is in - /// `bun_jsc::event_loop` which can't name `bun_runtime`). The two reads - /// it needs — `event_loop.immediate_tasks.len()` and the QUIC tick — are + /// `bun_jsc::event_loop` which can't name `bun_runtime`), so `has_pending_immediate` is /// passed in pre-computed until the cycle is broken. /// /// # Safety @@ -944,7 +943,6 @@ impl All { &mut self, spec: &mut Timespec, has_pending_immediate: bool, - quic_next_tick_us: Option, vm: *mut (), /* erased *mut VirtualMachine, forwarded to fire() */ now_out: &mut Option, ) -> bool { @@ -973,15 +971,6 @@ impl All { }); let Some(next) = Self::soonest(wtf_next, reg_next) else { - if let Some(us) = quic_next_tick_us { - if us >= 0 { - *spec = Timespec { - sec: us / US_PER_S, - nsec: (us % US_PER_S) * NS_PER_US, - }; - return true; - } - } return false; }; @@ -992,27 +981,12 @@ impl All { let is_due_when_idle = (unsafe { &*this }).deadline_if_due_when_idle().is_some(); if next.greater(&now) && !is_due_when_idle { *spec = next.duration(&now); - if let Some(us) = quic_next_tick_us { - if us >= 0 { - Self::clamp_to_quic(spec, us); - } - } } else { *spec = Timespec { sec: 0, nsec: 0 }; } true } - fn clamp_to_quic(spec: &mut Timespec, us: i64) { - let cur_us = spec.sec * US_PER_S + spec.nsec / NS_PER_US; - if us < cur_us { - *spec = Timespec { - sec: us / US_PER_S, - nsec: (us % US_PER_S) * NS_PER_US, - }; - } - } - /// The deadline of the first timer, if it is one that does not wait for it. See `EventLoopTimer::is_due_when_idle`. fn deadline_if_due_when_idle(&self) -> Option { // SAFETY: `peek` returns a live heap node. @@ -1369,9 +1343,6 @@ impl ID { } } -const US_PER_S: i64 = bun_core::time::US_PER_S as i64; -const NS_PER_US: i64 = bun_core::time::NS_PER_US as i64; - /// The timer drain's fold: report what a fired timer's handler left pending /// as uncaught, or — if it is the VM's termination — tell the drain to stop. /// diff --git a/src/runtime/valkey_jsc/js_valkey.rs b/src/runtime/valkey_jsc/js_valkey.rs index 35bf96cc433f..7161385fd176 100644 --- a/src/runtime/valkey_jsc/js_valkey.rs +++ b/src/runtime/valkey_jsc/js_valkey.rs @@ -1,6 +1,7 @@ use core::ffi::c_void; -use crate::socket::{SSLConfig, SSLConfigFromJs}; +use crate::api::SSLContextCache::SSLContextCache; +use crate::socket::ssl_config::ParsedTls; use bun_boringssl as boringssl; use bun_core::{Utf8Bytes, strings}; use bun_event_loop::EventLoopTimer as Timer; @@ -1458,12 +1459,8 @@ impl JSValkeyClient { // Per-VM weak cache: a `duplicate()`'d client (or any // other client with the same config) hits the same // `SSL_CTX*` instead of rebuilding. - let state = crate::jsc_hooks::runtime_state(); - debug_assert!(!state.is_null(), "RuntimeState not installed"); - // SAFETY: per-thread `RuntimeState`; `ssl_ctx_cache` has a - // stable address for the VM's lifetime, JS-thread-only. - let cache = unsafe { &mut (*state).ssl_ctx_cache }; - self._secure.set(cache.get_or_create(custom, &mut err)); + self._secure + .set(SSLContextCache::get_or_create(custom, &mut err)); } self._secure.get().is_none() } else { @@ -1977,14 +1974,11 @@ impl Options { valkey::TLS::None }; } else if tls.is_object() { - // SAFETY: `bun_vm()` returns the live per-global VM pointer. - if let Some(ssl_config) = - SSLConfig::from_js(global_object.bun_vm(), global_object, tls, false)? - { - this.tls = valkey::TLS::Custom(Box::new(ssl_config)); - } else { + let parsed = ParsedTls::from_js(global_object.bun_vm(), global_object, tls, false)?; + let Some(ssl_config) = parsed.or_none() else { return Err(global_object.throw_invalid_argument_type("tls", "tls", "object")); - } + }; + this.tls = valkey::TLS::Custom(Box::new(ssl_config)); } else { return Err(global_object.throw_invalid_argument_type( "tls", diff --git a/src/runtime/valkey_jsc/valkey.rs b/src/runtime/valkey_jsc/valkey.rs index f7d6cf01d5e7..bad8c15fd1e8 100644 --- a/src/runtime/valkey_jsc/valkey.rs +++ b/src/runtime/valkey_jsc/valkey.rs @@ -203,7 +203,7 @@ impl Address { let kind = SocketKind::ValkeyTls; let sock = match self { Address::Unix(path) => { - uws::SocketTLS::connect_unix_group(group, kind, ssl_ctx, path, owner, false)? + uws::SocketTLS::connect_unix_group(group, kind, ssl_ctx, path, owner)? } Address::Host { host, port } => uws::SocketTLS::connect_group( group, @@ -212,7 +212,6 @@ impl Address { host, i32::from(*port), owner, - false, )?, }; Ok(AnySocket::SocketTls(sock)) @@ -220,7 +219,7 @@ impl Address { let kind = SocketKind::Valkey; let sock = match self { Address::Unix(path) => { - uws::SocketTCP::connect_unix_group(group, kind, ssl_ctx, path, owner, false)? + uws::SocketTCP::connect_unix_group(group, kind, ssl_ctx, path, owner)? } Address::Host { host, port } => uws::SocketTCP::connect_group( group, @@ -229,7 +228,6 @@ impl Address { host, i32::from(*port), owner, - false, )?, }; Ok(AnySocket::SocketTcp(sock)) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index e794f2c38566..582096a8a324 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -117,7 +117,9 @@ impl read_file::ReadFileCompletion for H { // This crate layers behaviour via the `BlobExt` extension trait below. // ────────────────────────────────────────────────────────────────────────── -pub(crate) use bun_jsc::webcore_types::{Blob, BlobContentType, ClosingState, MAX_SIZE, SizeType}; +pub(crate) use bun_jsc::webcore_types::{ + Blob, BlobContentType, BodyFacts, ClosingState, MAX_SIZE, SizeType, +}; /// 1: Initial /// 2: Added byte for whether it's a dom file, length and bytes for `stored_name`, @@ -145,7 +147,7 @@ use crate::generated_bindgen::blob::{ // ────────────────────────────────────────────────────────────────────────── pub(crate) trait BlobExt { - fn get_form_data_encoding(&self) -> Option>; + fn get_form_data_encoding(&self) -> Option; // `has_content_type_from_user`/`content_type_or_mime_type`/`is_s3`/ // `needs_to_read_file`/`get_file_name`: data-only predicates, hoisted to // inherent `impl Blob` in `bun_jsc::webcore_types` (LAYERING). @@ -181,7 +183,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]; @@ -303,7 +309,6 @@ pub(crate) trait BlobExt { global: &JSGlobalObject, raw_bytes: *mut [u8], ) -> JsResult; - fn to_string_transfer(&self, cx: &bun_jsc::JsThread<'_>) -> JsResult; fn to_string(&self, cx: &bun_jsc::JsThread<'_>, lifetime: Lifetime) -> JsResult; fn to_json(&self, cx: &bun_jsc::JsThread<'_>, lifetime: Lifetime) -> JsResult; /// # Safety @@ -385,11 +390,8 @@ pub(crate) trait BlobExt { } impl BlobExt for Blob { - fn get_form_data_encoding(&self) -> Option> { - let content_type_slice = self.get_content_type()?; - let encoding = bun_core::form_data::Encoding::get(content_type_slice.slice())?; - // drop content_type_slice via Drop - Some(bun_core::form_data::AsyncFormData::init(encoding)) + fn get_form_data_encoding(&self) -> Option { + bun_core::form_data::Encoding::get(self.get_content_type()?.slice()) } /// `Function` is the `*WithBytes` callback. @@ -720,7 +722,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]; @@ -735,8 +740,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. @@ -802,12 +806,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"--"); @@ -830,7 +834,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] { @@ -1178,7 +1182,7 @@ impl BlobExt for Blob { } // A borrowed view with NO ref bumps; `write_file_internal` then `dupe()`s // its own owned `destination_blob` from it. - let mut blob_internal = PathOrBlob::Blob(Box::new(self.borrowed_view())); + let mut blob_internal = PathOrBlob::Blob(Box::new(self.dupe())); write_file_internal( &cx, &mut blob_internal, @@ -1721,7 +1725,7 @@ impl BlobExt for Blob { fn get_mime_type_or_content_type(&self) -> Option { if self.content_type_was_set.get() { - return Some(MimeType::init(self.content_type_slice(), false, None)); + return Some(MimeType::init(self.content_type_slice(), false)); } self.store().map(|s| s.mime_type.clone()) } @@ -2152,8 +2156,6 @@ impl BlobExt for Blob { Self::try_create(bytes_, global_this, was_string).expect("oom") } - // dupe / dupe_with_content_type / into_js: defined once below (top-level impl); duplicates removed (E0592). - /// Raw-pointer counterpart of [`shared_view`]: returns a `*mut [u8]` into /// the Store's byte buffer with **mutable provenance** (derived through /// `RefPtr::as_ptr()` → the original `heap::alloc` tag), suitable for @@ -2354,10 +2356,6 @@ impl BlobExt for Blob { } } - fn to_string_transfer(&self, cx: &bun_jsc::JsThread<'_>) -> JsResult { - self.to_string(cx, Lifetime::Transfer) - } - fn to_string(&self, cx: &bun_jsc::JsThread<'_>, lifetime: Lifetime) -> JsResult { if self.needs_to_read_file() { return Ok(self.do_read_file::(cx)); @@ -2489,19 +2487,14 @@ impl BlobExt for Blob { } fn to_form_data_with_bytes(&self, global: &JSGlobalObject, buf: &[u8]) -> JSValue { - let Some(encoder) = self.get_form_data_encoding() else { + let Some(encoding) = self.get_form_data_encoding() else { return global.create_error_instance(format_args!("Invalid encoding")); }; // `crate::webcore::form_data::Encoding` re-exports // `bun_core::form_data::Encoding` — same type, no re-tagging needed. - match crate::webcore::form_data::FormData::to_js(global, buf, &encoder.encoding) { - Ok(v) => v, - Err(crate::Error::JSError) => global.take_error(jsc::JsError::Thrown), - Err(err) => { - global.create_error_instance(format_args!("FormData encoding failed: {err}")) - } - } + crate::webcore::form_data::FormData::to_js(global, buf, &encoding) + .unwrap_or_else(|err| global.take_error(err)) } /// # Safety @@ -2551,16 +2544,20 @@ impl BlobExt for Blob { // SAFETY: `buf` is valid for reads for the duration of this call (either a // leaked Box for `Temporary` or a store-backed view otherwise). let buf_len = unsafe { &*buf }.len(); + // ArrayBuffer doesn't have this limit. + if TYPED_ARRAY_VIEW != jsc::JSType::ArrayBuffer + && buf_len > jsc::virtual_machine::synthetic_allocation_limit() + { + match LIFETIME { + Lifetime::Clone | Lifetime::Transfer => self.detach(), + Lifetime::Share => {} + // SAFETY: `Temporary` ⇒ `buf` is a leaked default-allocator `Box<[u8]>`. + Lifetime::Temporary => unsafe { drop(bun_core::heap::take(buf)) }, + } + return Err(global.throw_out_of_memory()); + } match LIFETIME { Lifetime::Clone => { - if TYPED_ARRAY_VIEW != jsc::JSType::ArrayBuffer { - // ArrayBuffer doesn't have this limit. - if buf_len > jsc::virtual_machine::synthetic_allocation_limit() { - self.detach(); - return Err(global.throw_out_of_memory()); - } - } - #[cfg(any(target_os = "linux", target_os = "android"))] { use crate::allocators::linux_mem_fd_allocator::LinuxMemFdAllocator; @@ -2607,11 +2604,6 @@ impl BlobExt for Blob { jsc::ArrayBuffer::create::(global, unsafe { &*buf }) } Lifetime::Share => { - if buf_len > jsc::virtual_machine::synthetic_allocation_limit() - && TYPED_ARRAY_VIEW != jsc::JSType::ArrayBuffer - { - return Err(global.throw_out_of_memory()); - } let store = self.store().expect("infallible: store present").clone(); // SAFETY: `from_bytes` only records ptr+len into the FFI struct; the // pointer is then handed to JSC as an external buffer backing whose @@ -2637,12 +2629,6 @@ impl BlobExt for Blob { self.detach(); return copied; } - if buf_len > jsc::virtual_machine::synthetic_allocation_limit() - && TYPED_ARRAY_VIEW != jsc::JSType::ArrayBuffer - { - self.detach(); - return Err(global.throw_out_of_memory()); - } // Move the existing +1 out. Cloning then `transfer()` would leak a ref. let store = self.take_store().expect("transfer with null store"); // SAFETY: see `Share` arm. After `take()` the store ref is moved @@ -2656,13 +2642,6 @@ impl BlobExt for Blob { } } Lifetime::Temporary => { - if buf_len > jsc::virtual_machine::synthetic_allocation_limit() - && TYPED_ARRAY_VIEW != jsc::JSType::ArrayBuffer - { - // SAFETY: `Temporary` ⇒ `buf` is a leaked default-allocator `Box<[u8]>`. - unsafe { drop(bun_core::heap::take(buf)) }; - return Err(global.throw_out_of_memory()); - } // SAFETY: `Temporary` ⇒ `buf` is a leaked `Box<[u8]>` we exclusively own; // ownership is transferred to JSC. // `to_js_unchecked`: `to_js`'s heap-region probe would skip the deallocator @@ -2871,6 +2850,7 @@ impl BlobExt for Blob { ), charset: Cell::new(blob.charset.get()), is_jsdom_file: Cell::new(blob.is_jsdom_file.get()), + body_facts: Cell::new(blob.body_facts.get()), ref_count: bun_ptr::RawRefCount::init(0), // setNotHeapAllocated global_this: Cell::new(blob.global_this.get()), last_modified: Cell::new(blob.last_modified.get()), @@ -3276,15 +3256,12 @@ pub(crate) enum FormDataEntry<'a> { // FormDataContext // ────────────────────────────────────────────────────────────────────────── -/// Stack-local helper for `Blob::from_dom_form_data`. `boundary` borrows the -/// caller's `boundary_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 @@ -3372,12 +3349,11 @@ impl<'a> FormDataContext<'a> { } fn on_entry(&mut self, name: EncodedSlice<'a>, entry: FormDataEntry<'a>) { - 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; @@ -3412,7 +3388,8 @@ impl<'a> FormDataContext<'a> { joiner.push_static(b"\r\n\r\n"); if blob.store.get().is_some() { - if blob.size.get() == MAX_SIZE { + // `read_file` reads a file of unknown size to EOF. macOS reports only the buffered bytes as a pipe's `st_size`. + if blob.size.get() == MAX_SIZE && !blob.needs_to_read_file() { blob.resolve_size(); } let store = blob @@ -3439,11 +3416,7 @@ impl<'a> FormDataContext<'a> { 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 @@ -3724,7 +3697,7 @@ pub(crate) extern "C" fn Blob__setAsFile(this: &mut Blob, path_str: &BunString) #[unsafe(no_mangle)] pub(crate) extern "C" fn Blob__dupe(this: &Blob) -> *mut Blob { - Blob::new(this.dupe_with_content_type(true)) + Blob::new(this.dupe()) } #[unsafe(no_mangle)] @@ -4084,8 +4057,8 @@ pub(crate) fn write_file_with_source_destination( match write_file::WriteFileWindows::create( cx.vm().event_loop(), cx.context(), - destination_blob.borrowed_view(), - source_blob.borrowed_view(), + destination_blob.dupe(), + source_blob.dupe(), promise, cx.global(), options.mkdirp_if_not_exists.unwrap_or(true), @@ -4100,8 +4073,8 @@ pub(crate) fn write_file_with_source_destination( #[cfg(not(windows))] { let file_copier = write_file::WriteFile::create( - destination_blob.borrowed_view(), - source_blob.borrowed_view(), + destination_blob.dupe(), + source_blob.dupe(), options.mkdirp_if_not_exists.unwrap_or(true), ) .expect("unreachable"); @@ -5744,17 +5717,7 @@ impl Any { } pub(crate) fn from_owned_slice(bytes: Vec) -> Any { - Any::InternalBlob(Internal { - bytes, - was_string: false, - }) - } - - pub(crate) fn from_array_list(list: Vec) -> Any { - Any::InternalBlob(Internal { - bytes: list, - was_string: false, - }) + Any::InternalBlob(bytes.into()) } /// Assumed that AnyBlob itself is covered by the caller. @@ -5816,6 +5779,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; @@ -5849,14 +5826,14 @@ impl Any { if matches!(self, Any::Blob(_)) { return self.to_string(cx, Lifetime::Clone); } - self.to_string_transfer(cx) + self.to_string(cx, Lifetime::Transfer) } streams::BufferActionTag::Bytes => { if matches!(self, Any::Blob(_)) { return self .to_array_buffer_view::<{ jsc::JSType::Uint8Array }>(cx, Lifetime::Clone); } - self.to_uint8_array_transfer(cx) + self.to_uint8_array(cx, Lifetime::Transfer) } streams::BufferActionTag::Blob => { let result = self.to_blob(cx.global()); @@ -5868,7 +5845,7 @@ impl Any { return self .to_array_buffer_view::<{ jsc::JSType::ArrayBuffer }>(cx, Lifetime::Clone); } - self.to_array_buffer_transfer(cx) + self.to_array_buffer(cx, Lifetime::Transfer) } streams::BufferActionTag::Json => self.to_json(cx, Lifetime::Share), } @@ -5929,32 +5906,17 @@ impl Any { *self = Any::Blob(Blob::default()); str } - Any::WTFStringImpl(_) => self.take_string().to_js_by_parse_json(cx.global()), + 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; + } + self.take_string().to_js_by_parse_json(cx.global()) + } } } - pub(crate) fn to_json_share(&mut self, cx: &bun_jsc::JsThread<'_>) -> JsResult { - self.to_json(cx, Lifetime::Share) - } - - pub(crate) fn to_string_transfer(&mut self, cx: &bun_jsc::JsThread<'_>) -> JsResult { - self.to_string(cx, Lifetime::Transfer) - } - - pub(crate) fn to_uint8_array_transfer( - &mut self, - cx: &bun_jsc::JsThread<'_>, - ) -> JsResult { - self.to_uint8_array(cx, Lifetime::Transfer) - } - - pub(crate) fn to_array_buffer_transfer( - &mut self, - cx: &bun_jsc::JsThread<'_>, - ) -> JsResult { - self.to_array_buffer(cx, Lifetime::Transfer) - } - pub(crate) fn to_blob(&mut self, global: &JSGlobalObject) -> Blob { if self.size() == 0 { return Blob::init_empty(global); @@ -5974,7 +5936,7 @@ impl Any { let Any::InternalBlob(ib) = self else { unreachable!() }; - let blob = Blob::init(core::mem::take(&mut ib.bytes), global); + let blob = ib.to_blob(global); *self = Any::Blob(Blob::default()); blob } @@ -5994,7 +5956,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()) + } } } @@ -6073,9 +6046,20 @@ impl Any { pub(crate) fn was_string(&self) -> bool { match self { - Any::Blob(b) => b.charset.get() == strings::AsciiStatus::AllAscii, + Any::Blob(b) => { + b.body_facts.get().contains(BodyFacts::STRING) + || b.charset.get() == strings::AsciiStatus::AllAscii + } Any::WTFStringImpl(_) => true, - Any::InternalBlob(ib) => ib.was_string, + Any::InternalBlob(ib) => ib.facts.contains(BodyFacts::STRING), + } + } + + pub(crate) fn set_facts(&mut self, facts: BodyFacts) { + match self { + Any::Blob(blob) => blob.body_facts.set(facts), + Any::InternalBlob(internal) => internal.facts = facts, + Any::WTFStringImpl(_) => {} } } @@ -6128,10 +6112,34 @@ impl Any { #[derive(Default)] pub struct Internal { pub(crate) bytes: Vec, - pub(crate) was_string: bool, + pub(crate) facts: BodyFacts, +} + +impl From> for Internal { + fn from(bytes: Vec) -> Self { + Self { + bytes, + facts: BodyFacts::empty(), + } + } } impl Internal { + /// The UTF-8 bytes of a JS string. + pub(crate) fn from_string_bytes(bytes: Vec) -> Self { + Self { + bytes, + facts: BodyFacts::STRING, + } + } + + /// Moves the bytes into a `Blob`, which can be shared. + pub(crate) fn to_blob(&mut self, global_this: &JSGlobalObject) -> Blob { + let blob = Blob::init(self.to_owned_slice(), global_this); + blob.body_facts.set(self.facts); + blob + } + pub(crate) fn memory_cost(&self) -> usize { self.bytes.capacity() } @@ -6179,7 +6187,7 @@ impl Internal { // MimeType::{TEXT,OTHER} are `const` (not `static`), so // borrowing `.value` would borrow a temporary. Inline the literals // (matches `MimeType::init_comptime` values). - if self.was_string { + if self.facts.contains(BodyFacts::STRING) { return b"text/plain;charset=utf-8"; // MimeType::TEXT } b"application/octet-stream" // MimeType::OTHER diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index 5c8656e2d697..7c518b12a45f 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -3,6 +3,7 @@ use core::ffi::c_void; use core::ptr::NonNull; +use crate::webcore::blob::BodyFacts; use crate::webcore::jsc::{ self as jsc, CommonAbortReason, CommonAbortReasonExt as _, DOMFormData, JSGlobalObject, JSPromise, JSValue, JsResult, SystemError, URLSearchParams, VirtualMachine, @@ -16,12 +17,12 @@ use bun_http_types::MimeType::MimeType; // Re-export so callers can write `body::InternalBlob`. use crate::jsc::HTTPHeaderName; pub(crate) use crate::webcore::InternalBlob; -use crate::webcore::form_data::AsyncFormDataExt as _; -use bun_core::String as BunString; +use bun_core::{String as BunString, StringView}; use bun_core::{Utf8Bytes, WTFString, WTFStringImpl, WTFStringImplExt as _, WTFStringImplStruct}; use bun_jsc::JsCell; use bun_jsc::StringJsc as _; -use bun_jsc::bindgen::{JsScope, JsWrapper}; +use bun_jsc::SysErrorJsc as _; +use bun_jsc::bindgen::JsScope; use bun_jsc::bun_string_jsc; /// Deref the `Value::WTFStringImpl` payload. @@ -39,14 +40,7 @@ fn wtf_impl(s: &WTFStringImpl) -> &WTFStringImplStruct { unsafe { &**s } } -fn set_blob_content_type(blob: &Blob, mime_type: MimeType) { - blob.content_type_was_set.set(true); - blob.content_type - .set(blob::BlobContentType::from(mime_type)); -} - bun_core::declare_scope!(BodyValue, visible); -bun_core::declare_scope!(BodyMixin, visible); // R-2 (host-fn re-entrancy): `Body` is embedded inline in JS-exposed // `Response` (and aliased via `HiveRef` in `Request`). Every BodyMixin host @@ -198,11 +192,17 @@ pub struct PendingValue { /// to the `NewSource` when the locked body is realised as a native stream. pub producer: streams::SourceHandle, pub(crate) size_hint: blob::SizeType, + pub(crate) facts: BodyFacts, + /// The `Content-Type` this body implied before it became a stream. A stream passed in as a body implies none. + pub(crate) content_type: Option>, pub(crate) deinit: bool, pub(crate) action: Action, } +// `Value` is pooled, and `ValueError` is its largest arm in a release build. +const _: () = assert!(size_of::() < size_of::()); + impl PendingValue { pub(crate) fn new(global: &JSGlobalObject) -> Self { Self { @@ -210,6 +210,22 @@ impl PendingValue { ..Default::default() } } + + fn from_stream(readable: ReadableStream, global: &JSGlobalObject) -> Self { + Self { + readable: webcore::readable_stream::Strong::init(readable, global), + facts: readable.body_facts(), + ..Self::new(global) + } + } + + /// The clone's body: the other branch of a tee of this body's stream. + fn tee_branch(&self, readable: ReadableStream, global: &JSGlobalObject) -> Value { + Value::Locked(Self { + content_type: self.content_type.clone(), + ..Self::from_stream(readable, global) + }) + } } impl Default for PendingValue { @@ -227,6 +243,8 @@ impl Default for PendingValue { on_readable_stream_available: None, producer: streams::SourceHandle::None, size_hint: 0, + facts: BodyFacts::empty(), + content_type: None, deinit: false, action: Action::None, } @@ -234,6 +252,21 @@ impl Default for PendingValue { } impl PendingValue { + fn content_type(&self) -> &blob::BlobContentType { + static NONE: blob::BlobContentType = blob::BlobContentType::Static(b""); + self.content_type.as_deref().unwrap_or(&NONE) + } + + fn take_content_type(&mut self) -> blob::BlobContentType { + self.content_type + .take() + .map_or_else(Default::default, |boxed| *boxed) + } + + pub(crate) fn set_content_type(&mut self, content_type: blob::BlobContentType) { + self.content_type = (!content_type.is_empty()).then(|| Box::new(content_type)); + } + /// Once `readable` is set the live handle is `NewSource.producer`; these /// hooks go stale when the producer (e.g. `FetchTasklet`) is freed. fn detach_producer(&mut self) { @@ -278,58 +311,11 @@ impl PendingValue { self.size_hint } + /// Lifts the whole payload out of a stream that nothing has read. fn to_any_blob(&mut self) -> Option { - if self.promise.is_some() { - return None; - } - self.to_any_blob_allow_promise() - } - - pub(crate) fn is_disturbed( - &self, - global_object: &JSGlobalObject, - this_value: JSValue, - ) -> bool { - if self.has_consumer() { - return true; - } - - if let Some(body_value) = T::body_get_cached(this_value) { - if webcore::readable_stream::is_disturbed_value(body_value, global_object) { - return true; - } - return false; - } - - if let Some(readable) = self.readable.get() { - return readable.is_disturbed(global_object); - } - - false - } - - pub(crate) fn is_disturbed2(&self, global_object: &JSGlobalObject) -> bool { - if self.has_consumer() { - return true; - } - - if let Some(readable) = self.readable.get() { - return readable.is_disturbed(global_object); - } - - false - } - - fn to_any_blob_allow_promise(&mut self) -> Option { - let global = self.global(); - let mut stream = self.readable.get()?; - - if let Some(blob) = stream.to_any_blob(global) { - self.readable.deinit(); - return Some(blob); - } - - None + let blob = self.readable.get()?.to_any_blob(self.global())?; + self.readable.deinit(); + Some(blob) } /// [`Self::to_any_blob`] for `clone()`, going through the wrapper's cached `.body` when there is one. @@ -338,9 +324,6 @@ impl PendingValue { global: &JSGlobalObject, cached: Option, ) -> Option { - if self.promise.is_some() || self.on_receive_value.is_some() { - return None; - } let mut stream = cached.or_else(|| self.readable.get())?; // Two Blobs over a pipe, a tty, or any fd would compete for its bytes, // so a stream over such a file stays teed. @@ -365,47 +348,35 @@ impl PendingValue { ) -> JsResult { self.action = action; if let Some(readable) = owned_readable.or_else(|| self.readable.get()) { - match &mut self.action { - Action::GetFormData(_) - | Action::GetText - | Action::GetJSON - | Action::GetBlob - | Action::GetArrayBuffer - | Action::GetBytes => { - let promise = match &mut self.action { - Action::GetJSON => global_this.readable_stream_to_json(readable.value), - Action::GetArrayBuffer => { - global_this.readable_stream_to_array_buffer(readable.value) - } - Action::GetBytes => global_this.readable_stream_to_bytes(readable.value), - Action::GetText => global_this.readable_stream_to_text(readable.value), - Action::GetBlob => global_this.readable_stream_to_blob(readable.value), - Action::GetFormData(form_data) => 'brk: { - let fd = form_data.take().unwrap(); - let encoding_js = match &fd.encoding { - bun_core::form_data::Encoding::Multipart(multipart) => { - bun_string_jsc::create_utf8_for_js(global_this, multipart)? - } - bun_core::form_data::Encoding::URLEncoded => JSValue::UNDEFINED, - }; - // fd dropped at end of scope (Box -> Drop) - break 'brk global_this - .readable_stream_to_form_data(readable.value, encoding_js); + let promise = match &mut self.action { + Action::GetJSON => global_this.readable_stream_to_json(readable.value), + Action::GetArrayBuffer => { + global_this.readable_stream_to_array_buffer(readable.value) + } + Action::GetBytes => global_this.readable_stream_to_bytes(readable.value), + Action::GetText => global_this.readable_stream_to_text(readable.value), + Action::GetBlob => global_this.readable_stream_to_body_blob( + readable.value, + bun_string_jsc::create_utf8_for_js(global_this, self.content_type().given())?, + ), + Action::GetFormData(encoding) => { + let boundary = match *encoding.take().unwrap() { + webcore::form_data::Encoding::Multipart(boundary) => { + bun_string_jsc::create_utf8_for_js(global_this, &boundary)? } - _ => unreachable!(), + webcore::form_data::Encoding::URLEncoded => JSValue::UNDEFINED, }; - self.readable.deinit(); - if promise.is_ok() { - // The consumer holds its reader now; keep the lock once it lets go. - readable.mark_consumed_as_body(global_this); - } - // The ReadableStream within is expected to keep this Promise alive. - // If you try to protect() this, it will leak memory because the other end of the ReadableStream won't call it. - // See https://github.com/oven-sh/bun/issues/13678 - return promise; + global_this.readable_stream_to_form_data(readable.value, boundary) } - Action::None => {} + Action::None => unreachable!(), + }; + self.readable.deinit(); + if promise.is_ok() { + // The consumer holds its reader now; keep the lock once it lets go. + readable.mark_consumed_as_body(global_this); } + // Don't protect() this promise: the stream keeps it alive, and nothing would unprotect it (#13678). + return promise; } { @@ -432,20 +403,86 @@ pub(crate) enum Action { GetArrayBuffer, GetBytes, GetBlob, - GetFormData(Option>), + /// `read_body` fills it in from the owner's `Content-Type`. + GetFormData(Option>), } impl Action { pub(crate) fn is_none(&self) -> bool { matches!(self, Action::None) } + + /// Turns the whole body into what the reader asked for. `content_type` is the owner's, for `blob()`. + fn convert( + &mut self, + body: &mut Value, + content_type: blob::BlobContentType, + cx: &bun_jsc::JsThread<'_>, + ) -> JsResult { + if let Action::GetBlob = self { + let blob = body.use_(); + set_type_of_body_blob(&blob, content_type, true); + return Ok(blob.into_js(cx.global())); + } + + let mut blob = match self { + // The parser needs UTF-8. + Action::GetFormData(_) => body.use_as_any_blob(), + _ => body.use_as_any_blob_allow_non_utf8_string(), + }; + let result = match self { + Action::GetText => blob.to_string(cx, Lifetime::Transfer), + Action::GetJSON => blob.to_json(cx, Lifetime::Share), + Action::GetArrayBuffer => blob.to_array_buffer(cx, Lifetime::Transfer), + Action::GetBytes => blob.to_uint8_array(cx, Lifetime::Transfer), + Action::GetFormData(encoding) => { + let encoding = encoding.take().expect("read_body sets the encoding"); + webcore::form_data::FormData::to_js(cx.global(), blob.slice(), &encoding) + } + Action::None | Action::GetBlob => unreachable!(), + }; + blob.detach(); + result + } } -/// Tag-only equality. `GetFormData` payload is ignored. -impl PartialEq for Action { - fn eq(&self, other: &Self) -> bool { - core::mem::discriminant(self) == core::mem::discriminant(other) +/// `blob()`: the bytes keep their own type. Without one they get the owner's `content_type`, or else text/plain. +fn set_type_of_body_blob(blob: &Blob, content_type: blob::BlobContentType, or_else_text: bool) { + if !blob.content_type().is_empty() { + return; } + let content_type = if content_type.is_empty() && or_else_text && blob.store.get().is_some() { + bun_http_types::MimeType::TEXT.into() + } else { + content_type + }; + blob.content_type_was_set.set(!content_type.is_empty()); + blob.content_type.set(content_type); +} + +/// What [`Action::convert`] does for `blob()`, for the Blob that a stream's bytes were collected into. +#[unsafe(no_mangle)] +pub(crate) extern "C" fn Body__finishBlobOfStream( + global: &JSGlobalObject, + blob: JSValue, + content_type: JSValue, + facts: u8, +) { + let Some(blob) = blob.as_class_ref::() else { + return; + }; + // An exception stays pending for the caller. + let Ok(content_type) = content_type.to_utf8(global) else { + return; + }; + let facts = BodyFacts::from_bits_truncate(facts); + blob.body_facts.set(facts); + // What comes out of the program's own stream has no type unless the program gave it one. + set_type_of_body_blob( + blob, + blob::BlobContentType::Owned(content_type.slice().into()), + facts.contains(BodyFacts::NATIVE_STREAM), + ); } /// Per-class codegen'd cached-slot accessors for the `body` and `stream` @@ -655,7 +692,7 @@ impl Value { /// by the JSC heap cell and outlives the call only as long as `value` is /// kept alive by the caller. pub(crate) fn from_request_or_response(value: JSValue) -> Option<*mut Value> { - if value.is_empty_or_undefined_or_null() { + if !value.is_cell() || value.js_type() != jsc::JSType::DOMWrapper { return None; } if let Some(req) = value.as_class_ref::() { @@ -667,11 +704,64 @@ impl Value { None } - pub(crate) fn was_string(&self) -> bool { + pub(crate) fn facts(&self) -> BodyFacts { + match self { + Value::Blob(blob) => blob.body_facts.get(), + Value::InternalBlob(blob) => blob.facts, + Value::WTFStringImpl(_) => BodyFacts::STRING, + Value::Locked(locked) => locked.facts, + _ => BodyFacts::empty(), + } + } + + /// The `Content-Type` this body implies: what its owner's header list has if nothing else set one. + pub(crate) fn content_type(&self) -> &blob::BlobContentType { + static NONE: blob::BlobContentType = blob::BlobContentType::Static(b""); match self { - Value::InternalBlob(blob) => blob.was_string, - Value::WTFStringImpl(_) => true, - _ => false, + Value::Blob(blob) => blob.content_type.get(), + Value::Locked(locked) => locked.content_type(), + _ => &NONE, + } + } + + /// Fetch's "extract a body": the type goes in the header list unless the list has one. + pub(crate) fn put_content_type_default( + &self, + headers: &mut FetchHeaders, + global_this: &JSGlobalObject, + ) -> JsResult<()> { + let content_type = self.content_type(); + if content_type.is_empty() { + return Ok(()); + } + headers.put_default( + HTTPHeaderName::ContentType, + &StringView::ascii(content_type.as_slice()), + global_this, + ) + } + + /// Replaces a stream that nothing has read with the bytes lifted out of it. They keep the type this body implied. + fn replace_stream_with(&mut self, bytes: AnyBlob) { + let content_type = match self { + Value::Locked(locked) => locked.take_content_type(), + _ => blob::BlobContentType::default(), + }; + *self = Value::from(bytes); + self.keep_content_type(content_type); + } + + /// Gives the bytes that replaced a pending body the type which that body implied. + fn keep_content_type(&mut self, content_type: blob::BlobContentType) { + if content_type.is_empty() || content_type.as_slice() == self.content_type().as_slice() { + return; + } + if let Value::InternalBlob(internal) = self { + *self = Value::Blob(internal.to_blob(VirtualMachine::get().global())); + } + if let Value::Blob(blob) = self { + blob.content_type.set(content_type); + blob.content_type_was_set.set(true); } } } @@ -683,19 +773,20 @@ impl Value { // The UTF-8 buffer is already heap-owned by the slice wrapper; // transfer it (no copy). The deref is handled by `Value::drop` on the // overwritten `WTFStringImpl` variant — do NOT deref explicitly here. - *self = Value::InternalBlob(InternalBlob { - bytes, - was_string: true, - }); + *self = Value::InternalBlob(InternalBlob::from_string_bytes(bytes)); } } + self.lift_unread_stream(); + } + + /// A stream that nothing has read, and whose bytes are all at hand, goes back to being those bytes. + pub(crate) fn lift_unread_stream(&mut self) { let Value::Locked(locked) = self else { return; }; - if let Some(blob) = locked.to_any_blob() { - *self = Value::from(blob); + self.replace_stream_with(blob); } } @@ -758,6 +849,7 @@ impl Value { pub(crate) fn to_readable_stream(&mut self, cx: &bun_jsc::JsThread<'_>) -> JsResult { jsc::mark_binding(); + let content_type = self.content_type().clone(); // From here on the stream is the body: `.body`, `bodyUsed` and every reader go through it. let stream = match self { Value::Used => return ReadableStream::used(cx.global()), @@ -781,10 +873,10 @@ impl Value { ReadableStream::errored(cx.global(), reason)? } }; - *self = Value::from_readable_stream_without_lock_check( - ReadableStream::from_js_direct(stream).unwrap(), - cx.global(), - ); + let mut locked = + PendingValue::from_stream(ReadableStream::from_js_direct(stream).unwrap(), cx.global()); + locked.set_content_type(content_type); + *self = Value::Locked(locked); Ok(stream) } @@ -871,7 +963,6 @@ impl Value { // `to_readable_stream()` below (freed by the GC finalizer). let reader = webcore::readable_stream::NewSource::::new_mut( webcore::readable_stream::NewSource { - // `ByteStream::default()` is the post-setup state. context: ByteStream::default(), global_this: core::cell::Cell::new(Some(bun_ptr::BackRef::new(cx.global()))), ..Default::default() @@ -880,7 +971,6 @@ impl Value { reader.producer.set(locked.producer); - reader.context.setup(); reader.context.apply_drain_result(drain_result); let context_ptr: *mut ByteStream = &raw mut reader.context; @@ -889,6 +979,7 @@ impl Value { } else { reader.to_readable_stream(cx)? }; + ReadableStream::set_body_facts(stream_value, locked.facts); let readable = ReadableStream { ptr: webcore::readable_stream::Source::Bytes(context_ptr), value: stream_value, @@ -941,18 +1032,15 @@ impl Value { // The global allocator aborts on OOM, so a "Failed to clone // ArrayBufferView" error path is unreachable. - return Ok(Value::InternalBlob(InternalBlob { - bytes: bytes.to_vec(), - was_string: false, - })); + return Ok(Value::InternalBlob(bytes.to_vec().into())); } } if let Some(form_data) = DOMFormData::from_js(value) { - return Ok(Value::Blob(Blob::from_dom_form_data( - global_this, - form_data, - ))); + return match Blob::from_dom_form_data(global_this, form_data) { + Ok(blob) => Ok(Value::Blob(blob)), + Err(err) => Err(err.throw(global_this)), + }; } if let Some(search_params) = URLSearchParams::from_js(value) { @@ -964,12 +1052,12 @@ impl Value { if js_type == jsc::JSType::DOMWrapper { // `as_class_ref` is the safe shared-borrow downcast (one audited - // unsafe in `JSValue`); `dupe_with_content_type` / `encode_for_body` + // unsafe in `JSValue`); `dupe` / `encode_for_body` // both take `&self`. if let Some(blob) = value.as_class_ref::() { return Ok(Value::Blob( // We must preserve "type" so that DOMFormData and the "type" field are preserved. - blob.dupe_with_content_type(true), + blob.dupe(), )); } @@ -1016,19 +1104,27 @@ impl Value { readable: ReadableStream, global_this: &JSGlobalObject, ) -> Value { - Value::Locked(PendingValue { - readable: webcore::readable_stream::Strong::init(readable, global_this), - ..PendingValue::new(global_this) - }) + Value::Locked(PendingValue::from_stream(readable, global_this)) + } + + /// The producer has the whole body. It replaces the pending state, and the reader that waited for it gets it. + pub(crate) fn fulfill( + &mut self, + bytes: Vec, + cx: &bun_jsc::JsThread<'_>, + ) -> jsc::JsResult<()> { + let facts = self.facts(); + let content_type = self.content_type().clone(); + let mut pending = + core::mem::replace(self, Value::InternalBlob(InternalBlob { bytes, facts })); + self.keep_content_type(content_type); + pending.resolve(self, cx) } pub(crate) fn resolve( &mut self, new: &mut Value, cx: &bun_jsc::JsThread<'_>, - // Opaque C++ handle, mutated via FFI. Taking - // `NonNull` (not `&`/`&mut`) avoids manufacturing aliased Rust borrows. - headers: Option>, ) -> jsc::JsResult<()> { bun_core::scoped_log!(BodyValue, "resolve"); if let Value::Locked(locked) = self { @@ -1064,84 +1160,11 @@ impl Value { if let Some(promise_) = locked.promise.take() { let promise = promise_.as_any_promise().unwrap(); - - match &mut locked.action { - // These ones must use promise.wrap() to handle exceptions thrown while calling .toJS() on the value. - // These exceptions can happen if the String is too long, ArrayBuffer is too large, JSON parse error, etc. - Action::GetText => match new { - Value::WTFStringImpl(_) | Value::InternalBlob(_) => { - let mut blob = new.use_as_any_blob_allow_non_utf8_string(); - let result = promise.wrap(cx.global(), |_| blob.to_string_transfer(cx)); - blob.detach(); - result?; - } - _ => { - let blob = new.use_(); - promise.wrap(cx.global(), |_| blob.to_string_transfer(cx))?; - } - }, - Action::GetJSON => { - let mut blob = new.use_as_any_blob_allow_non_utf8_string(); - let result = promise.wrap(cx.global(), |_| blob.to_json_share(cx)); - blob.detach(); - result?; - } - Action::GetArrayBuffer => { - let mut blob = new.use_as_any_blob_allow_non_utf8_string(); - let result = - promise.wrap(cx.global(), |_| blob.to_array_buffer_transfer(cx)); - blob.detach(); - result?; - } - Action::GetBytes => { - let mut blob = new.use_as_any_blob_allow_non_utf8_string(); - let result = - promise.wrap(cx.global(), |_| blob.to_uint8_array_transfer(cx)); - blob.detach(); - result?; - } - Action::GetFormData(form_data_slot) => 'inner: { - let mut blob = new.use_as_any_blob(); - let Some(async_form_data) = form_data_slot.take() else { - // `blob.detach()` below covers the reject error path too. - let r = promise.reject( - cx.global(), - cx.global().create_error_instance(format_args!( - "Internal error: task for FormData must not be null" - )), - ); - blob.detach(); - r?; - break 'inner; - }; - // `webcore::form_data::AsyncFormData` re-exports `bun_core::form_data::AsyncFormData`; - // `to_js` is provided via the `AsyncFormDataExt` extension trait. - let result = async_form_data.to_js(cx.global(), blob.slice(), promise); - blob.detach(); - // async_form_data dropped (Box -> Drop replaces deinit) - result?; - } - Action::None | Action::GetBlob => { - let blob = new.use_(); - if let Some(fetch_headers) = headers { - // `headers` is a live C++ FetchHeaders handle; - // `FetchHeaders` is an opaque ZST FFI handle (S008) — safe deref. - let fetch_headers = - bun_opaque::opaque_deref_mut(fetch_headers.as_ptr()); - if let Some(content_type) = - fetch_headers.fast_get(HTTPHeaderName::ContentType) - { - let content_slice = content_type.to_utf8(); - let mime_type = MimeType::init(content_slice.slice(), true, None); - set_blob_content_type(&blob, mime_type); - } - } - if !blob.content_type_was_set.get() && blob.store.get().is_some() { - set_blob_content_type(&blob, bun_http_types::MimeType::TEXT); - } - promise.resolve(cx.global(), blob.into_js(cx.global()))?; - } - } + let content_type = locked.take_content_type(); + // `wrap` turns what the conversion throws (a string too long, a JSON parse error) into a rejection. + promise.wrap(cx.global(), |_| { + locked.action.convert(new, content_type, cx) + })?; promise_.unprotect(); } } @@ -1164,13 +1187,7 @@ impl Value { Value::InternalBlob(ib) => { // SAFETY: VirtualMachine::get() returns the live per-thread VM. let global = VirtualMachine::get().global(); - let new_blob = Blob::init( - ib.to_owned_slice(), - // we will never resize it from here - // we have to use the default allocator - // even if it was actually allocated on a different thread - global, - ); + let new_blob = ib.to_blob(global); *self = Value::Used; new_blob } @@ -1184,6 +1201,7 @@ impl Value { // SAFETY: VirtualMachine::get() returns the live per-thread VM. let global = VirtualMachine::get().global(); let new_blob = Blob::init(wtf_ref.to_utf8().into_vec(), global); + new_blob.body_facts.set(BodyFacts::STRING); // Release the +1 the body held. wtf_ref.deref(); new_blob @@ -1214,76 +1232,35 @@ impl Value { unsafe { WTFString::adopt(ptr) } } + /// Returns the bytes if they are at hand and need no conversion, and leaves the body used. pub(crate) fn try_use_as_any_blob(&mut self) -> Option { - let any_blob: AnyBlob = match self { - Value::Blob(b) => AnyBlob::Blob(core::mem::take(b)), - Value::InternalBlob(b) => AnyBlob::InternalBlob(core::mem::take(b)), - Value::WTFStringImpl(str) => { - if wtf_impl(str).can_use_as_utf8() { - return Some(AnyBlob::WTFStringImpl(self.take_wtf_string())); - } else { - return None; - } - } - // `?` on the Option early-returns None; on Some it falls through - // to the `*self = Value::Used` assignment below. - Value::Locked(l) => l.to_any_blob_allow_promise()?, + self.lift_unread_stream(); + match self { + Value::Blob(_) | Value::InternalBlob(_) => {} + Value::WTFStringImpl(str) if wtf_impl(str).can_use_as_utf8() => {} _ => return None, - }; - - *self = Value::Used; - Some(any_blob) + } + Some(self.use_as_any_blob_allow_non_utf8_string()) } + /// [`Self::use_as_any_blob_allow_non_utf8_string`], with a string that isn't ASCII as its UTF-8 bytes. pub(crate) fn use_as_any_blob(&mut self) -> AnyBlob { - let was_null = matches!(self, Value::Null); - // `Value` has `Drop`, so we cannot `mem::replace` then - // destructure by value (E0509). Match by `&mut` and `mem::take` the - // payload; the trailing `*self = Used/Null` runs `Value::drop` on the - // emptied/residual variant (no-op for taken Blob/InternalBlob, releases - // the +1 for the UTF-8-converted WTFStringImpl arm, deinit for Locked). - let any_blob: AnyBlob = match self { - Value::Blob(b) => AnyBlob::Blob(core::mem::take(b)), - Value::InternalBlob(b) => AnyBlob::InternalBlob(core::mem::take(b)), - Value::WTFStringImpl(str) => 'brk: { - let str = *str; - let wtf_ref = wtf_impl(&str); - if let Utf8Bytes::Owned(utf8) = wtf_ref.to_utf8() { - // The deref is handled by `Value::drop` on the - // assignment below (the variant is still `WTFStringImpl(str)`). - break 'brk AnyBlob::InternalBlob(InternalBlob { - // Transfer ownership of the heap-allocated UTF-8 buffer (no copy). - bytes: utf8, - was_string: true, - }); - } else { - break 'brk AnyBlob::WTFStringImpl(self.take_wtf_string()); - } - } - Value::Locked(l) => l - .to_any_blob_allow_promise() - .unwrap_or(AnyBlob::Blob(Blob::default())), - _ => AnyBlob::Blob(Blob::default()), - }; - - *self = if was_null { Value::Null } else { Value::Used }; - any_blob + self.to_blob_if_possible(); + self.use_as_any_blob_allow_non_utf8_string() } + /// Takes the bytes and leaves the body used. A null body stays null. pub(crate) fn use_as_any_blob_allow_non_utf8_string(&mut self) -> AnyBlob { - let was_null = matches!(self, Value::Null); - // see `use_as_any_blob` — match by `&mut` to avoid E0509. - let any_blob: AnyBlob = match self { + self.lift_unread_stream(); + // `Value` has `Drop`, so a payload can't be moved out of it by value (E0509). + let any_blob = match self { Value::Blob(b) => AnyBlob::Blob(core::mem::take(b)), Value::InternalBlob(b) => AnyBlob::InternalBlob(core::mem::take(b)), - Value::WTFStringImpl(_) => AnyBlob::WTFStringImpl(self.take_wtf_string()), - Value::Locked(l) => l - .to_any_blob_allow_promise() - .unwrap_or(AnyBlob::Blob(Blob::default())), + Value::WTFStringImpl(_) => return AnyBlob::WTFStringImpl(self.take_wtf_string()), + Value::Null => return AnyBlob::Blob(Blob::default()), _ => AnyBlob::Blob(Blob::default()), }; - - *self = if was_null { Value::Null } else { Value::Used }; + *self = Value::Used; any_blob } @@ -1415,8 +1392,14 @@ impl Value { // Caller guarantees `self` is `Locked` at entry. unreachable!("tee() called on non-Locked Value"); }; + // The copy of a body that is being read is a used body. A locked stream that script made throws from its tee() below. + let unusable = |readable: &ReadableStream| { + readable.is_disturbed(cx.global()) + || (!matches!(readable.ptr, webcore::readable_stream::Source::JavaScript) + && readable.is_locked(cx.global())) + }; if let Some(readable) = owned_readable { - if readable.is_disturbed(cx.global()) { + if unusable(readable) { return Ok(Value::Used); } @@ -1424,85 +1407,34 @@ impl Value { // Keep the current readable as a strong reference when cloning, and return the second one in the result. // This will be checked and downgraded to a write barrier if needed. locked.readable = webcore::readable_stream::Strong::init(rs0, cx.global()); - return Ok(Value::Locked(PendingValue { - readable: webcore::readable_stream::Strong::init(rs1, cx.global()), - ..PendingValue::new(cx.global()) - })); + return Ok(locked.tee_branch(rs1, cx.global())); } } - if locked.readable.is_disturbed(cx.global()) { + if locked + .readable + .get() + .is_some_and(|readable| unusable(&readable)) + { return Ok(Value::Used); } if let Some(readable) = locked.readable.tee(cx.global())? { - return Ok(Value::Locked(PendingValue { - readable: webcore::readable_stream::Strong::init(readable, cx.global()), - ..PendingValue::new(cx.global()) - })); + return Ok(locked.tee_branch(readable, cx.global())); } if locked.has_consumer() || locked.readable.has() { return Ok(Value::Used); } - let mut drain_result = DrainResult::EstimatedSize(0); - - if let Some(drain) = locked.on_start_streaming.take() { - drain_result = drain(locked.task.unwrap()); - } - - if matches!(drain_result, DrainResult::Aborted) { - *self = Value::Null; - return Ok(Value::Null); - } - - // Ownership of the heap `NewSource` transfers to the JS wrapper in - // `to_readable_stream()` below (freed by the GC finalizer). - let reader = webcore::readable_stream::NewSource::::new_mut( - webcore::readable_stream::NewSource { - context: ByteStream::default(), - global_this: core::cell::Cell::new(Some(bun_ptr::BackRef::new(cx.global()))), - ..Default::default() - }, - ); - - reader.context.setup(); - reader.context.apply_drain_result(drain_result); - - // reshaped for borrowck — re-borrow locked after the early *self = Null path above. + self.locked_to_native_stream(cx, false)?; + // The producer had aborted. let Value::Locked(locked) = self else { - unreachable!() - }; - - reader.producer.set(locked.producer); - - let context_ptr: *mut ByteStream = &raw mut reader.context; - locked.readable = webcore::readable_stream::Strong::init( - ReadableStream { - ptr: webcore::readable_stream::Source::Bytes(context_ptr), - value: reader.to_readable_stream(cx)?, - }, - cx.global(), - ); - - if let Some(on_readable_stream_available) = locked.on_readable_stream_available.take() { - on_readable_stream_available( - locked.task.unwrap(), - cx.global(), - locked.readable.get().unwrap(), - ); - } - locked.detach_producer(); - - let teed = match locked.readable.tee(cx.global())? { - Some(t) => t, - None => return Ok(Value::Used), + return Ok(Value::Null); }; - - Ok(Value::Locked(PendingValue { - readable: webcore::readable_stream::Strong::init(teed, cx.global()), - ..PendingValue::new(cx.global()) - })) + Ok(match locked.readable.tee(cx.global())? { + Some(teed) => locked.tee_branch(teed, cx.global()), + None => Value::Used, + }) } pub(crate) fn clone(&mut self, cx: &bun_jsc::JsThread<'_>) -> JsResult { @@ -1521,7 +1453,7 @@ impl Value { // Anything else is teed. if let Value::Locked(locked) = self { match locked.take_blob_from_unread_stream(cx.global(), readable.as_deref().copied()) { - Some(blob) => *self = Value::from(blob), + Some(blob) => self.replace_stream_with(blob), None => return self.tee(cx, readable), } } @@ -1529,8 +1461,7 @@ impl Value { self.to_blob_if_possible(); if let Value::InternalBlob(internal_blob) = self { - let owned = internal_blob.to_owned_slice(); - *self = Value::Blob(Blob::init(owned, cx.global())); + *self = Value::Blob(internal_blob.to_blob(cx.global())); } if let Value::Blob(b) = self { @@ -1540,7 +1471,7 @@ impl Value { self.to_readable_stream(cx)?; return self.tee(cx, None); } - return Ok(Value::Blob(b.dupe_with_content_type(false))); + return Ok(Value::Blob(b.dupe())); } if let Value::WTFStringImpl(s) = *self { @@ -1581,7 +1512,7 @@ pub(crate) fn extract(global_this: &JSGlobalObject, value: JSValue) -> JsResult< // ──────────────────────────────────────────────────────────────────────────── /// Mixin trait with provided methods. -/// Implementers supply `get_body_value`, `get_fetch_headers`, `get_form_data_encoding`, +/// Implementers supply `get_body_value`, `get_content_type`, `ensure_headers`, /// and optionally override `get_body_readable_stream`. pub(crate) trait BodyMixin: BodyOwnerJs + Sized { /// R-2 interior-mutability boundary: implementors project `&mut Value` @@ -1590,12 +1521,18 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { /// short and do not hold it across a call that re-enters JS. #[allow(clippy::mut_from_ref)] fn get_body_value(&self) -> &mut Value; - /// `FetchHeaders` is an - /// opaque, intrusively-refcounted C++ handle whose accessors take `&mut self` - /// (FFI signature is `*mut`). Returning `NonNull` instead of `&FetchHeaders` - /// avoids deriving `&mut T` from `&T` at the call sites (UB). - fn get_fetch_headers(&self) -> Option>; - fn get_form_data_encoding(&self) -> JsResult>>; + /// The `Content-Type` header, or else the type the body implies. + fn get_content_type(&self) -> Option>; + /// 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 spends the body: a used body no longer has the `Content-Type`. + fn copy_content_type_to_headers(&self, global_this: &JSGlobalObject) -> JsResult<()> { + if !self.get_body_value().content_type().is_empty() { + self.ensure_headers(global_this)?; + } + Ok(()) + } // ──────────────────────────────────────────────────────────────────── // Twin methods (identical for Request/Response); [`BodyOwnerJs`] reaches each class's generated module. @@ -1682,7 +1619,7 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { self.check_body_stream_ref(global_this); } - /// Shared body-clone for `clone_into` / `clone_value`: clone through the + /// Shared body-clone for `Request::cloned` / `clone_value`: clone through the /// JS-side cached stream when present, then resync this owner's /// `body`/`stream` cache slots with whatever the body now holds. fn clone_body_value_via_cached_stream(&self, cx: &bun_jsc::JsThread<'_>) -> JsResult { @@ -1706,43 +1643,106 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { Ok(cloned) } - fn text(&self, this_value: JsWrapper<'_, Self>, cx: &mut JsScope<'_>) -> JsResult { - let cx = cx.js_thread(); + /// What every reader does: rejects if the body can't be read, else reads all of it as `action` says. + fn read_body(&self, cx: &bun_jsc::JsThread<'_>, mut action: Action) -> JsResult { let global_object = cx.global(); - let value = self.get_body_value(); - if matches!(value, Value::Used) { - return Ok(handle_body_already_used(global_object)); - } - if let Some(rejected) = handle_body_error(value, global_object) { + match self.body_unusable(global_object) { + Some(Unusable::Disturbed) => { + return Ok(global_object + .err( + jsc::ErrorCode::BODY_ALREADY_USED, + format_args!("Body already used"), + ) + .reject()); + } + Some(Unusable::Locked) => { + return Ok(global_object + .err( + jsc::ErrorCode::INVALID_STATE_TypeError, + format_args!("Invalid state: ReadableStream is locked"), + ) + .reject()); + } + None => {} + } + if let Some(rejected) = handle_body_error(self.get_body_value(), global_object) { return Ok(rejected); } - if matches!(value, Value::Locked(_)) { - if let Some(readable) = self.get_body_readable_stream() { - if let Some(rejected) = handle_body_stream_unusable(&readable, global_object) { - return Ok(rejected); - } - let value = self.get_body_value(); - if let Value::Locked(locked) = value { - return locked.set_promise(global_object, Action::GetText, Some(readable)); + // Asked before the stream's bytes are lifted out: their own type isn't one this body implied. + let implies_content_type = !self.get_body_value().content_type().is_empty(); + self.get_body_value().lift_unread_stream(); + + let mut content_type = blob::BlobContentType::default(); + match &mut action { + Action::GetFormData(encoding) => { + *encoding = self + .get_content_type() + .and_then(|content_type| { + webcore::form_data::Encoding::get(content_type.slice()) + }) + .map(Box::new); + if encoding.is_none() { + // TODO: catch specific errors from getFormDataEncoding + return Ok(global_object + .err( + jsc::ErrorCode::FORMDATA_PARSE_ERROR, + format_args!( + "Can't decode form data from body because of incorrect MIME type/boundary" + ), + ) + .reject()); } } - let value = self.get_body_value(); - if let Value::Locked(locked) = value { - if !locked.action.is_none() - || locked.is_disturbed::(global_object, this_value.to_js()) - { - return Ok(handle_body_already_used(global_object)); + Action::GetBlob => { + if let Some(header) = self.get_content_type() { + content_type = MimeType::init(header.slice(), true).into(); } - return locked.set_promise(global_object, Action::GetText, None); } + _ => {} } - let value = self.get_body_value(); - let mut blob = value.use_as_any_blob_allow_non_utf8_string(); - let result = JSPromise::wrap(global_object, |_| blob.to_string(&cx, Lifetime::Transfer)); - blob.detach(); - result + // A used body no longer has the type. + if implies_content_type { + self.ensure_headers(global_object)?; + } + if matches!(self.get_body_value(), Value::Locked(_)) { + let readable = self.get_body_readable_stream(); + let Value::Locked(locked) = self.get_body_value() else { + unreachable!() + }; + if matches!(action, Action::GetBlob) { + locked.set_content_type(content_type); + } + return locked.set_promise(global_object, action, readable); + } + JSPromise::wrap(global_object, |_| { + action.convert(self.get_body_value(), content_type, cx) + }) + } + + fn text(&self, cx: &mut JsScope<'_>) -> JsResult { + self.read_body(&cx.js_thread(), Action::GetText) + } + + fn json(&self, cx: &mut JsScope<'_>) -> JsResult { + self.read_body(&cx.js_thread(), Action::GetJSON) + } + + fn array_buffer(&self, cx: &mut JsScope<'_>) -> JsResult { + self.read_body(&cx.js_thread(), Action::GetArrayBuffer) + } + + fn bytes(&self, cx: &mut JsScope<'_>) -> JsResult { + self.read_body(&cx.js_thread(), Action::GetBytes) + } + + fn form_data(&self, cx: &mut JsScope<'_>) -> JsResult { + self.read_body(&cx.js_thread(), Action::GetFormData(None)) + } + + fn blob(&self, cx: &mut JsScope<'_>) -> JsResult { + self.read_body(&cx.js_thread(), Action::GetBlob) } fn get_body(&self, cx: &mut JsScope<'_>) -> JsResult { @@ -1772,6 +1772,7 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { let global_this = cx.global(); // Step 1: If this is unusable, throw a TypeError. self.throw_if_body_unusable(global_this)?; + self.copy_content_type_to_headers(global_this)?; // A `Locked` body whose stream is already materialized (user-provided // ReadableStream, or `.body` was accessed first) is decoded via a reader @@ -1794,45 +1795,34 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { Ok(stream) } - /// `Used` / in-flight-read bodies are unconditionally `true`; otherwise - /// `check` decides from the body's ReadableStream (JS `stream` cache - /// first, then `Locked.readable`). Bodies with no stream yet are `false`. - fn body_stream_check( - &self, - global_object: &JSGlobalObject, - check: fn(&ReadableStream, &JSGlobalObject) -> bool, - ) -> bool { - // reshaped for borrowck — `get_body_readable_stream` needs `&self`, - // so we can't hold a `match` borrow on `get_body_value()` across it. + /// Says why nothing more can read the body, if so. + fn body_unusable(&self, global_object: &JSGlobalObject) -> Option { match self.get_body_value() { - Value::Used => true, - Value::Locked(pending) if !pending.action.is_none() => true, - Value::Locked(_) => 'brk: { - if let Some(readable) = self.get_body_readable_stream() { - break 'brk check(&readable, global_object); - } - if let Value::Locked(pending) = self.get_body_value() { - if let Some(stream) = pending.readable.get() { - break 'brk check(&stream, global_object); - } - } - false - } - _ => false, + Value::Used => return Some(Unusable::Disturbed), + Value::Locked(pending) if pending.has_consumer() => return Some(Unusable::Disturbed), + Value::Locked(_) => {} + _ => return None, + } + // A stream that was detached from the body is still the one `.body` returned. + let readable = self + .get_body_readable_stream() + .or_else(|| ReadableStream::from_js_direct(Self::body_get_cached(self.js_ref()?)?))?; + if readable.is_disturbed(global_object) { + Some(Unusable::Disturbed) + } else if readable.is_locked(global_object) { + Some(Unusable::Locked) + } else { + None } } fn get_body_used(&self, cx: &mut JsScope<'_>) -> bool { - self.body_stream_check(cx.global(), ReadableStream::is_disturbed) + self.body_unusable(cx.global()) == Some(Unusable::Disturbed) } - /// Fetch spec step 1 of both `clone()` algorithms: throw a `TypeError` - /// when "this is unusable", i.e. the body is non-null and its stream is - /// disturbed or locked. + /// Step 1 of `clone()` and `textStream()`. fn throw_if_body_unusable(&self, global_object: &JSGlobalObject) -> JsResult<()> { - let unusable = - self.body_stream_check(global_object, ReadableStream::is_disturbed_or_locked); - if unusable { + if self.body_unusable(global_object).is_some() { return Err(global_object .err( jsc::ErrorCode::BODY_ALREADY_USED, @@ -1842,361 +1832,13 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { } Ok(()) } - - fn json(&self, this_value: JsWrapper<'_, Self>, cx: &mut JsScope<'_>) -> JsResult { - let cx = cx.js_thread(); - let global_object = cx.global(); - let value = self.get_body_value(); - if matches!(value, Value::Used) { - return Ok(handle_body_already_used(global_object)); - } - if let Some(rejected) = handle_body_error(value, global_object) { - return Ok(rejected); - } - - if matches!(value, Value::Locked(_)) { - if let Some(readable) = self.get_body_readable_stream() { - if let Some(rejected) = handle_body_stream_unusable(&readable, global_object) { - return Ok(rejected); - } - let value = self.get_body_value(); - value.to_blob_if_possible(); - if let Value::Locked(locked) = value { - return locked.set_promise(global_object, Action::GetJSON, Some(readable)); - } - } - let value = self.get_body_value(); - if let Value::Locked(locked) = value { - if !locked.action.is_none() - || locked.is_disturbed::(global_object, this_value.to_js()) - { - return Ok(handle_body_already_used(global_object)); - } - // reshaped for borrowck - let value = self.get_body_value(); - value.to_blob_if_possible(); - if let Value::Locked(locked) = value { - return locked.set_promise(global_object, Action::GetJSON, None); - } - } - } - - let value = self.get_body_value(); - let mut blob = value.use_as_any_blob_allow_non_utf8_string(); - let result = JSPromise::wrap(global_object, |_| blob.to_json(&cx, Lifetime::Share)); - blob.detach(); - result - } - - fn array_buffer( - &self, - this_value: JsWrapper<'_, Self>, - cx: &mut JsScope<'_>, - ) -> JsResult { - let cx = cx.js_thread(); - let global_object = cx.global(); - bun_core::scoped_log!(BodyMixin, "getArrayBuffer"); - let value = self.get_body_value(); - - if matches!(value, Value::Used) { - return Ok(handle_body_already_used(global_object)); - } - if let Some(rejected) = handle_body_error(value, global_object) { - return Ok(rejected); - } - - if matches!(value, Value::Locked(_)) { - if let Some(readable) = self.get_body_readable_stream() { - if let Some(rejected) = handle_body_stream_unusable(&readable, global_object) { - return Ok(rejected); - } - let value = self.get_body_value(); - value.to_blob_if_possible(); - if let Value::Locked(locked) = value { - return locked.set_promise( - global_object, - Action::GetArrayBuffer, - Some(readable), - ); - } - } - let value = self.get_body_value(); - if let Value::Locked(locked) = value { - if !locked.action.is_none() - || locked.is_disturbed::(global_object, this_value.to_js()) - { - return Ok(handle_body_already_used(global_object)); - } - let value = self.get_body_value(); - value.to_blob_if_possible(); - if let Value::Locked(locked) = value { - return locked.set_promise(global_object, Action::GetArrayBuffer, None); - } - } - } - - // 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(); - let result = JSPromise::wrap(global_object, |_| { - blob.to_array_buffer(&cx, Lifetime::Transfer) - }); - blob.detach(); - result - } - - fn bytes(&self, this_value: JsWrapper<'_, Self>, cx: &mut JsScope<'_>) -> JsResult { - let cx = cx.js_thread(); - let global_object = cx.global(); - let value = self.get_body_value(); - - if matches!(value, Value::Used) { - return Ok(handle_body_already_used(global_object)); - } - if let Some(rejected) = handle_body_error(value, global_object) { - return Ok(rejected); - } - - if matches!(value, Value::Locked(_)) { - if let Some(readable) = self.get_body_readable_stream() { - if let Some(rejected) = handle_body_stream_unusable(&readable, global_object) { - return Ok(rejected); - } - let value = self.get_body_value(); - value.to_blob_if_possible(); - if let Value::Locked(locked) = value { - return locked.set_promise(global_object, Action::GetBytes, Some(readable)); - } - } - let value = self.get_body_value(); - if let Value::Locked(locked) = value { - if !locked.action.is_none() - || locked.is_disturbed::(global_object, this_value.to_js()) - { - return Ok(handle_body_already_used(global_object)); - } - let value = self.get_body_value(); - value.to_blob_if_possible(); - if let Value::Locked(locked) = value { - return locked.set_promise(global_object, Action::GetBytes, None); - } - } - } - - // 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(); - let result = JSPromise::wrap(global_object, |_| { - blob.to_uint8_array(&cx, Lifetime::Transfer) - }); - blob.detach(); - result - } - - fn form_data( - &self, - this_value: JsWrapper<'_, Self>, - cx: &mut JsScope<'_>, - ) -> JsResult { - let global_object = cx.global(); - let value = self.get_body_value(); - - if matches!(value, Value::Used) { - return Ok(handle_body_already_used(global_object)); - } - if let Some(rejected) = handle_body_error(value, global_object) { - return Ok(rejected); - } - - if matches!(value, Value::Locked(_)) { - if let Some(readable) = self.get_body_readable_stream() { - if let Some(rejected) = handle_body_stream_unusable(&readable, global_object) { - return Ok(rejected); - } - let value = self.get_body_value(); - value.to_blob_if_possible(); - } - let value = self.get_body_value(); - if let Value::Locked(locked) = value { - if !locked.action.is_none() - || locked.is_disturbed::(global_object, this_value.to_js()) - { - return Ok(handle_body_already_used(global_object)); - } - let value = self.get_body_value(); - value.to_blob_if_possible(); - } - } - - let Some(encoder) = self.get_form_data_encoding()? else { - // TODO: catch specific errors from getFormDataEncoding - return Ok(global_object - .err( - jsc::ErrorCode::FORMDATA_PARSE_ERROR, - format_args!( - "Can't decode form data from body because of incorrect MIME type/boundary" - ), - ) - .reject()); - }; - - let value = self.get_body_value(); - if let Value::Locked(_locked) = value { - let owned_readable = self.get_body_readable_stream(); - // reshaped for borrowck — re-borrow after self method call. - let value = self.get_body_value(); - let Value::Locked(locked) = value else { - unreachable!() - }; - return locked.set_promise( - global_object, - Action::GetFormData(Some(encoder)), - owned_readable, - ); - } - - let mut blob: AnyBlob = value.use_as_any_blob(); - // `encoder.encoding` is `bun_core::form_data::Encoding`; convert - // to the `webcore::form_data::Encoding` shape FormData::to_js expects. - let encoding = match encoder.encoding { - bun_core::form_data::Encoding::URLEncoded => webcore::form_data::Encoding::URLEncoded, - bun_core::form_data::Encoding::Multipart(b) => { - webcore::form_data::Encoding::Multipart(b) - } - }; - // encoder dropped at end of scope (replaces defer encoder.deinit()) - - let js_value = - match webcore::form_data::FormData::to_js(global_object, blob.slice(), &encoding) { - Ok(v) => v, - Err(crate::Error::JSError) => { - blob.detach(); - let error = global_object.take_error(jsc::JsError::Thrown); - return Ok(JSPromise::rejected_promise(global_object, error).to_js()); - } - Err(err) => { - blob.detach(); - return Ok(global_object - .err( - jsc::ErrorCode::FORMDATA_PARSE_ERROR, - format_args!("FormData parse error {}", err.name()), - ) - .reject()); - } - }; - blob.detach(); - - Ok(JSPromise::wrap_value(global_object, js_value)) - } - - fn blob(&self, this_value: JsWrapper<'_, Self>, cx: &mut JsScope<'_>) -> JsResult { - self.get_blob_with_this_value(cx.global(), this_value.to_js()) - } - - fn get_blob_with_this_value( - &self, - global_object: &JSGlobalObject, - this_value: JSValue, - ) -> JsResult { - let value = self.get_body_value(); - - if matches!(value, Value::Used) { - return Ok(handle_body_already_used(global_object)); - } - if let Some(rejected) = handle_body_error(value, global_object) { - return Ok(rejected); - } - - if matches!(value, Value::Locked(_)) { - if let Some(readable) = self.get_body_readable_stream() { - let value = self.get_body_value(); - let Value::Locked(locked) = value else { - unreachable!() - }; - if !locked.action.is_none() { - return Ok(handle_body_already_used(global_object)); - } - if let Some(rejected) = handle_body_stream_unusable(&readable, global_object) { - return Ok(rejected); - } - value.to_blob_if_possible(); - if let Value::Locked(locked) = value { - return locked.set_promise(global_object, Action::GetBlob, Some(readable)); - } - } - let value = self.get_body_value(); - if let Value::Locked(locked) = value { - if !locked.action.is_none() - || ((!this_value.is_empty() - && locked.is_disturbed::(global_object, this_value)) - || (this_value.is_empty() && locked.readable.is_disturbed(global_object))) - { - return Ok(handle_body_already_used(global_object)); - } - let value = self.get_body_value(); - value.to_blob_if_possible(); - if let Value::Locked(locked) = value { - return locked.set_promise(global_object, Action::GetBlob, None); - } - } - } - - let value = self.get_body_value(); - let blob = value.use_(); - if blob.content_type().is_empty() { - if let Some(fetch_headers) = BodyMixin::get_fetch_headers(self) { - // `fetch_headers` is a live C++ FetchHeaders handle; - // `FetchHeaders` is an opaque ZST FFI handle (S008) — safe deref. - let fetch_headers = bun_opaque::opaque_deref_mut(fetch_headers.as_ptr()); - if let Some(content_type) = fetch_headers.fast_get(HTTPHeaderName::ContentType) { - let content_slice = content_type.to_utf8(); - let mime_type = MimeType::init(content_slice.slice(), true, None); - set_blob_content_type(&blob, mime_type); - } - } - if !blob.content_type_was_set.get() && blob.store.get().is_some() { - set_blob_content_type(&blob, bun_http_types::MimeType::TEXT); - } - } - Ok(JSPromise::resolved_promise_value( - global_object, - blob.into_js(global_object), - )) - } - - fn get_blob_without_call_frame(&self, global_object: &JSGlobalObject) -> JsResult { - self.get_blob_with_this_value(global_object, JSValue::ZERO) - } -} - -fn handle_body_already_used(global_object: &JSGlobalObject) -> JSValue { - global_object - .err( - jsc::ErrorCode::BODY_ALREADY_USED, - format_args!("Body already used"), - ) - .reject() } -/// : a disturbed or locked stream rejects every reader. -fn handle_body_stream_unusable( - readable: &ReadableStream, - global_object: &JSGlobalObject, -) -> Option { - if readable.is_disturbed(global_object) { - return Some(handle_body_already_used(global_object)); - } - if readable.is_locked(global_object) { - return Some( - global_object - .err( - jsc::ErrorCode::INVALID_STATE_TypeError, - format_args!("Invalid state: ReadableStream is locked"), - ) - .reject(), - ); - } - None +/// +#[derive(PartialEq, Eq)] +pub(crate) enum Unusable { + Disturbed, + Locked, } /// If the body already failed, reject the read with that error. Every body diff --git a/src/runtime/webcore/ByteStream.rs b/src/runtime/webcore/ByteStream.rs index 2ace9f8388ba..eea5a2c9a8cc 100644 --- a/src/runtime/webcore/ByteStream.rs +++ b/src/runtime/webcore/ByteStream.rs @@ -197,6 +197,14 @@ impl ProducerHold { self.source.get().is_some() } + /// Whether the hold keeps the stream's wrapper alive, so that no collection can find the stream. + pub(crate) fn roots_wrapper(&self) -> bool { + // SAFETY: live through our ref. + self.source + .get() + .is_some_and(|source| !unsafe { source.as_ref() }.wrapper_unrooted.get()) + } + /// The held stream, pinned for the guard's life: a consumer inside `on_data` can cancel the /// producer (which drops the hold), and the wrapper is not always rooted. pub(crate) fn bytes(&self) -> Option { @@ -425,14 +433,7 @@ impl ByteStream { /// Init-time reset. Runs before the JS /// wrapper exists, so `&mut self` is sound here (R-2 exemption). - pub(crate) fn setup(&mut self) { - // Called immediately after `ByteStream::default()` construction; - // the old value owns nothing the new one - // reuses, so dropping it is the intended reset. - drop(core::mem::take(self)); - } - - /// Seeds the stream from the drain result; init-time like [`Self::setup`]. + /// Seeds a new stream from the drain result. pub(crate) fn apply_drain_result(&mut self, drain_result: DrainResult) { match drain_result { DrainResult::EstimatedSize(estimated_size) => { @@ -955,10 +956,7 @@ impl ByteStream { p.result = streams::Result::Done; }); self.parent_const().is_closed.set(true); - return Some(blob::Any::InternalBlob(blob::Internal { - bytes: buffer, - was_string: false, - })); + return Some(blob::Any::from_owned_slice(buffer)); } None diff --git a/src/runtime/webcore/FileReader.rs b/src/runtime/webcore/FileReader.rs index d7641885292b..8a6aa5ab3b24 100644 --- a/src/runtime/webcore/FileReader.rs +++ b/src/runtime/webcore/FileReader.rs @@ -899,7 +899,8 @@ impl FileReader { self.flowing.get() ); - if self.flowing.get() == flag { + // Before `on_start` the reader has no parent to hand a read to, and nothing to pause. + if !self.started.get() || self.flowing.get() == flag { return; } @@ -921,6 +922,12 @@ impl FileReader { pub(crate) type Source = readable_stream::NewSource; +/// A reader was attached to the stream: undo the pause that the last consumer left behind. +#[unsafe(no_mangle)] +pub(crate) extern "C" fn Bun__FileReader__resume(source: &Source) { + source.context.set_flowing(true); +} + // SAFETY: `FileReader` is always the `context` field of a heap-allocated // `Source`. `parent` is the `raw` arm for releasing a ref, which can free it. bun_core::impl_field_parent! { FileReader => Source.context; pub fn raw parent; pub fn shared parent_const; } diff --git a/src/runtime/webcore/FormData.rs b/src/runtime/webcore/FormData.rs index 384e0d7e6d6a..dd2153510680 100644 --- a/src/runtime/webcore/FormData.rs +++ b/src/runtime/webcore/FormData.rs @@ -3,7 +3,7 @@ use bun_core::{self, declare_scope, scoped_log}; use bun_core::{EncodedSlice, strings}; use bun_jsc::bindgen::JsHeap; -use bun_jsc::{AnyPromise, DOMFormData, JSGlobalObject, JSValue, JsError, JsResult}; +use bun_jsc::{DOMFormData, JSGlobalObject, JSValue, JsError, JsResult}; use bun_semver::{self, SlicedString}; use core::ffi::c_void; @@ -15,50 +15,7 @@ declare_scope!(FormData, visible); pub(crate) struct FormData {} -pub(crate) use bun_core::form_data::{AsyncFormData, Encoding}; - -/// JSC-touching extension on `AsyncFormData` (lives in this crate because it -/// needs `JSGlobalObject` + `AnyPromise`). -pub(crate) trait AsyncFormDataExt { - fn to_js(&self, global: &JSGlobalObject, data: &[u8], promise: AnyPromise) -> JsResult<()>; -} - -impl AsyncFormDataExt for AsyncFormData { - /// Parse errors are routed into the promise rejection; only settlement's own exception escapes. - fn to_js(&self, global: &JSGlobalObject, data: &[u8], promise: AnyPromise) -> JsResult<()> { - if let Encoding::Multipart(b) = &self.encoding { - if b.is_empty() { - scoped_log!( - FormData, - "AsnycFormData.toJS -> promise.reject missing boundary" - ); - promise.reject( - global, - global.create_error_instance(format_args!("FormData missing boundary")), - )?; - return Ok(()); - } - } - - let js_value = match FormData::to_js(global, data, &self.encoding) { - Ok(v) => v, - Err(crate::Error::JSError) => { - promise.reject(global, global.take_error(JsError::Thrown))?; - return Ok(()); - } - Err(e) => { - scoped_log!(FormData, "AsnycFormData.toJS -> failed "); - promise.reject( - global, - global.create_error_instance(format_args!("FormData {}", e.name())), - )?; - return Ok(()); - } - }; - promise.resolve(global, js_value)?; - Ok(()) - } -} +pub(crate) use bun_core::form_data::Encoding; /// Raw slice into the input buffer. Not using `bun.Semver.String` because /// file bodies are binary data that can contain null bytes, which @@ -85,19 +42,26 @@ impl Default for Field<'_> { } impl FormData { + /// Throws `ERR_FORMDATA_PARSE_ERROR` if `input` can't be parsed. pub(crate) fn to_js( global: &JSGlobalObject, input: &[u8], encoding: &Encoding, - ) -> crate::Result { + ) -> JsResult { match encoding { Encoding::URLEncoded => { - let str = EncodedSlice::utf8(strings::without_utf8_bom(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) + DOMFormData::create_from_url_query(global, &EncodedSlice::utf8(input)) } - Encoding::Multipart(boundary) => to_js_from_multipart_data(global, input, boundary), + Encoding::Multipart(boundary) => to_js_from_multipart_data(global, input, boundary) + .map_err(|err| match err { + crate::Error::JSError => JsError::Thrown, + err => global + .err( + bun_jsc::ErrorCode::FORMDATA_PARSE_ERROR, + format_args!("FormData parse error {}", err.name()), + ) + .throw(), + }), } } } @@ -116,11 +80,7 @@ pub(crate) fn from_bytes( // ends the parse. let global = unsafe { heap.global() }; - match FormData::to_js(global, input, &encoding) { - Ok(v) => Ok(v), - Err(crate::Error::JSError) => Err(JsError::Thrown), - Err(e) => Err(global.throw_error(e, "while parsing FormData")), - } + FormData::to_js(global, input, &encoding) } pub(crate) fn from_blob( @@ -200,16 +160,8 @@ pub(crate) fn to_js_from_multipart_data( blob.detach(); result } 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.form.append(wrap.global, &key, &value) } } @@ -278,7 +230,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/ObjectURLRegistry.rs b/src/runtime/webcore/ObjectURLRegistry.rs index ce17baaaf22b..f0bbfddcc642 100644 --- a/src/runtime/webcore/ObjectURLRegistry.rs +++ b/src/runtime/webcore/ObjectURLRegistry.rs @@ -42,7 +42,7 @@ const _: fn() = || { impl Entry { pub(crate) fn init(blob: &Blob) -> Box { - let blob = blob.dupe_with_content_type(true); + let blob = blob.dupe(); blob.global_this.set(core::ptr::null()); blob.name.with_mut(|name| name.make_thread_shareable()); Box::new(Entry { blob }) @@ -78,7 +78,7 @@ impl ObjectURLRegistry { let uuid = uuid_from_pathname(pathname)?; let map = self.map.lock(); let entry = map.get(&uuid.bytes)?; - let blob = entry.blob.dupe_with_content_type(true); + let blob = entry.blob.dupe(); blob.global_this.set(global_object); Some(blob) } diff --git a/src/runtime/webcore/ReadableStream.bind.ts b/src/runtime/webcore/ReadableStream.bind.ts index 4c7e7206cccf..65394f3528e2 100644 --- a/src/runtime/webcore/ReadableStream.bind.ts +++ b/src/runtime/webcore/ReadableStream.bind.ts @@ -57,7 +57,7 @@ export const BlobInternalReadableStreamSource = b.class({ export const FileInternalReadableStreamSource = b.class({ ...source, rustPath: "crate::webcore::file_reader::Source", - /** `lockedStream` is the stream `ReadableStream__lockNative` locked, which `NewSource::end_locked_stream` closes or errors. */ + /** `lockedStream` is the stream `ReadableStream__tryLockNative` locked, which `NewSource::end_locked_stream` closes or errors. */ internalFields: [...internalFields, "lockedStream"], proto: { cancel: members.cancel, @@ -77,3 +77,10 @@ export const BytesInternalReadableStreamSource = b.class({ internalFields: [...internalFields, "lockedStream"], proto: bufferedSource, }); + +/** For `undici`. The stream is a body that `fetch()` decoded, so `Content-Encoding` and `Content-Length` describe other bytes. */ +export const isDecodedBody = b.fn({ + args: { stream: b.any }, + returns: b.bool, + rustPath: "crate::webcore::readable_stream::is_decoded_body", +}); diff --git a/src/runtime/webcore/ReadableStream.rs b/src/runtime/webcore/ReadableStream.rs index c555b17c0c8c..75711ed2465d 100644 --- a/src/runtime/webcore/ReadableStream.rs +++ b/src/runtime/webcore/ReadableStream.rs @@ -12,6 +12,7 @@ use crate::webcore::jsc as bun_jsc; use bun_collections::VecExt; use bun_sys as syscall; +use crate::webcore::blob::BodyFacts; use crate::webcore::streams; use crate::webcore::{self, Blob, ByteBlobLoader, ByteStream, FileReader}; @@ -125,6 +126,8 @@ unsafe extern "C" { ) -> bool; /// A held `JSReadableStream`'s tag and native source (`Invalid` if `value` is anything else). Pure. safe fn ReadableStreamTag__taggedStream(value: JSValue, ptr: &mut *mut c_void) -> Tag; + /// The same, but `JavaScript` for a stream that something has started or locked. Pure. + safe fn ReadableStreamTag__unstarted(value: JSValue, ptr: &mut *mut c_void) -> Tag; /// `possible_readable_stream` is read+overwritten in place; `ptr` is a /// stack out-param. Reference params discharge the only preconditions. safe fn ReadableStreamTag__tagged( @@ -141,6 +144,8 @@ unsafe extern "C" { possible_readable_stream: JSValue, global_object: &JSGlobalObject, ) -> bool; + safe fn ReadableStream__bodyFacts(possible_readable_stream: JSValue) -> u8; + safe fn ReadableStream__setBodyFacts(possible_readable_stream: JSValue, facts: u8); safe fn ReadableStream__isClosedUnread( possible_readable_stream: JSValue, global_object: &JSGlobalObject, @@ -152,7 +157,6 @@ unsafe extern "C" { safe fn ReadableStream__textDecodeFrom(global: &JSGlobalObject, source: JSValue) -> JSValue; safe fn ReadableStream__markConsumedAsBody(stream: JSValue, global: &JSGlobalObject); safe fn ReadableStream__closeConsumedAsBody(stream: JSValue, global: &JSGlobalObject); - safe fn ReadableStream__lockNative(stream: JSValue, global: &JSGlobalObject); safe fn ReadableStream__tryLockNative(stream: JSValue, global: &JSGlobalObject) -> bool; /// BunStreamSource.cpp: queue the adapter's close on the microtask queue. safe fn Bun__NativeStreamSourceAdapter__onClose(global: &JSGlobalObject, adapter: JSValue); @@ -189,23 +193,17 @@ impl ReadableStream { Ok(Some((out_stream1, out_stream2))) } - /// Re-read this stream's tag (its native source may have changed hands). Pure, like `from_js_direct`. - pub fn reload_tag(&mut self) { - *self = ReadableStream::from_js_direct(self.value).unwrap_or(ReadableStream { - ptr: Source::Invalid, - value: JSValue::ZERO, - }); + /// The native source, for a consumer that reads it instead of the stream. `JavaScript` if the stream was started or locked: its queue may hold chunks. + fn unstarted_source(&self) -> Source { + let mut ptr: *mut c_void = core::ptr::null_mut(); + let tag = ReadableStreamTag__unstarted(self.value, &mut ptr); + Self::from_tag(tag, self.value, ptr).map_or(Source::Invalid, |stream| stream.ptr) } /// Lift the whole payload out of an unread stream. On success the stream is spent (closed, disturbed, locked). pub fn to_any_blob(&mut self, global_this: &JSGlobalObject) -> Option { - if self.is_disturbed(global_this) || self.is_locked(global_this) { - return None; - } - - self.reload_tag(); - - let blob = match self.ptr { + let source = self.unstarted_source(); + let mut blob = match source { Source::Blob(blobby) => { // SAFETY: ptr came from ReadableStreamTag__tagged; valid while stream alive. let blobby = unsafe { &*blobby }; @@ -214,7 +212,7 @@ impl ReadableStream { Source::File(_) => { // BACKREF: see `Source::file()` — payload valid while stream alive. // R-2: `lazy`/`started` are `JsCell`/`Cell`; shared borrow suffices. - let blobby = self.ptr.file().expect("matched File"); + let blobby = source.file().expect("matched File"); let webcore::file_reader::Lazy::Blob(store) = blobby.lazy.get() else { return None; }; @@ -232,7 +230,7 @@ impl ReadableStream { } Source::Bytes(_) => { // BACKREF: see `Source::bytes()` — payload valid while stream alive. - let bytes = self.ptr.bytes().expect("matched Bytes"); + let bytes = source.bytes().expect("matched Bytes"); // If we've received the complete body by the time this function is called // we can avoid streaming it and convert it to a Blob bytes.to_any_blob()? @@ -243,6 +241,7 @@ impl ReadableStream { } Source::JavaScript | Source::Invalid => return None, }; + blob.set_facts(self.body_facts()); self.done(); ReadableStream__closeConsumedAsBody(self.value, global_this); @@ -303,22 +302,22 @@ impl ReadableStream { result } + /// What the body this stream was made from knew about its bytes. + pub(crate) fn body_facts(&self) -> BodyFacts { + BodyFacts::from_bits_truncate(ReadableStream__bodyFacts(self.value)) + } + + /// For a stream that Bun just made out of a body or a Blob. + pub(crate) fn set_body_facts(stream: JSValue, facts: BodyFacts) { + ReadableStream__setBodyFacts(stream, (facts | BodyFacts::NATIVE_STREAM).bits()); + } + /// A Body consumer owns this stream now; it stays disturbed and locked (the spec reader is never released). pub(crate) fn mark_consumed_as_body(&self, global_object: &JSGlobalObject) { ReadableStream__markConsumedAsBody(self.value, global_object); } - /// Mark the stream disturbed + locked-without-reader. Called by native - /// fast-paths after wiring a `SinkHandle` directly so `.locked`, - /// `.getReader()`, and body-mixin disturbed checks behave as they would - /// after `readStreamIntoSink` acquires a reader. It doesn't check the - /// stream's state, so callers must do that first. - pub fn lock_native(&self, global_object: &JSGlobalObject) { - ReadableStream__lockNative(self.value, global_object); - } - - /// Calls [`Self::lock_native`] if the stream hasn't been started or locked. - /// Otherwise returns `false` without touching the stream. + /// Locks the stream for a native consumer about to attach to its source. Returns `false`, changing nothing, if it was started or locked. pub(crate) fn try_lock_native(&self, global_object: &JSGlobalObject) -> bool { ReadableStream__tryLockNative(self.value, global_object) } @@ -419,7 +418,7 @@ impl ReadableStream { } pub fn is_disturbed(&self, global_object: &JSGlobalObject) -> bool { - is_disturbed_value(self.value, global_object) + ReadableStream__isDisturbed(self.value, global_object) } pub fn is_locked(&self, global_object: &JSGlobalObject) -> bool { @@ -427,11 +426,6 @@ impl ReadableStream { ReadableStream__isLocked(self.value, global_object) } - /// Fetch's "body is unusable" (). - pub fn is_disturbed_or_locked(&self, global_object: &JSGlobalObject) -> bool { - self.is_disturbed(global_object) || self.is_locked(global_object) - } - /// A pure `dynamicDowncast` type test: no tagging, no conversion. pub fn is_readable_stream(value: JSValue) -> bool { ReadableStream__is(value) @@ -540,7 +534,7 @@ impl ReadableStream { let Some(store) = blob.store.get() else { return ReadableStream::empty(cx.global()); }; - match &store.data { + let stream = match &store.data { webcore::blob::store::Data::Bytes(_) => { let reader = NewSource::::new_mut(NewSource { global_this: Cell::new(Some(bun_ptr::BackRef::new(cx.global()))), @@ -586,7 +580,9 @@ impl ReadableStream { cx, ) } - } + }?; + Self::set_body_facts(stream, blob.body_facts.get()); + Ok(stream) } pub fn from_pipe

( @@ -676,9 +672,9 @@ impl ReadableStream { } } -pub(crate) fn is_disturbed_value(value: JSValue, global_object: &JSGlobalObject) -> bool { - // SAFETY: FFI call; value may be any JSValue (C++ side checks). - ReadableStream__isDisturbed(value, global_object) +pub(crate) fn is_decoded_body(stream: JsLocal<'_>) -> bool { + BodyFacts::from_bits_truncate(ReadableStream__bodyFacts(stream.to_js())) + .contains(BodyFacts::DECODED) } pub(crate) fn is_locked_value(value: JSValue, global_object: &JSGlobalObject) -> bool { @@ -1094,7 +1090,7 @@ impl NewSource { Bun__NativeStreamSourceAdapter__onClose(global_this, adapter); } - /// A stream that [`ReadableStream::lock_native`] locked has no reader or controller, so its source ends it: errored with `err`, else closed. + /// A stream that [`ReadableStream::try_lock_native`] locked has no reader or controller, so its source ends it: errored with `err`, else closed. pub fn end_locked_stream(&self, err: Option<&streams::StreamError>) { if !self.is_reader_live() { return; diff --git a/src/runtime/webcore/Request.rs b/src/runtime/webcore/Request.rs index 3a793e86ba14..e2d5c8073c14 100644 --- a/src/runtime/webcore/Request.rs +++ b/src/runtime/webcore/Request.rs @@ -5,26 +5,26 @@ use core::ptr::NonNull; use std::borrow::Cow; use bun_jsc::JsCell; -use enumset::EnumSet; use super::response::HeadersRef; use crate::api::AnyRequestContext; use crate::generated_bindgen::response::js_Request as js; use crate::webcore::BlobExt as _; use crate::webcore::body::{self, BodyHiveHandle, BodyMixin, Value as BodyValue}; +use crate::webcore::fetch::request_init::{self, InitSources}; use crate::webcore::jsc::{HTTPHeaderName, JSGlobalObject, JSValue, JsError, JsRef, JsResult}; -use crate::webcore::{AbortSignal, Blob, CookieMap, FetchHeaders, ReadableStream, Response}; +use crate::webcore::{AbortSignal, Blob, CookieMap, ReadableStream, Response}; use bun_alloc::AllocError; use bun_core::{Output, fmt as bun_fmt}; use bun_core::{String as BunString, StringView, 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 _; @@ -81,13 +81,13 @@ 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, /// The JS wrapper holds the only ref, so `destroy` runs when it is collected. ref_count: Cell, /// Bun.serve's RequestContext holds a weak reference, which can outlive the wrapper. pub(crate) weak_ptr_data: WeakPtrData, + pub(crate) flags: Flags, // We must report a consistent value for this reported_estimated_size: Cell, } @@ -158,21 +158,12 @@ impl BodyMixin for Request { Request::get_body_value(self) } #[inline] - fn get_fetch_headers(&self) -> Option> { - // Opaque C++ handle. Return the raw `*mut` - // directly (via `HeadersRef::as_ptr`) so the provenance is mutable; - // going through `as_deref()` would derive it from a `&FetchHeaders` - // and make the later `as_mut()` UB under Stacked Borrows. - self.headers.get().as_ref().map(|h| { - core::ptr::NonNull::new(h.as_ptr()) - .expect("HeadersRef wraps a non-null *mut FetchHeaders") - }) + fn get_content_type(&self) -> Option> { + Request::get_content_type(self) } #[inline] - fn get_form_data_encoding( - &self, - ) -> bun_jsc::JsResult>> { - Request::get_form_data_encoding(self) + fn ensure_headers(&self, global_this: &JSGlobalObject) -> bun_jsc::JsResult<()> { + self.ensure_fetch_headers(global_this).map(|_| ()) } } @@ -231,9 +222,9 @@ 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()); + return Ok(headers); } if let Some(req) = self.request_context.get_request() { @@ -243,29 +234,20 @@ impl Request { ))); } else { // we don't have a request context, so we need to create an empty headers object - self.headers.set(Some(HeadersRef::create_empty())); - let content_type: Option<&[u8]> = match self.body_value() { - BodyValue::Blob(blob) => Some(blob.content_type_slice()), - BodyValue::Locked(locked) => match locked.readable.get() { - Some(readable) => match readable.ptr { - crate::webcore::readable_stream::Source::Blob(blob) => { - // SAFETY: `Source::Blob` holds a live `*mut ByteBlobLoader` - // for as long as the readable stream exists; we only read - // its `content_type` slice and immediately copy below. - Some(unsafe { (*blob).content_type.get().as_slice() }) - } - _ => None, - }, - None => None, - }, - _ => None, - }; - - if let Some(content_type_) = content_type { - if !content_type_.is_empty() { - self.headers_mut().as_mut().unwrap().put( + let headers = self.headers_mut().insert(HeadersRef::create_empty()); + let body = self.body_value(); + body.put_content_type_default(headers, global_this)?; + // Only this header list takes the type of a Blob whose unread stream() is the body. + if let BodyValue::Locked(locked) = body + && let Some(readable) = locked.readable.get() + && let crate::webcore::readable_stream::Source::Blob(loader) = readable.ptr + { + // SAFETY: `locked.readable` holds the stream, which keeps its `ByteBlobLoader` alive. + let content_type = unsafe { (*loader).content_type.get() }; + if !content_type.is_empty() { + headers.put_default( HTTPHeaderName::ContentType, - &StringView::ascii(content_type_), + &StringView::ascii(content_type.as_slice()), global_this, )?; } @@ -321,29 +303,22 @@ impl Request { Ok(None) } - pub(crate) fn get_content_type(&self) -> JsResult>> { - if let Some(req) = self.request_context.get_request() { - // S008: `uws::Request` is an `opaque_ffi!` ZST handle — safe deref. - let req = bun_opaque::opaque_deref(req); - if let Some(value) = req.header(b"content-type") { - return Ok(Some(bun_core::Utf8Bytes::Borrowed(value))); - } - } - + pub(crate) fn get_content_type(&self) -> Option> { + // The header list has what the handler changed, so the request it was made from comes second. if let Some(headers) = self.headers_mut().as_mut() { if let Some(value) = headers.fast_get(HTTPHeaderName::ContentType) { - return Ok(Some(value.to_utf8())); + return Some(value.to_utf8()); } - } - - if let BodyValue::Blob(blob) = self.body_value() { - let ct = blob.content_type_slice(); - if !ct.is_empty() { - return Ok(Some(bun_core::Utf8Bytes::Borrowed(ct))); + } else if let Some(req) = self.request_context.get_request() { + // S008: `uws::Request` is an `opaque_ffi!` ZST handle — safe deref. + let req = bun_opaque::opaque_deref(req); + if let Some(value) = req.header(b"content-type") { + return Some(bun_core::Utf8Bytes::Borrowed(value)); } } - Ok(None) + let content_type = self.body_value().content_type().as_slice(); + (!content_type.is_empty()).then_some(bun_core::Utf8Bytes::Borrowed(content_type)) } } @@ -360,9 +335,7 @@ 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`], so it needs the same check. #[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()?; @@ -399,7 +372,7 @@ impl Request { url: BunString, headers: Option, body: BodyHiveHandle, - method: Method, + method: OwnedMethod, ) -> Request { Request { url: JsCell::new(url), @@ -416,21 +389,6 @@ impl Request { } } - pub(crate) fn get_form_data_encoding( - &self, - ) -> JsResult>> { - let Some(content_type_slice) = self.get_content_type()? else { - return Ok(None); - }; - let Some(encoding) = crate::webcore::form_data::Encoding::get(content_type_slice.slice()) - else { - return Ok(None); - }; - Ok(Some(crate::webcore::form_data::AsyncFormData::init( - encoding, - ))) - } - pub(crate) fn estimated_size(&self) -> usize { self.reported_estimated_size.get() } @@ -534,7 +492,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) @@ -679,7 +637,10 @@ impl Request { } pub(crate) fn get_method(&self, cx: &mut JsScope<'_>) -> JSValue { - self.method.to_js(cx.global()) + match &self.method { + OwnedMethod::Known(method) => method.to_js(cx.global()), + OwnedMethod::Token(token) => bun_core::EncodedSlice::latin1(token).to_js(cx.global()), + } } pub(crate) fn get_mode(&self, cx: &mut JsScope<'_>) -> JSValue { @@ -930,23 +891,36 @@ impl Request { } } -#[derive(enumset::EnumSetType)] -enum Fields { - Method, - Headers, - Body, - // Referrer, - // ReferrerPolicy, - Mode, - // Credentials, - Redirect, - Cache, - // Integrity, - // Keepalive, - Signal, - // Proxy, - // Timeout, - Url, +/// A Request or Response that nothing has tampered with is copied from its fields, not through its getters. +pub(crate) enum Pristine<'a> { + Request(&'a Request), + Response(&'a Response), + No, +} + +impl Pristine<'_> { + /// `value` is an object. + pub(crate) fn of(value: JSValue) -> Self { + if value.js_type() == bun_jsc::JSType::DOMWrapper { + if let Some(request) = value.as_direct::() { + // SAFETY: the live payload of the wrapper, which the caller's argument keeps alive. + return Self::Request(unsafe { &*request }); + } + if let Some(response) = value.as_direct::() { + // SAFETY: as above. + return Self::Response(unsafe { &*response }); + } + } + Self::No + } +} + +/// Returns a copy of the body, or `None` if there is none left to copy. +fn copy_body(owner: &T, cx: &bun_jsc::JsThread<'_>) -> JsResult> { + match owner.get_body_value() { + BodyValue::Null | BodyValue::Used => Ok(None), + _ => owner.clone_body_value_via_cached_stream(cx).map(Some), + } } impl Request { @@ -955,456 +929,201 @@ impl Request { ::check_body_stream_ref(self, global_object) } - fn construct_into( - cx: &bun_jsc::JsThread<'_>, - url_or_object: JSValue, - init: JSValue, - ) -> JsResult { - let mut success = false; - // SAFETY: bun_vm() yields the live per-thread VM singleton. - let body = body::hive_alloc(BodyValue::Null); - // Snapshot the seed slot pointer for the repoint check below; `body` - // (the +1) is moved into `req.body` next. - let body_seed_ptr = body.as_ptr(); - let mut req = Request { - url: JsCell::new(BunString::EMPTY), - headers: JsCell::new(None), - signal: JsCell::new(None), - body: ManuallyDrop::new(body), + /// A Request that script made. Nothing here can fail, so a caller's `?` never leaves a body slot behind. + fn from_members( + url: BunString, + method: OwnedMethod, + headers: Option, + signal: Option, + body: BodyValue, + flags: Flags, + ) -> Request { + Request { + url: JsCell::new(url), + headers: JsCell::new(headers), + signal: JsCell::new(signal), + body: ManuallyDrop::new(body::hive_alloc(body)), js_ref: JsCell::new(JsRef::empty()), - method: Method::GET, - flags: Flags::default(), + method, + flags, request_context: AnyRequestContext::NULL, ref_count: Cell::new(1), weak_ptr_data: WeakPtrData::EMPTY, reported_estimated_size: Cell::new(0), - }; - // A scopeguard cannot capture `&mut req` while the - // fn body also uses it. Cleanup is invoked at each early-return site via `bail!`. - let cleanup = |req: &mut Request, - body_seed_ptr: *mut crate::webcore::body::HiveRef, - success: bool| { - // Snapshot before the `!success` drop — reading a `ManuallyDrop` - // after `ManuallyDrop::drop()` is documented use-after-drop. - let req_body_ptr = req.body.as_ptr(); - if !success { - req.finalize_without_deinit(); - // SAFETY: `req.body` is live; this is the sole release on this path. - unsafe { ManuallyDrop::drop(&mut req.body) }; - } - if req_body_ptr != body_seed_ptr { - // `clone_into` `ptr::write`-overwrote `req.body`, orphaning the - // seed slot's +1. Recover and drop it. - // SAFETY: `body_seed_ptr` is a live +1 leaked by the ptr::write. - drop(unsafe { BodyHiveHandle::from_raw(body_seed_ptr) }); - } - }; - - macro_rules! bail { - ($e:expr) => {{ - cleanup(&mut req, body_seed_ptr, success); - return $e; - }}; } + } - if !url_or_object.is_cell() { - bail!(Err(cx.global().throw(format_args!( - "Failed to construct 'Request': expected non-empty string or object, got undefined" - )))); + fn construct_into( + cx: &bun_jsc::JsThread<'_>, + input: JSValue, + init: JSValue, + ) -> JsResult { + let global = cx.global(); + if !input.is_cell() { + return Err(global.throw_type_error(format_args!( + "Failed to construct 'Request': expected non-empty string or object, got {}", + if input.is_null() { + "null" + } else if input.is_number() { + "number" + } else if input.is_boolean() { + "boolean" + } else { + "undefined" + } + ))); } - let url_or_object_type = url_or_object.js_type(); - let mut fields: EnumSet = EnumSet::empty(); - - let is_first_argument_a_url = - // fastest path: - url_or_object_type.is_string_like() || - // slower path: - bun_jsc::DOMURL::cast_(url_or_object, cx.global().vm()).is_some(); - - if is_first_argument_a_url { - let str = match BunString::from_js(url_or_object, cx.global()) { - Ok(s) => s, - Err(e) => bail!(Err(e)), - }; - req.url.set(str); - - if !req.url.get().is_empty() { - fields.insert(Fields::Url); - } - } else if !url_or_object_type.is_object() { - bail!(Err(cx.global().throw(format_args!( + let input_type = input.js_type(); + let mut url = BunString::EMPTY; + let input = if input_type.is_string_like() + || bun_jsc::DOMURL::cast_(input, global.vm()).is_some() + { + url = BunString::from_js(input, global)?; + None + } else if input_type.is_object() { + Some(input) + } else { + return Err(global.throw_type_error(format_args!( "Failed to construct 'Request': expected non-empty string or object" - )))); - } - - let values_to_try_: [JSValue; 2] = [ - if init.is_object() { - init - } else if is_first_argument_a_url { - JSValue::UNDEFINED - } else { - url_or_object - }, - if is_first_argument_a_url { - JSValue::UNDEFINED - } else { - url_or_object - }, - ]; - let values_to_try = - &values_to_try_[0..((!is_first_argument_a_url) as usize + init.is_object() as usize)]; - - for &value in values_to_try { - let value_type = value.js_type(); - let explicit_check = values_to_try.len() == 2 - && matches!( - value_type, - bun_jsc::JSType::FinalObject | bun_jsc::JSType::CompiledObject - ) - && values_to_try[1].js_type() == bun_jsc::JSType::DOMWrapper; - if value_type == bun_jsc::JSType::DOMWrapper { - if let Some(request) = value.as_direct::() { - // SAFETY: as_direct returns the wrapper's live *mut Request - let request = unsafe { &*request }; - if values_to_try.len() == 1 { - match Request::clone_into( - request, - &mut req, - cx, - fields.contains(Fields::Url), - ) { - Ok(()) => {} - Err(e) => bail!(Err(e)), - } - success = true; - cleanup(&mut req, body_seed_ptr, success); - return Ok(req); - } - - if !fields.contains(Fields::Method) { - req.method = request.method; - fields.insert(Fields::Method); - } - - if !fields.contains(Fields::Redirect) { - req.flags.redirect = request.flags.redirect; - fields.insert(Fields::Redirect); - } - - if !fields.contains(Fields::Cache) { - req.flags.cache = request.flags.cache; - fields.insert(Fields::Cache); - } - - if !fields.contains(Fields::Mode) { - req.flags.mode = request.flags.mode; - fields.insert(Fields::Mode); - } - - if !fields.contains(Fields::Headers) { - match request.clone_headers(cx.global()) { - Ok(Some(headers)) => { - req.headers.set(Some(headers)); - fields.insert(Fields::Headers); - } - Ok(None) => {} - Err(e) => bail!(Err(e)), - } - } - - if !fields.contains(Fields::Body) { - match request.body_value() { - BodyValue::Null | BodyValue::Empty | BodyValue::Used => {} - _ => { - match request.clone_body_value_via_cached_stream(cx) { - Ok(v) => { - *req.body_value_mut() = v; - } - Err(e) => bail!(Err(e)), - } - fields.insert(Fields::Body); - } - } - } - } - - if let Some(response) = value.as_direct::() { - // SAFETY: `as_direct` returned a live `*mut Response` owned by the JS wrapper. - let response = unsafe { &*response }; - if !fields.contains(Fields::Method) { - req.method = response.get_method(); - fields.insert(Fields::Method); - } - - if !fields.contains(Fields::Headers) { - if let Some(headers) = response.get_init_headers_mut() { - // The flag is set unconditionally once `getInitHeaders()` yielded a - // value, even if `cloneThis` returns null — so a later arg can't - // repopulate headers from a different source. - match headers.clone_this(cx.global()) { - Ok(h) => { - // SAFETY: clone_this returns a +1 ref FetchHeaders. - req.headers.set(h.map(|p| unsafe { HeadersRef::adopt(p) })); - fields.insert(Fields::Headers); - } - Err(e) => bail!(Err(e)), - } - } - } - - if !fields.contains(Fields::Url) { - let url = response.url(); - if !url.is_empty() { - req.url.set(url.clone()); - fields.insert(Fields::Url); - } - } - - if !fields.contains(Fields::Body) { - match response.get_body_value() { - BodyValue::Null | BodyValue::Empty | BodyValue::Used => {} - _ => { - match response.clone_body_value_via_cached_stream(cx) { - Ok(v) => { - *req.body_value_mut() = v; - } - Err(e) => bail!(Err(e)), - } - fields.insert(Fields::Body); - } - } - } - } - } - - if !fields.contains(Fields::Body) { - match value.fast_get(cx.global(), bun_jsc::BuiltinName::Body) { - Ok(Some(body_)) => { - fields.insert(Fields::Body); - // fetch spec Request(init): `keepalive: true` with a ReadableStream - // body throws before body extraction (Node's message is "keepalive"). - if crate::webcore::ReadableStream::is_readable_stream(body_) { - match value.get(cx.global(), "keepalive") { - Ok(Some(keepalive)) if keepalive.to_boolean() => { - bail!(Err(cx - .global() - .throw_type_error(format_args!("keepalive")))); - } - Ok(_) => {} - Err(e) => bail!(Err(e)), - } - } - match BodyValue::from_js(cx.global(), body_) { - Ok(v) => { - *req.body_value_mut() = v; - } - Err(e) => bail!(Err(e)), - } - } - Ok(None) => {} - Err(e) => bail!(Err(e)), - } + ))); + }; + let init = init.is_object().then_some(init); - // BodyValue::from_js() throws without returning Err; see Blob::from_dom_form_data - if cx.global().has_exception() { - bail!(Err(JsError::Thrown)); - } - } + if let (Some(only), None) | (None, Some(only)) = (init, input) + && let Pristine::Request(request) = Pristine::of(only) + { + return request.cloned(cx, (!url.is_empty()).then_some(url)); + } - if !fields.contains(Fields::Url) { - match value.fast_get(cx.global(), bun_jsc::BuiltinName::Url) { - Ok(Some(url)) => { - match BunString::from_js(url, cx.global()) { - Ok(s) => req.url.set(s), - Err(e) => bail!(Err(e)), - } - if !req.url.get().is_empty() { - fields.insert(Fields::Url); - } + // `new Request()` also copies a Request that is `init`, so the readers below tell the kinds of object apart. + let sources = InitSources { + init, + request: None, + object: input, + }; - // first value - } - Ok(None) => { - // Short-circuit ordering: only probe - // `implementsToString` (which performs JS property - // lookup with observable side effects) when the first - // two guards already hold. - if value == values_to_try[values_to_try.len() - 1] - && !is_first_argument_a_url - { - let implements = match value.implements_to_string(cx.global()) { - Ok(b) => b, - Err(e) => bail!(Err(e)), - }; - if implements { - let str = match BunString::from_js(value, cx.global()) { - Ok(s) => s, - Err(e) => bail!(Err(e)), - }; - req.url.set(str); - if !req.url.get().is_empty() { - fields.insert(Fields::Url); - } - } - } - } - Err(e) => bail!(Err(e)), - } + let body = sources.object_member(|object| { + let copy = match Pristine::of(object) { + Pristine::Request(request) => copy_body(request, cx)?, + Pristine::Response(response) => copy_body(response, cx)?, + Pristine::No => None, + }; + if copy.is_some() { + return Ok(copy); } - - if !fields.contains(Fields::Signal) { - // WebIDL `AbortSignal?`: present iff the member is not undefined. - // `fast_get` maps absent/undefined → None; `null` is Some(null) and - // means "present, detach" (no fallback to the input Request's signal). - match value.fast_get(cx.global(), bun_jsc::BuiltinName::signal) { - Ok(Some(signal_)) => { - fields.insert(Fields::Signal); - if signal_.is_null() { - // explicit detach; leave `req.signal` as None - } else if let Some(signal) = AbortSignal::ref_from_js(signal_) { - // Keep it alive - signal_.ensure_still_alive(); - // `ref_from_js` already ref'd. - req.signal.set(Some(signal)); - } else { - bail!(Err(cx.global().throw_type_error(format_args!( - "Failed to construct 'Request': signal is not of type AbortSignal." - )))); - } - } - Ok(None) => {} - Err(e) => bail!(Err(e)), - } + let Some(value) = object.fast_get(global, bun_jsc::BuiltinName::Body)? else { + return Ok(None); + }; + // The spec throws before it extracts the body. "keepalive" is Node's message. + if ReadableStream::is_readable_stream(value) + && let Some(keepalive) = object.get(global, "keepalive")? + && keepalive.to_boolean() + { + return Err(global.throw_type_error(format_args!("keepalive"))); } + BodyValue::from_js(global, value).map(Some) + })?; - 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() - } - } - } - - 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); - } - } - } - Ok(None) => {} - Err(e) => bail!(Err(e)), + if url.is_empty() { + let member = sources.object_member(|object| { + if let Pristine::Response(response) = Pristine::of(object) + && !response.url().is_empty() + { + return Ok(Some(response.url().clone())); } - } - - // Extract redirect option - if !fields.contains(Fields::Redirect) { - match value.get_optional_enum::(cx.global(), "redirect") { - Ok(Some(redirect_value)) => { - req.flags.redirect = redirect_value; - fields.insert(Fields::Redirect); + let url = match object.fast_get(global, bun_jsc::BuiltinName::Url)? { + Some(value) => BunString::from_js(value, global)?, + // Only `input` is converted to a string. + None if Some(object) == input && object.implements_to_string(global)? => { + BunString::from_js(object, global)? } - Ok(None) => {} - Err(e) => bail!(Err(e)), - } + None => return Ok(None), + }; + Ok((!url.is_empty()).then_some(url)) + })?; + if let Some(member) = member { + url = member; } + } - // Extract cache option - if !fields.contains(Fields::Cache) { - match value.get_optional_enum::(cx.global(), "cache") { - Ok(Some(cache_value)) => { - req.flags.cache = cache_value; - fields.insert(Fields::Cache); - } - Ok(None) => {} - Err(e) => bail!(Err(e)), - } - } + let signal = sources.object_member(|object| { + request_init::signal_member(global, object, || { + global.throw_type_error(format_args!( + "Failed to construct 'Request': signal is not of type AbortSignal." + )) + }) + })?; + + let headers = sources.object_member(|object| match Pristine::of(object) { + Pristine::Request(request) => request.clone_headers(global), + Pristine::Response(response) => response.clone_init_headers(global), + Pristine::No => match object.fast_get(global, bun_jsc::BuiltinName::Headers)? { + Some(value) => HeadersRef::from_init_value(global, value), + None => Ok(None), + }, + })?; - // Extract mode option - if !fields.contains(Fields::Mode) { - match value.get_optional_enum::(cx.global(), "mode") { - Ok(Some(mode_value)) => { - req.flags.mode = mode_value; - fields.insert(Fields::Mode); - } - Ok(None) => {} - Err(e) => bail!(Err(e)), - } + let method = sources.object_member(|object| match Pristine::of(object) { + Pristine::Request(request) => Ok(Some(request.method.clone())), + // A Response has no `method` property, so as `init` it can't set one. + Pristine::Response(response) => { + Ok((Some(object) == input).then(|| response.get_method().into())) } + Pristine::No => match object.fast_get(global, bun_jsc::BuiltinName::Method)? { + Some(value) => method_jsc::request_method_from_js(global, value).map(Some), + None => Ok(None), + }, + })?; + + let mut flags = Flags::default(); + if let Some(redirect) = sources.object_member(|object| match Pristine::of(object) { + Pristine::Request(request) => Ok(Some(request.flags.redirect)), + _ => object.get_optional_enum::(global, "redirect"), + })? { + flags.redirect = redirect; + } + if let Some(cache) = sources.object_member(|object| match Pristine::of(object) { + Pristine::Request(request) => Ok(Some(request.flags.cache)), + _ => object.get_optional_enum::(global, "cache"), + })? { + flags.cache = cache; + } + if let Some(mode) = sources.object_member(|object| match Pristine::of(object) { + Pristine::Request(request) => Ok(Some(request.flags.mode)), + _ => object.get_optional_enum::(global, "mode"), + })? { + flags.mode = mode; } - if req.url.get().is_empty() { - bail!(Err(cx.global().throw(format_args!( + if url.is_empty() { + return Err(global.throw_type_error(format_args!( "Failed to construct 'Request': url is required." - )))); + ))); } - - let href = bun_url::href_from_string(req.url.get()); + let href = bun_url::href_from_string(&url); if href.is_empty() { - // globalThis.throw can cause GC, which could cause the above string to be freed. - // so we must increment the reference count before calling it. - let err = cx.global().err_invalid_url(format_args!( - "Failed to construct 'Request': Invalid URL \"{}\"", - req.url.get() + let err = global.err_invalid_url(format_args!( + "Failed to construct 'Request': Invalid URL \"{url}\"" )); - bail!(Err(cx.global().throw_value(err))); + return Err(global.throw_value(err)); } - // hrefFromString increments the reference count if they end up being - // the same - // - // we increment the reference count on usage above, so we must - // decrement it to be perfectly balanced. - - req.url.set(href); - - if matches!(req.body_value(), BodyValue::Blob(_)) && req.headers.get().is_some() { - if let BodyValue::Blob(blob) = req.body_value() { - let ct: &[u8] = blob.content_type_slice(); - if !ct.is_empty() - && !req - .headers_mut() - .as_mut() - .unwrap() - .fast_has(HTTPHeaderName::ContentType) - { - match req.headers_mut().as_mut().unwrap().put( - HTTPHeaderName::ContentType, - &StringView::ascii(ct), - cx.global(), - ) { - Ok(()) => {} - Err(e) => bail!(Err(e)), - } - } - } + let body = body.unwrap_or(BodyValue::Null); + let mut headers = headers; + if let Some(headers) = &mut headers { + body.put_content_type_default(headers, global)?; } + let req = Self::from_members( + href, + method.unwrap_or_else(|| Method::GET.into()), + headers, + signal.flatten(), + body, + flags, + ); req.calculate_estimated_byte_size(); - success = true; - - cleanup(&mut req, body_seed_ptr, success); Ok(req) } @@ -1438,88 +1157,23 @@ impl Request { Ok(js_wrapper) } - pub(crate) fn clone_into( - &self, - req: &mut Request, - cx: &bun_jsc::JsThread<'_>, - preserve_url: bool, - ) -> JsResult<()> { - // allocator param dropped (global mimalloc) + /// A copy with its own headers and body, at `url` if there is one. + fn cloned(&self, cx: &bun_jsc::JsThread<'_>, url: Option) -> JsResult { let _ = self.ensure_url(); - let body_ = self.clone_body_value_via_cached_stream(cx)?; - // BodyValue's Drop frees `body_` on the `?` error path - let body = body::hive_alloc(body_); - // Last fallible call; an early return here leaves `req.url` untouched. - // `body` (a `BodyHiveHandle`) drops on the `?` error path, releasing its +1. + let body = self.clone_body_value_via_cached_stream(cx)?; let headers = self.clone_headers(cx.global())?; - let url = if preserve_url { - req.url.take() - } else { - self.url.get().clone() - }; - - // `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 - // sentinel. - // The old `req.body` hive ref is intentionally NOT unref'd here: - // `clone()` seeds it with a dangling sentinel, and `construct_into` - // releases its seed via the ptr-equality arm of its `cleanup`. - // `url` was taken above (preserve_url) or is the empty - // sentinel; remaining incoming fields are None/weak/Copy by contract. - // SAFETY: `req` is a valid &mut, fully initialized by the caller; - // nothing between here and the write can panic. - unsafe { - core::ptr::write( - req, - Request { - url: JsCell::new(url), - headers: JsCell::new(headers), - signal: JsCell::new(None), - body: ManuallyDrop::new(body), - js_ref: JsCell::new(JsRef::empty()), - method: self.method, - flags: self.flags, - request_context: AnyRequestContext::NULL, - ref_count: Cell::new(1), - weak_ptr_data: WeakPtrData::EMPTY, - reported_estimated_size: Cell::new(0), - }, - ); - } - - if let Some(signal) = self.signal.get() { - // `AbortSignalRef::clone` → C++ `ref()`. - req.signal.set(Some(signal.clone())); - } - Ok(()) + Ok(Self::from_members( + url.unwrap_or_else(|| self.url.get().clone()), + self.method.clone(), + headers, + self.signal.get().clone(), + body, + self.flags, + )) } pub(crate) fn clone(&self, cx: &bun_jsc::JsThread<'_>) -> JsResult<*mut Request> { - // allocator param dropped (global mimalloc) - // `clone_into` `ptr::write`s the new fields over the seed - // without reading or dropping it. - let mut req = Box::new(Request { - url: JsCell::new(BunString::EMPTY), - headers: JsCell::new(None), - signal: JsCell::new(None), - // `clone_into` `ptr::write`s the whole struct without dropping the - // sentinel; seed with a non-deref'd dangling handle. `ManuallyDrop` - // suppresses drop, so the `?` error path won't unref the dangling ptr. - // SAFETY: never deref'd or dropped — overwritten by `clone_into`. - body: ManuallyDrop::new(unsafe { - BodyHiveHandle::from_raw(NonNull::dangling().as_ptr()) - }), - js_ref: JsCell::new(JsRef::empty()), - method: Method::GET, - flags: Flags::default(), - request_context: AnyRequestContext::NULL, - ref_count: Cell::new(1), - weak_ptr_data: WeakPtrData::EMPTY, - reported_estimated_size: Cell::new(0), - }); - // Box drops on the error path automatically - self.clone_into(&mut req, cx, false)?; - Ok(bun_core::heap::into_raw(req)) + Ok(Request::new(self.cloned(cx, None)?)) } } @@ -1537,7 +1191,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 f5f4606fa079..3e4fbcb93d23 100644 --- a/src/runtime/webcore/Response.rs +++ b/src/runtime/webcore/Response.rs @@ -7,17 +7,18 @@ use bun_jsc::{AbortSignal, GlobalRef}; use bun_ptr::RefPtr; use crate::webcore::jsc::{ - BuiltinName, HTTPHeaderName, JSGlobalObject, JSType, JSValue, JsError, JsRef, JsResult, - StringJsc as _, + BuiltinName, HTTPHeaderName, JSGlobalObject, JSValue, JsError, JsRef, JsResult, StringJsc as _, }; use bun_core::Output; use bun_core::{String as BunString, StringView, Utf8Bytes}; use bun_http_types::Method::Method; use super::body::{Body, BodyMixin, Value as BodyValue, ValueError as BodyValueError}; -use super::{FetchHeaders, ReadableStream, Request}; +use super::request::Pristine; +use super::{FetchHeaders, ReadableStream}; -use super::blob::Blob; +use super::blob::{Blob, BodyFacts}; +use super::fetch::request_init::HeadersInit; use bun_ptr::weak_ptr::WeakPtrData; /// RAII handle to a C++-owned `WebCore::FetchHeaders`. @@ -72,14 +73,33 @@ 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> { + match HeadersInit::from_js(global, value)? { + Some(HeadersInit::Created(headers)) => Ok(Some(headers)), + Some(borrowed) => Self::clone_of(borrowed.get(), global), + None => Ok(None), + } + } + /// `FetchHeaders.cloneThis(global)` — deep copy on the C++ side. - #[inline] - pub(crate) fn clone_this(&self, global: &JSGlobalObject) -> JsResult> { + pub(crate) fn clone_of( + headers: &mut FetchHeaders, + global: &JSGlobalObject, + ) -> JsResult> { // SAFETY: C++ returns a +1 ref or null. - Ok(bun_opaque::opaque_deref_mut(self.0.as_ptr()) + Ok(headers .clone_this(global)? .map(|p| unsafe { Self::adopt(p) })) } + + #[inline] + pub(crate) fn clone_this(&self, global: &JSGlobalObject) -> JsResult> { + Self::clone_of(bun_opaque::opaque_deref_mut(self.0.as_ptr()), global) + } } impl core::ops::Deref for HeadersRef { @@ -276,21 +296,12 @@ impl BodyMixin for Response { Response::get_body_value(self) } #[inline] - fn get_fetch_headers(&self) -> Option> { - // Opaque C++ handle. Return the raw `*mut` - // directly (via `HeadersRef::as_ptr`) so the provenance is mutable; - // going through `as_deref()` would derive it from a `&FetchHeaders` - // and make the later `as_mut()` UB under Stacked Borrows. - self.init.get().headers.as_ref().map(|h| { - core::ptr::NonNull::new(h.as_ptr()) - .expect("HeadersRef wraps a non-null *mut FetchHeaders") - }) + fn get_content_type(&self) -> Option> { + Response::get_content_type(self) } #[inline] - fn get_form_data_encoding( - &self, - ) -> bun_jsc::JsResult>> { - Response::get_form_data_encoding(self) + fn ensure_headers(&self, global_this: &JSGlobalObject) -> bun_jsc::JsResult<()> { + self.get_or_create_headers(global_this).map(|_| ()) } } @@ -393,6 +404,22 @@ impl Response { self.init.with_mut(|init| init.headers.take()) } + /// Settles what the body says about the headers. Call it before the body is taken to be sent. + pub(crate) fn prepare_headers_for_send(&self, global_this: &JSGlobalObject) -> JsResult<()> { + if self.get_body_value().facts().contains(BodyFacts::DECODED) { + if let Some(headers) = self.get_init_headers_mut() { + headers.fast_remove(HTTPHeaderName::ContentEncoding); + } + } + let body = self.get_body_value(); + body.to_blob_if_possible(); + // Bytes are sent with their own type. A stream's type can only be sent as a header. + if matches!(body, BodyValue::Locked(_)) { + self.copy_content_type_to_headers(global_this)?; + } + Ok(()) + } + #[inline] pub(crate) fn get_method(&self) -> Method { self.init.get().method @@ -415,20 +442,6 @@ impl Response { } impl Response { - #[inline] - pub(crate) fn get_form_data_encoding( - &self, - ) -> JsResult>> { - let Some(content_type_slice) = self.get_content_type()? else { - return Ok(None); - }; - // content_type_slice drops at scope exit - let Some(encoding) = bun_core::form_data::Encoding::get(content_type_slice.slice()) else { - return Ok(None); - }; - Ok(Some(bun_core::form_data::AsyncFormData::init(encoding))) - } - pub(crate) fn calculate_estimated_byte_size(&self) { self.reported_estimated_size.set( self.body.get().value.get().estimated_size() @@ -505,10 +518,6 @@ impl Response { } impl Response { - pub(crate) fn get_fetch_headers(&self) -> Option<&FetchHeaders> { - self.init.get().headers.as_deref() - } - #[inline] pub(crate) fn status_code(&self) -> u16 { self.init.get().status_code @@ -528,11 +537,16 @@ impl Response { } pub(crate) fn get_type(&self, cx: &mut JsScope<'_>) -> JSValue { - if self.init.get().status_code < 200 { - return cx.global().common_strings().error(); + // https://fetch.spec.whatwg.org/#concept-response-type + let common_strings = cx.global().common_strings(); + if self.init.get().status_code == 0 { + return common_strings.error(); } - - cx.global().common_strings().default() + // Only a fetched response has a URL. Bun has no origin, so "cors" and "opaque" never apply. + if !self.url.get().is_empty() { + return common_strings.basic(); + } + common_strings.default() } pub(crate) fn get_status_text(&self, cx: &mut JsScope<'_>) -> JsResult { @@ -564,18 +578,9 @@ impl Response { // hold the borrow across calls that re-enter Response host-fns. let init = self.init_mut(); if init.headers.is_none() { - init.headers = Some(HeadersRef::create_empty()); - - if let BodyValue::Blob(blob) = self.body.get().value.get() { - let content_type = blob.content_type_slice(); - if !content_type.is_empty() { - init.headers.as_mut().unwrap().put( - HTTPHeaderName::ContentType, - &StringView::ascii(content_type), - global_this, - )?; - } - } + let headers = init.headers.insert(HeadersRef::create_empty()); + self.get_body_value() + .put_content_type_default(headers, global_this)?; } Ok(init.headers.as_mut().unwrap()) @@ -586,23 +591,17 @@ impl Response { Ok(self.get_or_create_headers(global_this)?.to_js(global_this)) } - pub(crate) fn get_content_type(&self) -> JsResult>> { + pub(crate) fn get_content_type(&self) -> Option> { // R-2 escape hatch via `init_mut()` — `fast_get` (FFI out-param write) // does not re-enter JS. if let Some(headers) = self.init_mut().headers.as_mut() { if let Some(value) = headers.fast_get(HTTPHeaderName::ContentType) { - return Ok(Some(value.to_utf8())); - } - } - - if let BodyValue::Blob(blob) = self.body.get().value.get() { - let content_type = blob.content_type_slice(); - if !content_type.is_empty() { - return Ok(Some(Utf8Bytes::Borrowed(content_type))); + return Some(value.to_utf8()); } } - Ok(None) + let content_type = self.body.get().value.get().content_type().as_slice(); + (!content_type.is_empty()).then_some(Utf8Bytes::Borrowed(content_type)) } } @@ -788,13 +787,11 @@ impl Response { // // - `Init` field drop glue releases `headers` (HeadersRef::Drop → // C++ deref) and `status_text` (WTF deref). - // - `Body` has NO `Drop`; `reset()` is the explicit cleanup API - // (Body.rs renames `deinit` → `reset`). `drop_in_place` here - // would leak refcounted payloads (WTFStringImpl, Blob store). + // - `body` — assignment drops the old `Value`, a locked one's owned fields included. // - `url` — assignment drops the old value (WTF deref). // - `JsRef` — assignment drops the `Strong` arm (block slot released). (*this).init.set(Init::default()); - (*this).body.get_mut().reset(); + (*this).body.set(Body::default()); (*this).url.set(BunString::EMPTY); (*this).js_ref.set(JsRef::empty()); (*this).abort_listener.set(None); @@ -841,12 +838,10 @@ impl Response { if let Some(&json_value) = args.first() { let str = Self::json_text_of(global_this, json_value)?; - 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()); - } + 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.get(1) { @@ -890,12 +885,10 @@ impl Response { |r| r.body.get().reset(), ); let str = Self::json_text_of(global_this, json_value)?; - 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()); - } + 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()); response.init.set(Init::from_parts(global_this, parts)?); let headers_ref = response.get_or_create_headers(global_this)?; headers_ref.put_default( @@ -909,28 +902,15 @@ impl Response { #[inline(always)] fn json_text_of(global_this: &JSGlobalObject, json_value: JSValue) -> JsResult { - // 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 - || json_value.js_type() == bun_jsc::JSType::CompiledClosure - || json_value.js_type() == bun_jsc::JSType::SlimClosure - || json_value.js_type() == bun_jsc::JSType::SlimClosureWithProperties - { - 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. - json_value.json_stringify_fast(global_this) + let text = json_value.json_stringify_fast(global_this)?; + // An empty string means `JSON.stringify` returned undefined. + if text.is_empty() { + return Err( + global_this.throw_type_error(format_args!("Value is not JSON serializable")) + ); + } + Ok(text) } fn validate_redirect_status_code( @@ -972,15 +952,18 @@ impl Response { } else if arg_init.is_number() { 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); } } } @@ -1089,23 +1072,24 @@ impl Response { } } } + let init_value = init; let init: Init = 'brk: { - if init.is_undefined_or_null() { + if init_value.is_undefined_or_null() { break 'brk Init { status_code: 200, headers: None, ..Default::default() }; } - if init.is_object() { - break 'brk Init::init(global_this, init)?.expect("unreachable"); + if init_value.is_object() { + break 'brk Init::init(global_this, init_value)?.expect("unreachable"); } return Err(global_this.throw_invalid_arguments(format_args!( "Failed to construct 'Response': The provided body value is not of type 'ResponseInit'", ))); }; - Self::from_body_and_init(global_this, body, init) + Self::from_body_and_init(global_this, body, init, init_value) } #[inline(always)] @@ -1113,6 +1097,7 @@ impl Response { global_this: &JSGlobalObject, body: JSValue, mut init: Init, + init_value: JSValue, ) -> JsResult> { let body: Body = 'brk: { if body.is_undefined_or_null() { @@ -1125,23 +1110,23 @@ 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. - if let BodyValue::Blob(blob) = body.value.get() { - if let Some(headers) = init.headers.as_deref_mut() { - let content_type = blob.content_type_slice(); - if !content_type.is_empty() && !headers.fast_has(HTTPHeaderName::ContentType) { - headers.put( - HTTPHeaderName::ContentType, - &StringView::ascii(content_type), - global_this, - )?; + if let Some(headers) = init.headers.as_deref_mut() { + body.value + .get() + .put_content_type_default(headers, global_this)?; + } else if let Some(source) = BodyValue::from_request_or_response(init_value) { + // SAFETY: `init_value` is an argument of this call, so the Request or Response it wraps is live. + let source = unsafe { &*source }; + // It has no header list either, so the type its body implies is the one header to copy. + if body.value.get().content_type().is_empty() && !source.content_type().is_empty() { + if let BodyValue::Locked(stream) = body.value_mut() { + stream.set_content_type(source.content_type().clone()); + } else { + let headers = init.headers.insert(HeadersRef::create_empty()); + source.put_content_type_default(headers, global_this)?; } } } @@ -1168,7 +1153,7 @@ impl Response { ))); } let init = Init::from_parts(global_this, parts)?; - let response = Self::from_body_and_init(global_this, body, init)?; + let response = Self::from_body_and_init(global_this, body, init, JSValue::UNDEFINED)?; Ok(Response::to_js(response, global_this)) } } @@ -1222,8 +1207,7 @@ fn Response__jsonFromParts( // 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, @@ -1281,20 +1265,7 @@ impl Init { } fn set_headers(&mut self, global_this: &JSGlobalObject, headers: JSValue) -> JsResult<()> { - // `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() { - self.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 { - self.headers = HeadersRef::create_from_js(global_this, headers)?; - } + self.headers = HeadersRef::from_init_value(global_this, headers)?; Ok(()) } @@ -1315,9 +1286,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() }; @@ -1325,35 +1305,21 @@ impl Init { return Ok(None); } - let js_type = response_init.js_type(); - - if !js_type.is_object() { + if !response_init.js_type().is_object() { return Ok(None); } - if js_type == JSType::DOMWrapper { - // fast path: it's a Request object or a Response object - // we can skip calling JS getters - if let Some(req) = response_init.as_direct::() { - // SAFETY: `as_direct` returned a live `*mut Request` owned by the - // JS wrapper cell; the wrapper is rooted by `response_init` for - // the duration of this call, so no GC can finalize it here. - // Everything touched is `&self`. - let req = unsafe { &*req }; + match Pristine::of(response_init) { + Pristine::Request(req) => { if let Some(headers) = req.get_fetch_headers_unless_empty() { result.headers = headers.clone_this(global_this)?; } - result.method = req.method; + result.method = req.method.known().unwrap_or(Method::GET); return Ok(Some(result)); } - - if let Some(resp) = response_init.as_direct::() { - // SAFETY: `as_direct` returned a live `*mut Response` owned by the - // JS wrapper cell; rooted by `response_init` for this call. - let resp = unsafe { &*resp }; - return Ok(Some(resp.init.get().clone(global_this)?)); - } + Pristine::Response(resp) => return Ok(Some(resp.init.get().clone(global_this)?)), + Pristine::No => {} } if let Some(headers) = response_init.fast_get(global_this, BuiltinName::headers)? { diff --git a/src/runtime/webcore/blob/Store.rs b/src/runtime/webcore/blob/Store.rs index 15130252fdbc..3de656d7c26b 100644 --- a/src/runtime/webcore/blob/Store.rs +++ b/src/runtime/webcore/blob/Store.rs @@ -6,7 +6,6 @@ //! (S3 I/O, async file ops, structured-clone serialize) via extension traits. use core::ffi::c_void; -use core::ptr::NonNull; use crate::node::fs as node_fs; use crate::node::types::PathOrFileDescriptorSerializeTag; @@ -474,23 +473,6 @@ impl BytesExt for Bytes { self.len = 0; self.cap = 0; self.allocator = bun_alloc::basic::C_ALLOCATOR; - super::Internal { - bytes, - was_string: false, - } + bytes.into() } } - -/// JSC `ArrayBuffer` external -/// deallocator callback for buffers backed by a `Blob.Store`. C++ stashes a -/// `*mut Store` as the deallocator context; this releases that ref. -#[unsafe(no_mangle)] -pub(crate) extern "C" fn BlobArrayBuffer_deallocator( - _bytes: *mut core::ffi::c_void, - blob: *mut core::ffi::c_void, -) { - // SAFETY: `blob` is the non-null `*mut Store` C++ stashed as deallocator - // context (originating from `heap::alloc` / `RefPtr::into_raw`); it - // owns one outstanding reference being released here. - unsafe { Store::deref(NonNull::new_unchecked(blob.cast::())) }; -} diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 3a5e8c15af81..e95730c22a53 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -36,6 +36,10 @@ pub(crate) mod compress_body; pub(crate) mod fetch_session; pub(crate) use self::fetch_session::FetchSession; +#[path = "fetch/RequestInit.rs"] +pub(crate) mod request_init; +use self::request_init::{HeadersInit, InitSources, flag}; + #[path = "fetch/proxy_testing.rs"] pub(crate) mod proxy_testing; @@ -48,25 +52,23 @@ use crate::webcore::jsc::{ }; use bun_core::{String as BunString, StringView, 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_http_types::Method::{Method, OwnedMethod}; use bun_jsc::bindgen::{JsLocal, JsScope}; -use bun_jsc::{HTTPHeaderName, StringJsc as _, SysErrorJsc as _, URLJsc as _}; +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::{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; -use crate::webcore::{ - AbortSignal, Blob, Body, FetchHeaders, ObjectURLRegistry, ReadableStream, Request, Response, -}; +use crate::webcore::{Blob, Body, ObjectURLRegistry, ReadableStream, Request, Response}; use crate::webcore::{blob, readable_stream, response}; use bun_http_jsc as _; use bun_http_jsc::headers_jsc::from_fetch_headers; @@ -75,7 +77,7 @@ use bun_jsc::AbortSignalRef; use bun_paths::resolve_path::PosixToWinNormalizer; use bun_picohttp as picohttp; use bun_ptr::RefPtr; -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; @@ -85,10 +87,6 @@ pub(crate) use self::fetch_tasklet::{ FetchTasklet, FetchTaskletDeinitHop, FetchTaskletRequestDrain, }; -// ────────────────────────────────────────────────────────────────────────── -// Local extension shims (upstream methods not yet ported / not in scope) -// ────────────────────────────────────────────────────────────────────────── - /// The `unix` option when it is a non-empty string. fn parse_unix( vm: &VirtualMachine, @@ -144,61 +142,41 @@ fn s3_credentials_for_fetch(global_this: &JSGlobalObject) -> bun_s3_signing::S3C ) } -/// `Blob.Any` accessor shim. -trait AnyBlobExt { - fn blob(&self) -> &Blob; -} -impl AnyBlobExt for blob::Any { - fn blob(&self) -> &Blob { - match self { - blob::Any::Blob(b) => b, - _ => unreachable!("Blob.Any::blob() on non-Blob variant"), - } - } -} - -/// `HTTPRequestBody` accessor shim missing from FetchTasklet.rs. -trait HTTPRequestBodyExt { - fn any_blob(&self) -> &blob::Any; -} -impl HTTPRequestBodyExt for HTTPRequestBody { - fn any_blob(&self) -> &blob::Any { - match self { - HTTPRequestBody::AnyBlob(b) => b, - _ => unreachable!("HTTPRequestBody::any_blob() on non-AnyBlob"), - } - } -} - // ────────────────────────────────────────────────────────────────────────── // 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, + &StringView::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 = RefPtr::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)), @@ -206,7 +184,37 @@ fn data_url_response(url: BunString, global_this: &JSGlobalObject) -> JSValue { false, )); - JSPromise::resolved_promise_value(global_this, Response::to_js(response, global_this)) + Ok(JSPromise::resolved_promise_value( + global_this, + Response::to_js(response, global_this), + )) +} + +/// `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)) } // ────────────────────────────────────────────────────────────────────────── @@ -254,7 +262,7 @@ pub(crate) fn preconnect(cx: &mut JsScope<'_>, url: &BunString) -> JsResult<()> }; } - 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")) @@ -278,13 +286,13 @@ pub(crate) fn preconnect(cx: &mut JsScope<'_>, url: &BunString) -> JsResult<()> // `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. 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() { reclaim_href!(); return Ok(()); } - http::async_http::preconnect(url, true); + http::async_http::preconnect(url, true, crate::socket::http_client_defaults(vm)); Ok(()) } @@ -318,7 +326,7 @@ pub(crate) fn fetch( input: JsLocal<'_>, init: JsLocal<'_>, ) -> JsResult { - fetch_impl::(cx.global(), input.to_js(), init.to_js(), None) + fetch_impl(cx.global(), input.to_js(), init.to_js(), None) } /// What `session.fetch` is bound from: `fetch()` with `this` as its session. @@ -328,7 +336,7 @@ pub(crate) fn session_fetch( input: JsLocal<'_>, init: JsLocal<'_>, ) -> JsResult { - fetch_impl::( + fetch_impl( cx.global(), input.to_js(), init.to_js(), @@ -367,7 +375,7 @@ fn reject_before_send( } /// Shared implementation of fetch -fn fetch_impl( +fn fetch_impl( ctx: &JSGlobalObject, first_arg: JSValue, options: JSValue, @@ -388,18 +396,10 @@ fn fetch_impl( let vm = VirtualMachine::get().as_mut(); let mut upgraded_connection = false; - let mut forced_protocol: Option = None; - - let mut headers: Option = None; let vm_verbose_fetch = vm.get_verbose_fetch(); - let mut disable_timeout = false; let mut idle_timeout_seconds: Option = None; - let mut disable_keepalive = false; - let mut disable_decompression = false; - let mut compress: Option = None; - let mut max_redirects: Option = None; let mut verbose: http::HTTPVerboseLevel = if vm .log_ref() .is_some_and(|l| l.level.at_least(bun_ast::Level::Debug)) @@ -413,14 +413,10 @@ fn fetch_impl( } let mut proxy: Option = None; - let mut redirect_type: FetchRedirect = FetchRedirect::Follow; - let signal: Option; let mut range: Option = None; - let mut unix_socket_path: Box<[u8]> = Box::default(); let mut url_type = URLType::Remote; - let mut ssl_config: Option = None; let mut reject_unauthorized = vm.get_tls_reject_unauthorized(); let mut check_server_identity: JSValue = JSValue::ZERO; @@ -434,24 +430,12 @@ fn fetch_impl( break 'brk None; }; - // kept as raw `*mut Request` because the body re-borrows it - // multiple times across long-lived option/init reads. - let request: Option<*mut Request> = 'brk: { - if first_arg.is_cell() { - if let Some(request_) = first_arg.as_direct::() { - break 'brk Some(request_); - } - } - break 'brk None; + let request: Option<&Request> = if first_arg.is_cell() { + // SAFETY: `first_arg`, which the call frame keeps alive, owns the Request. + first_arg.as_direct::().map(|p| unsafe { &*p }) + } else { + None }; - // Helper macro: short-lived `&Request` reborrow of the optional pointer. - macro_rules! request_ref { - () => { - // SAFETY: `request` was obtained from a live JS-owned Request via - // `as_direct`; each reborrow is non-overlapping at the call site. - request.map(|p| unsafe { &*p }) - }; - } // If it's NOT a Request or a subclass of Request, treat the first argument as a URL. let url_str_optional = if first_arg.as_::().is_none() { @@ -478,7 +462,7 @@ fn fetch_impl( break 'extract_url str; } - if let Some(req) = request_ref!() { + if let Some(req) = request { let _ = req.ensure_url(); // bun.handleOom — aborts on OOM break 'extract_url req.url.get().clone(); } @@ -491,7 +475,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() { @@ -502,24 +490,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 href = bun_url::href_from_string_without_fragment(&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(); + if url_proxy_buffer.starts_with(b"s3:") { + // `#` is part of an S3 key. + url_proxy_buffer = bun_url::href_from_string(&url_str).to_owned_slice(); + } let mut url = ZigURL::parse(&url_proxy_buffer[..]); if url.is_file() { url_type = URLType::File; @@ -529,164 +519,92 @@ fn fetch_impl( // **Start with the harmless ones.** - // "method" - let mut method = '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_)?; - } - } - - if let Some(req) = request_ref!() { - break 'extract_method Some(req.method); - } - - 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_)?; - } - } + let sources = InitSources { + init: options_object, + request, + object: request_init_object, + }; - break 'extract_method None; - } - .unwrap_or(Method::GET); + // "method" + let mut method: OwnedMethod = sources + .member( + |object| match object.fast_get_truthy(global_this, jsc::BuiltinName::method)? { + Some(method) => method_jsc::request_method_from_js(global_this, method).map(Some), + None => Ok(None), + }, + |req| Ok(Some(req.method.clone())), + )? + .unwrap_or_else(|| Method::GET.into()); // "decompress: boolean" - disable_decompression = 'extract_disable_decompression: { - let objects_to_try = [ - options_object.unwrap_or_default(), - request_init_object.unwrap_or_default(), - ]; - - for obj in objects_to_try { - if !obj.is_empty() { - if let Some(decompression_value) = - obj.get_common_string(global_this, jsc::CommonString::FetchOptionDecompress)? - { - if decompression_value.is_boolean() { - break 'extract_disable_decompression !decompression_value.as_boolean(); - } else if decompression_value.is_number() { - break 'extract_disable_decompression decompression_value.to_int32() == 0; - } - } - } - } - - break 'extract_disable_decompression disable_decompression; - }; + let disable_decompression = sources + .option(global_this, jsc::CommonString::FetchOptionDecompress, |v| { + Ok(flag(v)) + })? + .is_some_and(|decompress| !decompress); // "compress: boolean | string | { encoding, level? }" - 'extract_compress: { - let objects_to_try = [ - options_object.unwrap_or_default(), - request_init_object.unwrap_or_default(), - ]; - - for obj in objects_to_try { - if !obj.is_empty() { - if let Some(compress_value) = - obj.get_common_string(global_this, jsc::CommonString::FetchOptionCompress)? - { - if !compress_value.is_undefined() { - compress = compress_body::from_js(global_this, compress_value)?; - break 'extract_compress; - } - } - } - } - } + let mut compress: Option = sources + .option(global_this, jsc::CommonString::FetchOptionCompress, |v| { + compress_body::from_js(global_this, v).map(Some) + })? + .flatten(); // "maxRedirects: number" - 'extract_max_redirects: { - let objects_to_try = [ - options_object.unwrap_or_default(), - request_init_object.unwrap_or_default(), - ]; - - for obj in objects_to_try { - if !obj.is_empty() { - if let Some(value) = - obj.get_common_string(global_this, jsc::CommonString::FetchOptionMaxRedirects)? - { - if !value.is_undefined_or_null() { - if !value.is_number() { - return Err(global_this.throw_invalid_arguments(format_args!( - "fetch: 'maxRedirects' must be a non-negative integer" - ))); - } - let n = value.as_number(); - if n.is_nan() || n < 0.0 || n.fract() != 0.0 { - return Err(global_this.throw_invalid_arguments(format_args!( - "fetch: 'maxRedirects' must be a non-negative integer" - ))); - } - max_redirects = Some(n.min(126.0) as u8); - break 'extract_max_redirects; - } - } + let max_redirects: Option = sources.option( + global_this, + jsc::CommonString::FetchOptionMaxRedirects, + |value| { + if value.is_null() { + return Ok(None); } - } - } + let n = if value.is_number() { + value.as_number() + } else { + f64::NAN + }; + if n.is_nan() || n < 0.0 || n.fract() != 0.0 { + return Err(global_this.throw_invalid_arguments(format_args!( + "fetch: 'maxRedirects' must be a non-negative integer" + ))); + } + Ok(Some(n.min(126.0) as u8)) + }, + )?; // "session: Bun.FetchSession" - let session_hold: Option = 'extract_session: { - if let Some(bound) = bound_session { - break 'extract_session Some(fetch_session::SessionHold::from_js(global_this, bound)?); - } - let objects_to_try = [ - options_object.unwrap_or_default(), - request_init_object.unwrap_or_default(), - ]; - for obj in objects_to_try { - if !obj.is_empty() { - if let Some(value) = - obj.get_common_string(global_this, jsc::CommonString::FetchOptionSession)? - { - if !value.is_undefined_or_null() { - break 'extract_session Some(fetch_session::SessionHold::from_js( - global_this, - value, - )?); - } + let session_hold: Option = match bound_session { + Some(bound) => Some(fetch_session::SessionHold::from_js(global_this, bound)?), + None => sources.option( + global_this, + jsc::CommonString::FetchOptionSession, + |value| { + if value.is_null() { + return Ok(None); } - } - } - break 'extract_session None; + fetch_session::SessionHold::from_js(global_this, value).map(Some) + }, + )?, }; let session = session_hold.as_ref(); // "tls: TLSConfig". A request's `tls` replaces its session's as a whole. let mut request_has_tls = false; - ssl_config = 'extract_ssl_config: { - let objects_to_try = [ - options_object.unwrap_or_default(), - request_init_object.unwrap_or_default(), - ]; - - for obj in objects_to_try { - if !obj.is_empty() { - if let Some(tls) = - obj.get_common_string(global_this, jsc::CommonString::FetchOptionTls)? - { - if tls.is_object() { - request_has_tls = true; - let parsed = fetch_session::parse_tls(vm, global_this, tls)?; - if let Some(reject) = parsed.reject_unauthorized { - reject_unauthorized = reject; - } - if let Some(callback) = parsed.check_server_identity { - check_server_identity = callback; - } - if parsed.ssl_config.is_some() { - break 'extract_ssl_config parsed.ssl_config; - } - } - } + let mut ssl_config: Option = + sources.option(global_this, jsc::CommonString::FetchOptionTls, |tls| { + if !tls.is_object() { + return Ok(None); } - } - - break 'extract_ssl_config ssl_config; - }; + request_has_tls = true; + let parsed = fetch_session::parse_tls(vm, global_this, tls)?; + if let Some(reject) = parsed.reject_unauthorized { + reject_unauthorized = reject; + } + if let Some(callback) = parsed.check_server_identity { + check_server_identity = callback; + } + Ok(parsed.ssl_config) + })?; // A per-request closure has no identity a pool key could compare. let bypass_pool = !check_server_identity.is_empty(); // The session's callbacks stay on the session, which the request keeps alive. @@ -701,202 +619,122 @@ fn fetch_impl( } // unix: string | undefined - unix_socket_path = 'extract_unix_socket_path: { - let objects_to_try = [ - options_object.unwrap_or_default(), - request_init_object.unwrap_or_default(), - ]; - - for obj in objects_to_try { - if !obj.is_empty() { - if let Some(socket_path) = - obj.get_common_string(global_this, jsc::CommonString::FetchOptionUnix)? - { - if let Some(path) = parse_unix(vm, global_this, socket_path)? { - break 'extract_unix_socket_path path; - } + let mut unix_socket_path: Box<[u8]> = sources + .option( + global_this, + jsc::CommonString::FetchOptionUnix, + |socket_path| { + let path = parse_unix(vm, global_this, socket_path)?; + // Ignoring it would send the request to the URL's host over TCP. + if path.is_none() && !socket_path.is_string() && socket_path.to_boolean() { + return Err(global_this + .throw_invalid_arguments(format_args!("fetch: 'unix' must be a string"))); } - } - } - break 'extract_unix_socket_path unix_socket_path; - }; + Ok(path) + }, + )? + .unwrap_or_default(); let request_has_unix = !unix_socket_path.is_empty(); // protocol: "http2" | "h2" | "http1.1" | "h1" | undefined. - 'extract_protocol: { - let objects_to_try = [ - options_object.unwrap_or_default(), - request_init_object.unwrap_or_default(), - ]; - for obj in objects_to_try { - if !obj.is_empty() { - if let Some(protocol_val) = - obj.get_common_string(global_this, jsc::CommonString::FetchOptionProtocol)? - { - if protocol_val.is_string() { - let str = protocol_val.to_js_string_view(global_this)?; - if str.eq_ascii(b"http2") || str.eq_ascii(b"h2") { - forced_protocol = Some(http::Protocol::Http2); - } else if str.eq_ascii(b"http3") || str.eq_ascii(b"h3") { - forced_protocol = Some(http::Protocol::Http3); - } else if str.eq_ascii(b"http1.1") || str.eq_ascii(b"h1") { - forced_protocol = Some(http::Protocol::Http1_1); - } else { - return Err(global_this.throw_invalid_arguments( - format_args!("fetch: 'protocol' must be \"http2\", \"h2\", \"http3\", \"h3\", \"http1.1\", or \"h1\""), - )); - } - break 'extract_protocol; - } - } + let forced_protocol: Option = sources.option( + global_this, + jsc::CommonString::FetchOptionProtocol, + |protocol| { + if !protocol.is_string() { + return Ok(None); } - } - } + let str = protocol.to_js_string_view(global_this)?; + if str.eq_ascii(b"http2") || str.eq_ascii(b"h2") { + Ok(Some(http::Protocol::Http2)) + } else if str.eq_ascii(b"http3") || str.eq_ascii(b"h3") { + Ok(Some(http::Protocol::Http3)) + } else if str.eq_ascii(b"http1.1") || str.eq_ascii(b"h1") { + Ok(Some(http::Protocol::Http1_1)) + } else { + Err(global_this.throw_invalid_arguments(format_args!( + "fetch: 'protocol' must be \"http2\", \"h2\", \"http3\", \"h3\", \"http1.1\", or \"h1\"" + ))) + } + }, + )?; // timeout: false | number | undefined - disable_timeout = 'extract_disable_timeout: { - let objects_to_try = [ - options_object.unwrap_or_default(), - request_init_object.unwrap_or_default(), - ]; - - for obj in objects_to_try { - if !obj.is_empty() { - if let Some(timeout_value) = - obj.get_common_string(global_this, jsc::CommonString::FetchOptionTimeout)? - { - if timeout_value.is_boolean() { - break 'extract_disable_timeout !timeout_value.as_boolean(); - } else if timeout_value.is_number() { - // A finite positive `timeout` (in ms) also governs the - // socket idle deadline, overriding the global - // `BUN_CONFIG_HTTP_IDLE_TIMEOUT` default. - let ms = timeout_value.as_number(); - if ms.is_finite() && ms > 0.0 { - idle_timeout_seconds = - Some((ms / 1000.0).ceil().min(core::ffi::c_uint::MAX as f64) - as core::ffi::c_uint); - } - // `to_int32()` saturates ±Infinity (JSC's - // `coerceJSValueDoubleTruncatingT`, not spec ToInt32), - // so gate on `is_finite()` too so `{timeout: Infinity}` - // disables the timer instead of silently falling back - // to the global default. - break 'extract_disable_timeout !ms.is_finite() - || timeout_value.to_int32() == 0; - } + let disable_timeout = sources + .option( + global_this, + jsc::CommonString::FetchOptionTimeout, + |timeout| { + if !timeout.is_number() { + return Ok(flag(timeout).map(|enabled| !enabled)); } - } - } - - break 'extract_disable_timeout disable_timeout; - }; - - // redirect: "follow" | "error" | "manual" | undefined; - redirect_type = 'extract_redirect_type: { - // First, try to use the Request object's redirect if available - if let Some(req) = request_ref!() { - redirect_type = req.flags.redirect; - } - - // Then check options/init objects which can override the Request's redirect - let objects_to_try = [ - options_object.unwrap_or_default(), - request_init_object.unwrap_or_default(), - ]; - - for obj in objects_to_try { - if !obj.is_empty() { - if let Some(redirect_value) = - obj.get_optional_enum::(global_this, "redirect")? - { - break 'extract_redirect_type redirect_value; + // A finite positive `timeout` (in ms) also governs the socket idle deadline, instead of `BUN_CONFIG_HTTP_IDLE_TIMEOUT`. + let ms = timeout.as_number(); + if ms.is_finite() && ms > 0.0 { + idle_timeout_seconds = + Some((ms / 1000.0).ceil().min(core::ffi::c_uint::MAX as f64) + as core::ffi::c_uint); } - } - } + // `to_int32()` saturates ±Infinity, which has to disable the timer like 0 does. + Ok(Some(!ms.is_finite() || timeout.to_int32() == 0)) + }, + )? + .unwrap_or(false); - break 'extract_redirect_type redirect_type; - }; + // redirect: "follow" | "error" | "manual" | undefined; + let redirect_type: FetchRedirect = sources + .member( + |object| object.get_optional_enum::(global_this, "redirect"), + |req| Ok(Some(req.flags.redirect)), + )? + .unwrap_or(FetchRedirect::Follow); // keepalive: boolean | undefined; the session's setting is the default. - if session.is_some_and(|c| !c.keep_alive()) { - disable_keepalive = true; - } - disable_keepalive = 'extract_disable_keepalive: { - let objects_to_try = [ - options_object.unwrap_or_default(), - request_init_object.unwrap_or_default(), - ]; - - for obj in objects_to_try { - if !obj.is_empty() { - if let Some(keepalive_value) = - obj.get_common_string(global_this, jsc::CommonString::FetchOptionKeepalive)? - { - if keepalive_value.is_boolean() { - break 'extract_disable_keepalive !keepalive_value.as_boolean(); - } else if keepalive_value.is_number() { - break 'extract_disable_keepalive keepalive_value.to_int32() == 0; - } - } - } - } - - break 'extract_disable_keepalive disable_keepalive; - }; + let disable_keepalive = sources + .option(global_this, jsc::CommonString::FetchOptionKeepalive, |v| { + Ok(flag(v)) + })? + .map_or_else( + || session.is_some_and(|c| !c.keep_alive()), + |keepalive| !keepalive, + ); // verbose: boolean | "curl" | undefined; - verbose = 'extract_verbose: { - let objects_to_try = [ - options_object.unwrap_or_default(), - request_init_object.unwrap_or_default(), - ]; - - for obj in objects_to_try { - if !obj.is_empty() { - if let Some(verb) = - obj.get_common_string(global_this, jsc::CommonString::FetchOptionVerbose)? - { - if verb.is_string() { - if verb.to_js_string_view(global_this)?.eq_ascii(b"curl") { - break 'extract_verbose http::HTTPVerboseLevel::Curl; - } - } else if verb.is_boolean() { - break 'extract_verbose if verb.to_boolean() { - http::HTTPVerboseLevel::Headers - } else { - http::HTTPVerboseLevel::None - }; - } - } - } - } - break 'extract_verbose verbose; - }; + if let Some(level) = + sources.option(global_this, jsc::CommonString::FetchOptionVerbose, |verb| { + Ok(if verb.is_string() { + verb.to_js_string_view(global_this)? + .eq_ascii(b"curl") + .then_some(http::HTTPVerboseLevel::Curl) + } else if verb.is_boolean() { + Some(if verb.to_boolean() { + http::HTTPVerboseLevel::Headers + } else { + http::HTTPVerboseLevel::None + }) + } else { + None + }) + })? + { + verbose = level; + } // proxy: string | URL | { url, headers?, respectNoProxy? } | false | undefined; let mut proxy_headers: Option = None; let mut proxy_direct = false; let mut proxy_respects_no_proxy = true; { - let mut proxy_option: Option = None; - let objects_to_try = [ - options_object.unwrap_or_default(), - request_init_object.unwrap_or_default(), - ]; - for obj in objects_to_try { - if !obj.is_empty() && proxy_option.is_none() { - if let Some(proxy_arg) = - obj.get_common_string(global_this, jsc::CommonString::FetchOptionProxy)? - { - if let fetch_session::ProxyArg::Policy(policy) = - fetch_session::parse_proxy(global_this, proxy_arg)? - { - proxy_option = Some(policy); - } - } - } - } + let proxy_option: Option = sources.option( + global_this, + jsc::CommonString::FetchOptionProxy, + |proxy_arg| { + Ok(match fetch_session::parse_proxy(global_this, proxy_arg)? { + fetch_session::ProxyArg::Policy(policy) => Some(policy), + _ => None, + }) + }, + )?; // The request's own `unix` or `proxy` wins over whichever of the two // its session names; they cannot be combined. if let Some(session) = session { @@ -940,51 +778,19 @@ fn fetch_impl( let pool = session.map(|c| c.pool()).unwrap_or_default(); // signal: AbortSignal | null | undefined; - // WebIDL `AbortSignal?` member: present iff not undefined. A present `null` - // detaches (no fallback to the input Request's signal); a present non-null - // non-AbortSignal is a TypeError. - signal = 'extract_signal: { - if let Some(options) = options_object { - if let Some(signal_) = options.fast_get(global_this, jsc::BuiltinName::signal)? { - if signal_.is_null() { - break 'extract_signal None; - } - if let Some(signal) = AbortSignal::ref_from_js(signal_) { - break 'extract_signal Some(signal); - } - let err = ctx.to_type_error( - jsc::ErrorCode::INVALID_ARG_TYPE, - format_args!("signal is not of type AbortSignal."), - ); - return Ok(JSPromise::rejected_promise(global_this, err).to_js()); - } - } - - if let Some(req) = request_ref!() { - if let Some(signal_) = req.abort_signal() { - break 'extract_signal Some(signal_.ref_()); - } - break 'extract_signal None; - } - - if let Some(options) = request_init_object { - if let Some(signal_) = options.fast_get(global_this, jsc::BuiltinName::signal)? { - if signal_.is_null() { - break 'extract_signal None; - } - if let Some(signal) = AbortSignal::ref_from_js(signal_) { - break 'extract_signal Some(signal); - } - let err = ctx.to_type_error( - jsc::ErrorCode::INVALID_ARG_TYPE, - format_args!("signal is not of type AbortSignal."), - ); - return Ok(JSPromise::rejected_promise(global_this, err).to_js()); - } - } - - break 'extract_signal None; - }; + let signal: Option = sources + .member( + |object| { + request_init::signal_member(global_this, object, || { + global_this.throw_value(ctx.to_type_error( + jsc::ErrorCode::INVALID_ARG_TYPE, + format_args!("signal is not of type AbortSignal."), + )) + }) + }, + |req| Ok(Some(req.abort_signal().map(|signal| signal.ref_()))), + )? + .flatten(); // We do this 2nd to last instead of last so that if it's a FormData // object, we can still insert the boundary. @@ -998,175 +804,79 @@ fn fetch_impl( // // body: BodyInit | null | undefined; // - let mut body = 'extract_body: { - if let Some(options) = options_object { - if let Some(body__) = options.fast_get(global_this, jsc::BuiltinName::Body)? { - if !body__.is_undefined() { - break 'extract_body Some(HTTPRequestBody::from_js( - &ctx.js_thread(context), - body__, - )?); - } - } - } - - if let Some(req) = request_ref!() { - let body_value = req.get_body_value(); - let already_used = match body_value { - BodyValue::Used => true, - BodyValue::Locked(locked) => { - locked.action != BodyValueLockedAction::None - || locked.is_disturbed::(global_this, first_arg) + let mut body = sources + .member( + |object| match object.fast_get(global_this, jsc::BuiltinName::Body)? { + Some(body) => HTTPRequestBody::from_js(&ctx.js_thread(context), body).map(Some), + None => Ok(None), + }, + |req| { + if BodyMixin::body_unusable(req, global_this).is_some() { + return Err(global_this + .err( + jsc::ErrorCode::BODY_ALREADY_USED, + format_args!("Request body already used"), + ) + .throw()); } - _ => false, - }; - if already_used { - return Err(global_this - .err( - jsc::ErrorCode::BODY_ALREADY_USED, - format_args!("Request body already used"), - ) - .throw()); - } - body_value.to_blob_if_in_memory(); - if matches!(*body_value, BodyValue::Locked(_)) { - if let Some(readable) = req.get_body_readable_stream() { - if readable.is_disturbed(global_this) || readable.is_locked(global_this) { - return Err(global_this - .err( - jsc::ErrorCode::BODY_ALREADY_USED, - format_args!("Request body already used"), - ) - .throw()); - } - break 'extract_body Some(HTTPRequestBody::ReadableStream( - readable_stream::Strong::init(readable, global_this), - )); - } + BodyMixin::copy_content_type_to_headers(req, global_this)?; let body_value = req.get_body_value(); - if let BodyValue::Locked(locked) = body_value { - if locked.readable.has() { - break 'extract_body Some(HTTPRequestBody::ReadableStream( - readable_stream::Strong::init( - locked.readable.get().unwrap(), - global_this, - ), - )); - } - } - let readable = body_value.to_readable_stream(&global_this.js_thread(context))?; - if !readable.is_empty_or_undefined_or_null() { - if let BodyValue::Locked(locked) = body_value { - if locked.readable.has() { - break 'extract_body Some(HTTPRequestBody::ReadableStream( - readable_stream::Strong::init( - locked.readable.get().unwrap(), - global_this, - ), - )); - } - } - } - } - - break 'extract_body Some(HTTPRequestBody::AnyBlob( - req.get_body_value().use_as_any_blob(), - )); - } - - if let Some(req) = request_init_object { - if let Some(body__) = req.fast_get(global_this, jsc::BuiltinName::Body)? { - if !body__.is_undefined() { - break 'extract_body Some(HTTPRequestBody::from_js( - &ctx.js_thread(context), - body__, - )?); - } - } - } - - break 'extract_body None; - } - .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. - let mut created_headers: Option = None; - - let fetch_headers: Option<*mut FetchHeaders> = 'brk: { - if let Some(options) = options_object { - if let Some(headers_value) = - options.fast_get(global_this, jsc::BuiltinName::Headers)? - { - if !headers_value.is_undefined() { - if let Some(headers__) = FetchHeaders::cast(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; - } - break 'brk Some(headers__.as_ptr()); - } - - if let Some(headers__) = HeadersRef::create_from_js(ctx, headers_value)? { - break 'brk Some(created_headers.insert(headers__).as_ptr()); - } - - break 'brk None; + body_value.to_blob_if_in_memory(); + if matches!(*body_value, BodyValue::Locked(_)) { + if let Some(readable) = req.get_body_readable_stream() { + return Ok(Some(HTTPRequestBody::ReadableStream( + readable_stream::Strong::init(readable, global_this), + ))); } - } - } - - if let Some(req) = request_ref!() { - if let Some(head) = req.get_fetch_headers_unless_empty() { - break 'brk Some(head.as_ptr()); - } - break 'brk None; - } - - if let Some(options) = request_init_object { - if let Some(headers_value) = - options.fast_get(global_this, jsc::BuiltinName::Headers)? - { - if !headers_value.is_undefined() { - if let Some(headers__) = FetchHeaders::cast(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; + let body_value = req.get_body_value(); + let readable = + body_value.to_readable_stream(&global_this.js_thread(context))?; + if !readable.is_empty_or_undefined_or_null() { + if let BodyValue::Locked(locked) = body_value { + if locked.readable.has() { + return Ok(Some(HTTPRequestBody::ReadableStream( + readable_stream::Strong::init( + locked.readable.get().unwrap(), + global_this, + ), + ))); } - break 'brk Some(headers__.as_ptr()); - } - - if let Some(headers__) = HeadersRef::create_from_js(ctx, headers_value)? { - break 'brk Some(created_headers.insert(headers__).as_ptr()); } - - break 'brk None; } } - } - break 'extract_headers headers; - }; + Ok(Some(HTTPRequestBody::AnyBlob( + req.get_body_value().use_as_any_blob(), + ))) + }, + )? + .unwrap_or_default(); - let result = if let Some(headers_) = fetch_headers { - // `headers_` points to a live FetchHeaders (either JS-owned or - // refcounted via `created_headers` above). `FetchHeaders` is - // an opaque ZST FFI handle (S008) — safe `*mut → &mut` deref. - let headers_ref = bun_opaque::opaque_deref_mut(headers_); + // headers: Headers | undefined; + let fetch_headers: Option = sources + .member( + |object| match object.fast_get(global_this, jsc::BuiltinName::Headers)? { + Some(headers) => HeadersInit::from_js(global_this, headers).map(Some), + None => Ok(None), + }, + |req| { + Ok(Some(req.get_fetch_headers_unless_empty().and_then( + |headers| core::ptr::NonNull::new(headers.as_ptr()).map(HeadersInit::Borrowed), + ))) + }, + )? + .flatten(); + let mut headers: Option = match &fetch_headers { + Some(fetch_headers) => { + let fetch_headers = fetch_headers.get(); if url.is_s3() { - if let Some(range_) = headers_ref.fast_get(HTTPHeaderName::Range) { + if let Some(range_) = fetch_headers.fast_get(HTTPHeaderName::Range) { range = Some(range_.to_owned_slice_z()); } } - if let Some(upgrade_) = headers_ref.fast_get(HTTPHeaderName::Upgrade) { + if let Some(upgrade_) = fetch_headers.fast_get(HTTPHeaderName::Upgrade) { if http::upgrade_header_is_not_h2(upgrade_.to_utf8().slice()) { upgraded_connection = true; } @@ -1174,15 +884,13 @@ fn fetch_impl( Some(from_fetch_headers( global_this, - Some(headers_ref), + Some(fetch_headers), any_blob_content_type_opt(body.get_any_blob().map(|b| &*b)), )?) - } else { - headers - }; - - break 'extract_headers result; + } + None => None, }; + drop(fetch_headers); if proxy.is_some() && !unix_socket_path.is_empty() { let err = ctx.to_type_error( @@ -1379,7 +1087,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 = RefPtr::new(Response::init( @@ -1411,7 +1123,10 @@ 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 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."), @@ -1429,7 +1144,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, @@ -1462,7 +1177,7 @@ fn fetch_impl( if let Some(stream) = ReadableStream::from_js( ReadableStream::from_blob_copy_ref( &global_this.js_thread(context), - body.any_blob().blob(), + body.blob(), s3::MultiPartUploadOptions::DEFAULT_PART_SIZE as crate::webcore::blob::SizeType, )?, global_this, @@ -1495,7 +1210,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) { @@ -1503,14 +1224,15 @@ fn fetch_impl( } else { bun_sys::O::RDONLY | bun_sys::O::NOCTTY }; - bun_sys::open(zpath, flags, 0) + // On Windows `zpath` starts with `\\?\`. Report the caller's path, like `.text()` does. + bun_sys::open(zpath, flags, 0).map_err(|err| err.with_path(path.slice())) } } }; 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); } @@ -1537,12 +1259,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 @@ -1550,9 +1269,9 @@ fn fetch_impl( break 'use_sendfile; } - let original_size = body.any_blob().blob().size.get(); + let original_size = body.blob().size.get(); let stat_size = blob::SizeType::try_from(stat.st_size).expect("int cast"); - let blob_offset = body.any_blob().blob().offset.get(); + let blob_offset = body.blob().offset.get(); // `http::SendFile` fields are `usize`; blob sizes/offsets // are `blob::SizeType` (u64) — hence the `as usize` casts. @@ -1563,26 +1282,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 @@ -1591,8 +1314,8 @@ fn fetch_impl( let opened_fd = scopeguard::guard(opened_fd, |fd| fd.close()); // TODO: make this async + lazy - let blob_offset = body.any_blob().blob().offset.get(); - let blob_size = body.any_blob().blob().size.get(); + let blob_offset = body.blob().offset.get(); + let blob_size = body.blob().size.get(); // The `vm.node_fs()` accessor is a jsc↔runtime cycle. `read_file` // with an `Fd` path only touches `self.sync_error_buf` for // path-variant inputs, so a fresh `NodeFS` is sufficient here. @@ -1611,8 +1334,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); } @@ -1708,7 +1431,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!( @@ -1762,14 +1485,23 @@ fn fetch_impl( )?; 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, @@ -1881,10 +1613,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, @@ -1909,7 +1642,6 @@ fn fetch_impl( ssl_config: ssl_config.take(), upgraded_connection, forced_protocol, - is_node_http_client: ALLOW_GET_BODY, compress, check_server_identity: if check_server_identity.is_empty_or_undefined_or_null() { jsc::strong::Optional::empty() @@ -1981,10 +1713,9 @@ impl<'a> S3StreamWrapper<'a> { status_text: BunString::create_atom_if_possible(err.code), ..Default::default() }, - Body::new(BodyValue::InternalBlob(InternalBlob { - bytes: err.message.to_vec(), - was_string: true, - })), + Body::new(BodyValue::InternalBlob(InternalBlob::from_string_bytes( + err.message.to_vec(), + ))), self_.url, false, )); diff --git a/src/runtime/webcore/fetch/FetchSession.rs b/src/runtime/webcore/fetch/FetchSession.rs index 147553786392..0fd38b6167ea 100644 --- a/src/runtime/webcore/fetch/FetchSession.rs +++ b/src/runtime/webcore/fetch/FetchSession.rs @@ -15,9 +15,8 @@ use bun_jsc::{ }; use bun_ptr::RefPtr; -use crate::socket::ssl_config::{SSLConfig, SSLConfigFromJs as _}; -use crate::webcore::FetchHeaders; -use crate::webcore::response::HeadersRef; +use super::request_init::HeadersInit; +use crate::socket::ssl_config::ParsedTls; pub(crate) use crate::generated_bindgen::fetch_session::js_FetchSession as js; @@ -69,9 +68,10 @@ pub(crate) fn parse_tls( )); } } - if let Some(config) = SSLConfig::from_js(vm, global, tls, false)? { - parsed.ssl_config = Some(http::ssl_config::global_registry::intern(config)); - } + // `tls: {}` is a request without TLS options. + parsed.ssl_config = ParsedTls::from_js(vm, global, tls, false)? + .or_none() + .map(http::ssl_config::global_registry::intern); Ok(parsed) } @@ -139,13 +139,10 @@ pub(crate) fn parse_proxy(global: &JSGlobalObject, proxy_arg: JSValue) -> JsResu let href = proxy_href(global, url_arg)?; 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) { - 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)? { - headers = Some(from_fetch_headers(global, Some(&fetch_headers), None)?); - } + if !headers_value.is_undefined_or_null() + && let Some(fetch_headers) = HeadersInit::from_js(global, headers_value)? + { + headers = Some(from_fetch_headers(global, Some(fetch_headers.get()), None)?); } } let respect_no_proxy = proxy_arg @@ -256,7 +253,7 @@ impl FetchSession { let mut this = Box::new(FetchSession { pool: http::PoolOptions { id: NEXT_POOL_ID.fetch_add(1, Ordering::Relaxed), - idle_timeout_seconds: 0, + idle_timeout_ms: 0, max_idle_sockets: 0, }, keep_alive: true, @@ -344,9 +341,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_ms = (seconds.ceil() as u32).saturating_mul(1000); } 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 bcdb428d5d3b..0192a4220daa 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -29,6 +29,7 @@ use crate::api::bun_x509 as X509; use crate::generated_bindgen::fetch_tasklet::{ Bun__FetchTasklet__onRejectRequestStream, Bun__FetchTasklet__onResolveRequestStream, }; +use crate::webcore::blob::BodyFacts; use crate::webcore::blob::{Any as AnyBlob, Blob, SizeType as BlobSizeType, Store as BlobStore}; use crate::webcore::body::{self, Body, Value as BodyValue, ValueError as BodyValueError}; use crate::webcore::fetch::fetch_request_body_sink::FetchRequestBodySink; @@ -37,7 +38,9 @@ use crate::webcore::response::HeadersRef; use crate::webcore::sink::ReadableSinkController; use crate::webcore::streams::EncodedChunk; 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 @@ -158,11 +161,6 @@ pub struct FetchTasklet { pub(crate) concurrent_task: ConcurrentTask, /// `JsCell`: the ByteStream's drain signal reaches `on_stream_drained` through a shared ref. pub poll_ref: JsCell, - /// For Http Client requests - /// when Content-Length is provided this represents the whole size of the request - /// If chunked encoded this will represent the total received size (ignoring the chunk headers) - /// If is not chunked encoded and Content-Length is not provided this will be unknown - pub(crate) body_size: http::BodySize, /// This is url + proxy memory buffer and is owned by FetchTasklet /// We always clone url and proxy (if informed) @@ -200,7 +198,7 @@ pub struct FetchTasklet { } // Boxing `AnyBlob` is not viable: the `AnyBlob` arm is constructed/matched in -// `fetch.rs` (e.g. `HTTPRequestBodyExt::any_blob`) and would require changes +// `fetch.rs` and would require changes // across files. The enum is also short-lived per-request, so the size cost is bounded. #[allow(clippy::large_enum_variant)] pub(crate) enum HTTPRequestBody { @@ -218,6 +216,14 @@ impl Default for HTTPRequestBody { } impl HTTPRequestBody { + /// The `Blob` of a body that `is_s3()` or `needs_to_read_file()`. + pub(crate) fn blob(&self) -> &Blob { + match self { + HTTPRequestBody::AnyBlob(AnyBlob::Blob(blob)) => blob, + _ => unreachable!("the body is not a Blob"), + } + } + pub(crate) fn store(&self) -> Option<&BlobStore> { match self { HTTPRequestBody::AnyBlob(blob) => blob.store(), @@ -239,52 +245,30 @@ 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 { - 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()))) - { - return Err(cx - .global() - .err( - jsc::ErrorCode::BODY_ALREADY_USED, - format_args!("body already used"), - ) - .throw()); + 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()))), + }; } + // It threw if `value` is a stream that is disturbed or locked. + let mut body_value = BodyValue::from_js(cx.global(), value)?; body_value.to_blob_if_in_memory(); - if let BodyValue::Locked(locked) = &mut body_value { - if locked.readable.has() { - // `BodyValue` now has `Drop` (H3), so we cannot move - // `l.readable` out by value (E0509). `mem::take` leaves a default - // readable; `Value::drop` on the residual `Locked` then runs - // `readable.deinit()` on that default — a no-op. - return Ok(HTTPRequestBody::ReadableStream(core::mem::take( - &mut locked.readable, - ))); - } - } - if matches!(&body_value, BodyValue::Locked(_)) { - let readable = body_value.to_readable_stream(cx)?; - if !readable.is_empty_or_undefined_or_null() { - if let BodyValue::Locked(l) = &mut body_value { - if l.readable.has() { - // See note above re: E0509 and `Value::drop`. - return Ok(HTTPRequestBody::ReadableStream(core::mem::take( - &mut l.readable, - ))); - } - } - } + if let BodyValue::Locked(stream) = &mut body_value { + return Ok(HTTPRequestBody::ReadableStream(core::mem::take( + &mut stream.readable, + ))); } Ok(HTTPRequestBody::AnyBlob(body_value.use_as_any_blob())) } @@ -837,56 +821,59 @@ impl FetchTasklet { if !self.result.has_more { let scheduled_response_buffer = core::mem::take(&mut self.scheduled_response_buffer.list); - // done resolve body - let old = core::mem::replace( - body, - BodyValue::InternalBlob(InternalBlob { - bytes: scheduled_response_buffer, - was_string: false, - }), - ); bun_output::scoped_log!( FetchTasklet, "onBodyReceived body_value length={}", - match body { - BodyValue::InternalBlob(b) => b.bytes.len(), - _ => 0, - } + scheduled_response_buffer.len() ); - self.scheduled_response_buffer = MutableString::default(); - if matches!(old, BodyValue::Locked(_)) { - bun_output::scoped_log!(FetchTasklet, "onBodyReceived old.resolve"); - let mut old = old; - // BodyValue::resolve takes `Option>` (opaque C++ handle - // mutated via FFI); the inherent `get_fetch_headers` returns `Option<&_>`, so - // erase the borrow into a raw NonNull. Disjoint from `body` (response.init vs - // response.body) and outlives this block. - let headers = response.get_fetch_headers().map(core::ptr::NonNull::from); - let context = self.global_this.bun_vm().context_of(self.context); - BodyValue::resolve( - &mut old, - body, - &self.global_this.js_thread(context), - headers, - )?; - } + let context = self.global_this.bun_vm().context_of(self.context); + body.fulfill( + scheduled_response_buffer, + &self.global_this.js_thread(context), + )?; } } 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 @@ -908,6 +895,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 { @@ -928,7 +924,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)); } @@ -963,6 +959,12 @@ impl FetchTasklet { } // if we already respond the metadata and still need to process the body if self.is_waiting_body { + if core::mem::take(&mut self.result.slot_wanted) + && !self.response_stream.roots_wrapper() + { + // If the Response is garbage, only a collection can tell. Its finalizer gives the slot back. + vm.gc_controller.collect_for_finalizers(); + } // `scheduled_response_buffer` has two readers that both drain-and-reset: // this path (onBodyReceived) and `onStartStreamingHTTPResponseBodyCallback`, // which runs once when JS first touches `res.body` and hands any already- @@ -1043,7 +1045,7 @@ impl FetchTasklet { // checkServerIdentity passed: un-park the HTTP-thread connection // so the request is finally written to the now-verified peer. If // the connection already closed/failed the resume is a no-op - // (keyed through the abort tracker). + // (the HTTP thread looks the request up by id). if let Some(http_) = self.http.as_mut() { http::http_thread().schedule_cert_check_resume(http_); } @@ -1319,19 +1321,26 @@ impl FetchTasklet { } } - if fail == http::Error::RequestBodyLengthMismatch - && let Some(declared) = self.declared_request_body_len - { + if fail == http::Error::RequestBodyLengthMismatch { + let code = jsc::ErrorCode::ERR_HTTP_CONTENT_LENGTH_MISMATCH; let written = self.request_body_len_written; - let err = self - .global_this - .err( - jsc::ErrorCode::ERR_HTTP_CONTENT_LENGTH_MISMATCH, - format_args!( - "Request body of {written} bytes does not match the Content-Length of {declared}" - ), - ) - .to_js(); + let global = &self.global_this; + let err = match self.declared_request_body_len { + Some(declared) => global + .err( + code, + format_args!( + "Request body of {written} bytes does not match the Content-Length of {declared}" + ), + ) + .to_js(), + None => global + .err( + code, + format_args!("The file became shorter while it was being uploaded"), + ) + .to_js(), + }; return BodyValueError::JSValue(StrongOptional::create(err, &self.global_this)); } @@ -1379,6 +1388,14 @@ impl FetchTasklet { _ => fail.name(), }; + let syscall = match fail { + http::Error::ConnectionRefused => BunString::static_("connect"), + http::Error::FailedToOpenSocket if self.result.connect_errno.is_some() => { + 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()", @@ -1604,17 +1621,24 @@ impl FetchTasklet { code: BunString::static_(code), message, path, + syscall, ..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) } fn connect_errno_name(&self) -> Option<&'static str> { - if self.result.connect_errno == 0 { - return None; - } - bun_sys::SystemErrno::init(i64::from(self.result.connect_errno)).map(Into::into) + self.result.connect_errno.map(Into::into) } /// `ERR_PROXY_TUNNEL` with the `status`, `statusText` and `headers` of the @@ -1691,7 +1715,7 @@ impl FetchTasklet { // After the take, not before: a chunk the HTTP thread appends (and pauses for) in // between would otherwise reach the stream with its task finding the buffer empty, and // nothing left to undo that pause. Unconditional: also flushes body bytes the client - // holds that arrived with no follow-up read (`drain_response_body`). + // holds that arrived with no follow-up read (`receive_resumed`). this.signal_store.receive_on_demand(); this.schedule_receive_resume(); @@ -1706,7 +1730,7 @@ impl FetchTasklet { } fn get_size_hint(&self) -> BlobSizeType { - match self.body_size { + match self.result.body_size { http::BodySize::ContentLength(n) => n as BlobSizeType, http::BodySize::TotalReceived(n) => n as BlobSizeType, http::BodySize::Unknown => 0, @@ -1806,8 +1830,14 @@ impl FetchTasklet { // the body so this matches the split-read `on_body_received` path. return BodyValue::Error(self.on_reject()); } + let facts = if self.result.did_decompress { + BodyFacts::DECODED + } else { + BodyFacts::empty() + }; if self.is_waiting_body { let mut pending = body::PendingValue::new(&self.global_this); + pending.facts = facts; pending.size_hint = self.get_size_hint(); pending.task = Some(NonNull::from(&mut *self).cast::()); pending.on_start_streaming = @@ -1821,7 +1851,7 @@ impl FetchTasklet { let scheduled_response_buffer = core::mem::take(&mut self.scheduled_response_buffer); let response = BodyValue::InternalBlob(InternalBlob { bytes: scheduled_response_buffer.list, - was_string: false, + facts, }); self.scheduled_response_buffer = MutableString::default(); @@ -1963,7 +1993,6 @@ impl FetchTasklet { promise, concurrent_task: ConcurrentTask::default(), poll_ref: JsCell::new(KeepAlive::default()), - body_size: http::BodySize::Unknown, url_proxy_buffer: fetch_options.url_proxy_buffer, abort_handle: jsc::AbortHandle::for_owner::(), context: cx.context().id(), @@ -2103,7 +2132,9 @@ impl FetchTasklet { let http_client = fetch_tasklet.http.as_mut().unwrap(); http_client.client.flags.is_streaming_request_body = is_stream; http_client.client.flags.forced_protocol = fetch_options.forced_protocol; - http_client.client.flags.is_node_http_client = fetch_options.is_node_http_client; + if fetch_tasklet.upgraded_connection { + http_client.client.expect_upgrade(); + } fetch_tasklet.is_waiting_request_stream_start = is_stream; if is_stream { // An upgraded connection tunnels its "body", so those bytes are not counted. @@ -2547,8 +2578,6 @@ impl FetchTasklet { task_ref.result.metadata = None; } - task_ref.body_size = task_ref.result.body_size; - let success = task_ref.result.is_success(); if task_ref.signal_store.body_receive_mode() == BodyReceiveMode::Abandoned { @@ -2568,7 +2597,7 @@ impl FetchTasklet { // doesn't leave the ~2x doubling over-capacity that the // ArrayBuffer would adopt. Only for a consumer that wants the whole body. if task_ref.signal_store.body_receive_mode() == BodyReceiveMode::BufferAll { - if let http::BodySize::ContentLength(n) = task_ref.body_size { + if let http::BodySize::ContentLength(n) = task_ref.result.body_size { if n > scheduled.list.capacity() { let additional = n .min(SCHEDULED_PRERESERVE_MAX) @@ -2691,7 +2720,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`. @@ -2715,7 +2745,6 @@ pub(crate) struct FetchOptions { pub(crate) ssl_config: Option, pub(crate) upgraded_connection: bool, pub(crate) forced_protocol: Option, - pub(crate) is_node_http_client: bool, pub(crate) compress: Option, pub(crate) pool: http::PoolOptions, pub(crate) bypass_pool: bool, diff --git a/src/runtime/webcore/fetch/RequestInit.rs b/src/runtime/webcore/fetch/RequestInit.rs new file mode 100644 index 000000000000..a822cd7708a6 --- /dev/null +++ b/src/runtime/webcore/fetch/RequestInit.rs @@ -0,0 +1,119 @@ +//! Where each member of `fetch(input, init)` and `new Request(input, init)` comes from, and how the members they share are read. + +use core::ptr::NonNull; + +use bun_jsc::{ + AbortSignalRef, BuiltinName, CommonString, JSGlobalObject, JSValue, JsError, JsResult, +}; + +use crate::webcore::response::HeadersRef; +use crate::webcore::{AbortSignal, FetchHeaders, Request}; + +/// `init` wins over `input`, which is at most one of `request` and `object`. A URL is neither. +#[derive(Clone, Copy)] +pub(crate) struct InitSources<'a> { + /// `init`, if it's an object. + pub(crate) init: Option, + /// `input`, if it's a Request that nothing has tampered with. Its fields are read, not its getters. + pub(crate) request: Option<&'a Request>, + /// `input`, if it's any other object. It's read like `init`. + pub(crate) object: Option, +} + +impl<'a> InitSources<'a> { + /// Reads one member. `from_js` returns `None` for an object that doesn't decide it. + #[inline] + pub(crate) fn member( + &self, + mut from_js: impl FnMut(JSValue) -> JsResult>, + from_request: impl FnOnce(&'a Request) -> JsResult>, + ) -> JsResult> { + if let Some(init) = self.init + && let Some(value) = from_js(init)? + { + return Ok(Some(value)); + } + if let Some(request) = self.request { + return from_request(request); + } + self.object.map_or(Ok(None), from_js) + } + + /// [`Self::member`] for one that `request` doesn't decide. + #[inline] + pub(crate) fn object_member( + &self, + from_js: impl FnMut(JSValue) -> JsResult>, + ) -> JsResult> { + self.member(from_js, |_| Ok(None)) + } + + /// Reads an option of Bun's, which a Request doesn't carry. `parse` returns `None` for a value that doesn't decide it. + #[inline] + pub(crate) fn option( + &self, + global: &JSGlobalObject, + key: CommonString, + mut parse: impl FnMut(JSValue) -> JsResult>, + ) -> JsResult> { + self.object_member(|object| match object.get_common_string(global, key)? { + Some(value) => parse(value), + None => Ok(None), + }) + } +} + +/// `true`, `false` or a number, which is how Bun's on/off options are written. +pub(crate) fn flag(value: JSValue) -> Option { + if value.is_boolean() { + Some(value.as_boolean()) + } else if value.is_number() { + Some(value.to_int32() != 0) + } else { + None + } +} + +/// The `signal` member (WebIDL `AbortSignal?`): present unless undefined, and `null` detaches the input's signal. +pub(crate) fn signal_member( + global: &JSGlobalObject, + object: JSValue, + not_a_signal: impl FnOnce() -> JsError, +) -> JsResult>> { + let Some(value) = object.fast_get(global, BuiltinName::signal)? else { + return Ok(None); + }; + if value.is_null() { + return Ok(Some(None)); + } + match AbortSignal::ref_from_js(value) { + Some(signal) => Ok(Some(Some(signal))), + None => Err(not_a_signal()), + } +} + +/// A `headers` member that has fields. +pub(crate) enum HeadersInit { + /// The caller's own `Headers`, which whoever passed the value keeps alive. + Borrowed(NonNull), + /// Made from a record or a sequence of pairs. + Created(HeadersRef), +} + +impl HeadersInit { + /// Returns `None` if `value` has no fields. + pub(crate) fn from_js(global: &JSGlobalObject, value: JSValue) -> JsResult> { + let Some(headers) = FetchHeaders::cast_as_init(value) else { + return Ok(HeadersRef::create_from_js(global, value)?.map(Self::Created)); + }; + let is_empty = bun_opaque::opaque_deref_mut(headers.as_ptr()).is_empty(); + Ok((!is_empty).then_some(Self::Borrowed(headers))) + } + + pub(crate) fn get(&self) -> &mut FetchHeaders { + bun_opaque::opaque_deref_mut(match self { + Self::Borrowed(headers) => headers.as_ptr(), + Self::Created(headers) => headers.as_ptr(), + }) + } +} diff --git a/src/runtime/webcore/fetch/proxy_testing.bind.ts b/src/runtime/webcore/fetch/proxy_testing.bind.ts index 64bb286b2c7a..98d37558c556 100644 --- a/src/runtime/webcore/fetch/proxy_testing.bind.ts +++ b/src/runtime/webcore/fetch/proxy_testing.bind.ts @@ -14,4 +14,6 @@ export const proxyFor = b.fn({ throws: true, }); +export const tunnelLiveCount = b.fn({ returns: b.u32 }); + export const parseURL = b.fn({ args: { href: b.string.loose.utf8 }, returns: b.record(b.string.utf8) }); diff --git a/src/runtime/webcore/fetch/proxy_testing.rs b/src/runtime/webcore/fetch/proxy_testing.rs index 12ae14cb90a1..0b4b44a928ea 100644 --- a/src/runtime/webcore/fetch/proxy_testing.rs +++ b/src/runtime/webcore/fetch/proxy_testing.rs @@ -31,6 +31,11 @@ pub(crate) fn proxy_for<'a>( .transpose() } +/// `tunnelLiveCount()` +pub(crate) fn tunnel_live_count() -> u32 { + bun_http::proxy_tunnel::LIVE_TUNNELS.load(core::sync::atomic::Ordering::Relaxed) +} + /// `parseURL(href)`: what the HTTP client's URL parser makes of `href`. pub(crate) fn parse_url(href: &[u8]) -> [(&'static [u8], &[u8]); 5] { let url = URL::parse(href); diff --git a/src/runtime/webcore/response.bind.ts b/src/runtime/webcore/response.bind.ts index f3a51e8c7cb2..006c23569175 100644 --- a/src/runtime/webcore/response.bind.ts +++ b/src/runtime/webcore/response.bind.ts @@ -5,8 +5,8 @@ const js = { capability: "js", throws: true } as const; const getter = { getter: b.any.returning("string"), capability: "js" } as const; const cached = { ...getter, cache: true, ...js } as const; const headers = { ...cached, getter: b.any.returning("Headers") } as const; -/** Reads the whole body. The `stream` field of the receiver says whether a script already read from it. */ -const read = { returns: b.any.returning("Promise"), ...js, passThis: true } as const; +/** Reads the whole body. */ +const read = { returns: b.any.returning("Promise"), ...js } as const; /** Members of the trait `BodyMixin`. */ const body = { diff --git a/src/runtime/webcore/s3/client.rs b/src/runtime/webcore/s3/client.rs index 04a1bbd71af4..6881aa3c28fe 100644 --- a/src/runtime/webcore/s3/client.rs +++ b/src/runtime/webcore/s3/client.rs @@ -978,11 +978,10 @@ pub(crate) fn upload_stream( // bytes flow via `ByteStream::on_data` → `SinkHandle::write` without the JS // `readStreamIntoSink` pump. if let Some(byte_stream) = readable_stream.ptr.bytes() { - if byte_stream.sink.get().is_none() { + if byte_stream.sink.get().is_none() && readable_stream.try_lock_native(cx.global()) { sink.source = crate::webcore::streams::SourceHandle::ByteStream(byte_stream); byte_stream.sink.set(sink_handle); byte_stream.sink_paused.set(false); - readable_stream.lock_native(cx.global()); byte_stream.signal_consumer_attached(); if let Some(err) = byte_stream.take_pending_error() { @@ -1456,7 +1455,6 @@ pub(crate) fn readable_stream( ..Default::default() }); - reader.context.setup(); let readable_value = reader.to_readable_stream(cx)?; let wrapper = S3DownloadStreamWrapper::new(S3DownloadStreamWrapper { diff --git a/src/runtime/webcore/streams.rs b/src/runtime/webcore/streams.rs index 0cb3e1e76181..4aa9d1fa6fb6 100644 --- a/src/runtime/webcore/streams.rs +++ b/src/runtime/webcore/streams.rs @@ -1840,7 +1840,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`. @@ -1856,7 +1856,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/runtime/webcore/wasm_streaming.rs b/src/runtime/webcore/wasm_streaming.rs index b429e8c8ea08..8561660eeb36 100644 --- a/src/runtime/webcore/wasm_streaming.rs +++ b/src/runtime/webcore/wasm_streaming.rs @@ -12,7 +12,7 @@ use bun_core::strings; use bun_jsc::{ErrorCode, JSGlobalObject, JSValue, JsError, JsResult}; use crate::webcore::blob::{self, Any as AnyBlob, Blob, BlobExt}; -use crate::webcore::body::{BodyMixin as _, Value as BodyValue}; +use crate::webcore::body::{BodyMixin as _, Unusable, Value as BodyValue}; use crate::webcore::{ReadableStream, Response, response}; unsafe extern "C" { @@ -51,7 +51,7 @@ fn get_body_stream_or_bytes_for_wasm_streaming( }; { - let content_type_slice = response.get_content_type()?; + let content_type_slice = response.get_content_type(); let content_type: &[u8] = match &content_type_slice { Some(ct) => ct.slice(), None => b"null", @@ -86,7 +86,7 @@ fn get_body_stream_or_bytes_for_wasm_streaming( .throw()); } - if response.body_stream_check(this, ReadableStream::is_disturbed) { + if response.body_unusable(this) == Some(Unusable::Disturbed) { return Err(this .err( ErrorCode::WEBASSEMBLY_RESPONSE, diff --git a/src/runtime/webview/ChromeBackend.cpp b/src/runtime/webview/ChromeBackend.cpp index fde9608fd980..5fc55a22bfca 100644 --- a/src/runtime/webview/ChromeBackend.cpp +++ b/src/runtime/webview/ChromeBackend.cpp @@ -267,7 +267,6 @@ static constexpr us_socket_vtable_t s_cdpVTable = { .on_writable = cdpOnWritable, .on_close = cdpOnClose, .on_timeout = nullptr, - .on_long_timeout = nullptr, .on_end = cdpOnEnd, .on_connect_error = nullptr, .on_connecting_error = nullptr, diff --git a/src/runtime/webview/WebKitBackend.cpp b/src/runtime/webview/WebKitBackend.cpp index e81683070a84..887e80a36c7f 100644 --- a/src/runtime/webview/WebKitBackend.cpp +++ b/src/runtime/webview/WebKitBackend.cpp @@ -108,7 +108,6 @@ static constexpr us_socket_vtable_t s_hostVTable = { .on_writable = hostOnWritable, .on_close = hostOnClose, .on_timeout = nullptr, - .on_long_timeout = nullptr, .on_end = hostOnEnd, .on_connect_error = nullptr, .on_connecting_error = nullptr, diff --git a/src/spawn/process.rs b/src/spawn/process.rs index 0cd38f2ea294..9753a4ae0ed3 100644 --- a/src/spawn/process.rs +++ b/src/spawn/process.rs @@ -68,19 +68,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 @@ -1490,8 +1478,7 @@ pub mod waiter_thread_posix { WaiterThreadPosix::reload_handlers(); let mut active = Vec::new(); - #[allow(unused_labels)] - 'outer: loop { + loop { INSTANCE.js_process.loop_(&mut active); #[cfg(any(target_os = "linux", target_os = "android"))] @@ -1508,7 +1495,7 @@ pub mod waiter_thread_posix { // Consume the pending eventfd let mut buf = [0u8; 8]; if bun_sys::read(efd, &mut buf).unwrap_or(0) > 0 { - continue 'outer; + continue; } // SAFETY: valid pollfd array. diff --git a/src/sql_jsc/jsc.rs b/src/sql_jsc/jsc.rs index 50473d69c83d..b04899ff4790 100644 --- a/src/sql_jsc/jsc.rs +++ b/src/sql_jsc/jsc.rs @@ -170,7 +170,7 @@ pub use bun_io::KeepAlive; pub use bun_jsc::event_loop::{EventLoop, EventLoopEnterGuard as EventLoopGuard}; pub use bun_jsc::virtual_machine::VirtualMachine; -// `bun_runtime` owns the per-VM `RuntimeState` (timer heap, SSLContextCache, SSLConfig parser) and +// `bun_runtime` owns the per-VM `RuntimeState` (timer heap, SSLConfig parser) and // depends on this crate, so importing it would be a cycle. This crate defines the table of // function pointers, `bun_runtime::hw_exports::sql_hooks` the one instance. pub struct SqlRuntimeHooks { @@ -182,18 +182,9 @@ pub struct SqlRuntimeHooks { pub timer_insert: unsafe fn(heap: *mut c_void, *mut EventLoopTimer), /// `Timer.All.remove`. pub timer_remove: unsafe fn(heap: *mut c_void, *mut EventLoopTimer), - /// `&mut runtime_state().ssl_ctx_cache` — opaque `SSLContextCache`. - pub ssl_ctx_cache: unsafe fn(*mut VirtualMachine) -> *mut c_void, - /// `SSLContextCache::getOrCreateOpts` — digest-keyed weak `SSL_CTX*` cache. - pub ssl_ctx_get_or_create: unsafe fn( - cache: *mut c_void, - opts: &bun_uws::us_bun_socket_context_options_t, - err: &mut bun_uws::create_bun_socket_error_t, - ) -> Option, - /// `SSLConfig::fromJS` — parse a JS TLS-options object. Returns a boxed + /// Parses a JS TLS-options object. Returns a boxed /// `bun_runtime::socket::SSLConfig` (caller frees via `ssl_config_free`), - /// or null when the value contained no TLS config / threw (caller checks - /// `global.has_exception()`). + /// or null when it threw. pub ssl_config_from_js: fn(&JSGlobalObject, JSValue) -> *mut c_void, /// Drop a boxed `SSLConfig` returned by `ssl_config_from_js`. pub ssl_config_free: unsafe fn(*mut c_void), @@ -249,8 +240,6 @@ pub(crate) trait VirtualMachineSqlExt { fn sql_state(&mut self) -> &mut RareData; /// vm.timer — the Timer::All heap, owned by RuntimeState. fn timer(&mut self) -> &mut TimerHeap; - /// RareData.ssl_ctx_cache — owned by RuntimeState. - fn ssl_ctx_cache(&mut self) -> &mut SslCtxCache; /// bun_io::EventLoopCtx for the JS-thread VM, for KeepAlive::{ref_,unref}. fn vm_ctx(&self) -> bun_io::EventLoopCtx; /// `context`'s per-protocol uws [`bun_uws::SocketGroup`], for a new connection its script opens. @@ -279,12 +268,6 @@ impl VirtualMachineSqlExt for VirtualMachine { unsafe { &mut *(hooks().timer_heap)(self).cast::() } } #[inline] - fn ssl_ctx_cache(&mut self) -> &mut SslCtxCache { - // SAFETY: hook returns `&mut runtime_state().ssl_ctx_cache`; non-null - // after `init_runtime_state`. - unsafe { &mut *(hooks().ssl_ctx_cache)(self).cast::() } - } - #[inline] fn vm_ctx(&self) -> bun_io::EventLoopCtx { bun_io::js_vm_ctx() } @@ -390,9 +373,7 @@ pub mod api { pub mod server_config { use super::*; - /// Owning handle to a `Box`. `None` = - /// the default-constructed config — callers that pass - /// `tls: true` get an SSLConfig with no overrides. + /// Owning handle to a `Box`. `None` is a connection without TLS. #[derive(Default)] pub struct SSLConfig(Option>); @@ -454,53 +435,25 @@ pub mod api { (!bracketed && !bun_core::ip_address::is_ip_host(bare)).then_some(name) } - pub(crate) fn from_js( - global: &JSGlobalObject, - value: JSValue, - ) -> JsResult> { - let p = (hooks().ssl_config_from_js)(global, value); - if global.has_exception() { - debug_assert!(p.is_null()); - return Err(JsError::Thrown); + pub(crate) fn from_js(global: &JSGlobalObject, value: JSValue) -> JsResult { + let config = (hooks().ssl_config_from_js)(global, value); + match NonNull::new(config) { + Some(config) => Ok(Self(Some(config))), + None => Err(JsError::Thrown), } - Ok(NonNull::new(p).map(|p| Self(Some(p)))) } /// `SSLConfig.asUSocketsForClientVerification` — projects to the /// `#[repr(C)]` `us_bun_socket_context_options_t` for client mode /// (request_cert=1, reject_unauthorized=0; SQL re-verifies hostname - /// itself). Returns `Default` for the empty/`tls:true` config. + /// itself). pub(crate) fn as_usockets_for_client_verification( &self, - ) -> bun_uws::us_bun_socket_context_options_t { - match self.0 { - None => bun_uws::us_bun_socket_context_options_t { - request_cert: 1, - reject_unauthorized: 0, - ..Default::default() - }, - // SAFETY: live boxed SSLConfig. - Some(p) => unsafe { (hooks().ssl_config_as_usockets_client)(p.as_ptr()) }, - } + ) -> Option { + let config = self.0?; + // SAFETY: live boxed SSLConfig. + Some(unsafe { (hooks().ssl_config_as_usockets_client)(config.as_ptr()) }) } } } } - -// Opaque handle to `bun_runtime::api::SSLContextCache` (owned by -// `RuntimeState`). Reached via [`VirtualMachineSqlExt::ssl_ctx_cache`]; backed -// by [`SqlRuntimeHooks::ssl_ctx_cache`] / `ssl_ctx_get_or_create`. -use bun_boringssl_sys::OwnedSslCtx; - -bun_opaque::opaque_ffi! { pub struct SslCtxCache; } -impl SslCtxCache { - pub(crate) fn get_or_create_opts( - &mut self, - opts: &bun_uws::us_bun_socket_context_options_t, - err: &mut bun_uws::create_bun_socket_error_t, - ) -> Option { - // SAFETY: `self` is `&mut runtime_state().ssl_ctx_cache`; `opts`/`err` - // are caller stack locals. - unsafe { (hooks().ssl_ctx_get_or_create)(self._p.get().cast::(), opts, err) } - } -} diff --git a/src/sql_jsc/mysql/JSMySQLConnection.rs b/src/sql_jsc/mysql/JSMySQLConnection.rs index 8f9b989af309..5a1a5c70a868 100644 --- a/src/sql_jsc/mysql/JSMySQLConnection.rs +++ b/src/sql_jsc/mysql/JSMySQLConnection.rs @@ -347,24 +347,9 @@ impl JSMySQLConnection { // group after the SSLRequest exchange. let group = vm.mysql_socket_group::(context); let result = if !path.is_empty() { - SocketTCP::connect_unix_group( - group, - uws::DispatchKind::Mysql, - None, - &path[..], - ptr, - false, - ) + SocketTCP::connect_unix_group(group, uws::DispatchKind::Mysql, None, &path[..], ptr) } else { - SocketTCP::connect_group( - group, - uws::DispatchKind::Mysql, - None, - hostname, - port, - ptr, - false, - ) + SocketTCP::connect_group(group, uws::DispatchKind::Mysql, None, hostname, port, ptr) }; let socket = match result { Ok(s) => s, diff --git a/src/sql_jsc/postgres/PostgresSQLConnection.rs b/src/sql_jsc/postgres/PostgresSQLConnection.rs index 3e26c4b430e9..0556ac26c211 100644 --- a/src/sql_jsc/postgres/PostgresSQLConnection.rs +++ b/src/sql_jsc/postgres/PostgresSQLConnection.rs @@ -1128,7 +1128,6 @@ fn open( None, path_slice, ptr, - false, ) } else { uws::SocketTCP::connect_group( @@ -1138,7 +1137,6 @@ fn open( params.hostname, params.port, ptr, - false, ) }; diff --git a/src/sql_jsc/shared/ConnectionCtorArgs.rs b/src/sql_jsc/shared/ConnectionCtorArgs.rs index 2d0089323914..7a1249210bd1 100644 --- a/src/sql_jsc/shared/ConnectionCtorArgs.rs +++ b/src/sql_jsc/shared/ConnectionCtorArgs.rs @@ -3,8 +3,7 @@ //! tls, ...)` host functions, through the per-VM `SSL_CTX*` cache lookup. use crate::jsc::{ - JSGlobalObject, JSValue, JsResult, VirtualMachine, VirtualMachineSqlExt as _, - api::server_config::SSLConfig, + JSGlobalObject, JSValue, JsResult, VirtualMachine, api::server_config::SSLConfig, }; use bun_boringssl_sys::OwnedSslCtx; use bun_sql::shared::SSLMode; @@ -56,21 +55,15 @@ impl ConnectionCtorArgs { let mut tls_config = SSLConfig::default(); let mut secure: Option = None; if ssl_mode != SSLMode::Disable { - tls_config = if tls_object.is_boolean() && tls_object.to_boolean() { - SSLConfig::default() - } else if tls_object.is_object() { - match SSLConfig::from_js(global_object, tls_object) { - Ok(opt) => opt.unwrap_or_default(), - Err(_) => return Ok(None), - } - } else { + // `true` arrives as an object that names the server. + if !tls_object.is_object() { return Err(global_object .throw_invalid_arguments(format_args!("tls must be a boolean or an object"))); - }; - - if global_object.has_exception() { - return Ok(None); } + tls_config = match SSLConfig::from_js(global_object, tls_object) { + Ok(config) => config, + Err(_) => return Ok(None), + }; // We always request the cert so we can verify it and manually // abort if the hostname doesn't match. Built here (not at STARTTLS @@ -78,9 +71,9 @@ impl ConnectionCtorArgs { // `SSLContextCache` shares one `SSL_CTX*` per distinct config // across pooled connections and reconnects. let mut err = uws::create_bun_socket_error_t::none; - secure = vm - .ssl_ctx_cache() - .get_or_create_opts(&tls_config.as_usockets_for_client_verification(), &mut err); + secure = tls_config + .as_usockets_for_client_verification() + .and_then(|opts| vm.ssl_ctx_cache_get_or_create(&opts, &mut err)); if secure.is_none() { drop(tls_config); return Err( diff --git a/src/sql_jsc/shared/StartTls.rs b/src/sql_jsc/shared/StartTls.rs index 43a2e18e05a9..422d00c1f0b6 100644 --- a/src/sql_jsc/shared/StartTls.rs +++ b/src/sql_jsc/shared/StartTls.rs @@ -64,12 +64,13 @@ pub(crate) fn start_tls( // SAFETY: live, just returned by `adopt_tls` with an ext slot of `ext_size`. let sock = unsafe { &mut *new_socket }; *sock.ext::>>() = NonNull::new(owner); + let tls = SocketTLS { + socket: InternalSocket::Connected(new_socket), + }; if verifies_certificate(tls_config, ssl_mode) { - sock.set_inline_reject(); + tls.set_inline_reject(); } - store(AnySocket::SocketTls(SocketTLS { - socket: InternalSocket::Connected(new_socket), - })); + store(AnySocket::SocketTls(tls)); sock.start_tls_handshake(); Ok(()) } diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 57605ce25228..236028a4f14e 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -1168,19 +1168,12 @@ pub mod O { #[cfg(any(target_os = "linux", target_os = "android"))] pub const PATH: i32 = libc::O_PATH; #[cfg(any(target_os = "linux", target_os = "android"))] - pub const NOATIME: i32 = libc::O_NOATIME; - #[cfg(any(target_os = "linux", target_os = "android"))] pub const TMPFILE: i32 = libc::O_TMPFILE; - // Windows defines these (non-zero) so the `O.PATH` / - // `O.NOATIME` bit-tests in `openat_windows_impl` are meaningful. + // Non-zero on Windows so the `O::PATH` bit-test in `openat_windows_impl` is meaningful. #[cfg(windows)] pub const PATH: i32 = 0o10000000; - #[cfg(windows)] - pub const NOATIME: i32 = 0o1000000; #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))] pub const PATH: i32 = 0; - #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))] - pub const NOATIME: i32 = 0; // Defined for every platform; Darwin-only flags map to 0 // elsewhere so `flags & O.EVTONLY` etc. compile and are no-ops. #[cfg(unix)] @@ -1201,8 +1194,6 @@ pub mod O { pub const NOCTTY: i32 = libc::O_NOCTTY; #[cfg(windows)] pub const NOCTTY: i32 = 0; - #[cfg(windows)] - pub const ACCMODE: i32 = 3; #[cfg(target_os = "macos")] pub const SYMLINK: i32 = libc::O_SYMLINK; #[cfg(not(target_os = "macos"))] @@ -5167,30 +5158,10 @@ pub mod c { // single `bun_sys::c::*` import path matching the `bun.c` namespacing. #[cfg(target_os = "macos")] pub use libc::{ - _NSGetEnviron, COPYFILE_ACL, - COPYFILE_CHECK, - COPYFILE_CLONE, - COPYFILE_CLONE_FORCE, COPYFILE_DATA, COPYFILE_EXCL, - COPYFILE_METADATA, - COPYFILE_MOVE, - COPYFILE_NOFOLLOW, - COPYFILE_NOFOLLOW_DST, COPYFILE_NOFOLLOW_SRC, - COPYFILE_RECURSIVE, - COPYFILE_SECURITY, - COPYFILE_STAT, - COPYFILE_UNLINK, - COPYFILE_XATTR, - CPU_STATE_IDLE, - CPU_STATE_MAX, - CPU_STATE_NICE, - CPU_STATE_SYSTEM, - CPU_STATE_USER, - HOST_VM_INFO64, - HOST_VM_INFO64_COUNT, PROCESSOR_CPU_LOAD_INFO, // / clonefile, @@ -5201,7 +5172,6 @@ pub mod c { fclonefileat, fcopyfile, host_processor_info, - host_statistics64, integer_t, mach_msg_type_number_t, mach_port_t, @@ -5212,19 +5182,14 @@ pub mod c { // — host/processor/vm primitives for `os.cpus()` & memory stats natural_t, processor_cpu_load_info, - processor_cpu_load_info_data_t, - processor_flavor_t, processor_info_array_t, // — `sysctlbyname` already re-exported above for all BSDs. sysctl, - sysctlnametomib, // — `sockaddr_dl` already re-exported above for all BSDs. // misc libc truncate, vm_deallocate, vm_size_t, - vm_statistics64, - vm_statistics64_data_t, }; /// Safe rc-returning `clonefile(2)` — callers that want their own @@ -5303,16 +5268,6 @@ pub mod c { dyld_get_image_header_raw(image_index).cast() } - /// Darwin `sendfile(fd, s, off, *len, *hdtr, flags)`, without headers or trailers. - /// 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 fn sendfile(fd: c_int, s: c_int, off: i64, len: &mut i64, flags: c_int) -> c_int { - // SAFETY: `len` is live, and a null `hdtr` is allowed. - unsafe { libc::sendfile(fd, s, off, len, core::ptr::null_mut(), flags) } - } /// FreeBSD `sendfile(fd, s, off, nbytes, *hdtr, *sbytes, flags)`, without headers or trailers. #[cfg(target_os = "freebsd")] pub fn sendfile( @@ -5461,7 +5416,6 @@ pub mod linux { pub const PRI: u32 = libc::EPOLLPRI as u32; pub const ERR: u32 = libc::EPOLLERR as u32; pub const HUP: u32 = libc::EPOLLHUP as u32; - pub const RDHUP: u32 = libc::EPOLLRDHUP as u32; pub const ET: u32 = libc::EPOLLET as u32; pub const ONESHOT: u32 = libc::EPOLLONESHOT as u32; pub const CTL_ADD: i32 = libc::EPOLL_CTL_ADD; @@ -5537,11 +5491,8 @@ pub mod linux { /// inotify mask flags (`IN_*`). pub mod IN { - pub const ACCESS: u32 = libc::IN_ACCESS; pub const MODIFY: u32 = libc::IN_MODIFY; pub const ATTRIB: u32 = libc::IN_ATTRIB; - pub const CLOSE_WRITE: u32 = libc::IN_CLOSE_WRITE; - pub const CLOSE_NOWRITE: u32 = libc::IN_CLOSE_NOWRITE; pub const OPEN: u32 = libc::IN_OPEN; pub const MOVED_FROM: u32 = libc::IN_MOVED_FROM; pub const MOVED_TO: u32 = libc::IN_MOVED_TO; @@ -5550,9 +5501,7 @@ pub mod linux { pub const DELETE_SELF: u32 = libc::IN_DELETE_SELF; pub const MOVE_SELF: u32 = libc::IN_MOVE_SELF; pub const ONLYDIR: u32 = libc::IN_ONLYDIR; - pub const DONT_FOLLOW: u32 = libc::IN_DONT_FOLLOW; pub const EXCL_UNLINK: u32 = libc::IN_EXCL_UNLINK; - pub const MASK_ADD: u32 = libc::IN_MASK_ADD; pub const ISDIR: u32 = libc::IN_ISDIR; pub const ONESHOT: u32 = libc::IN_ONESHOT; pub const IGNORED: u32 = libc::IN_IGNORED; @@ -5726,7 +5675,6 @@ pub mod darwin { pub const WRITE: i16 = libc::EVFILT_WRITE; pub const VNODE: i16 = libc::EVFILT_VNODE; pub const PROC: i16 = libc::EVFILT_PROC; - pub const SIGNAL: i16 = libc::EVFILT_SIGNAL; pub const TIMER: i16 = libc::EVFILT_TIMER; pub const USER: i16 = libc::EVFILT_USER; pub const MACHPORT: i16 = libc::EVFILT_MACHPORT; @@ -5739,10 +5687,8 @@ pub mod darwin { pub const ADD: u16 = libc::EV_ADD; pub const DELETE: u16 = libc::EV_DELETE; pub const ENABLE: u16 = libc::EV_ENABLE; - pub const DISABLE: u16 = libc::EV_DISABLE; pub const ONESHOT: u16 = libc::EV_ONESHOT; pub const CLEAR: u16 = libc::EV_CLEAR; - pub const RECEIPT: u16 = libc::EV_RECEIPT; pub const DISPATCH: u16 = libc::EV_DISPATCH; pub const EOF: u16 = libc::EV_EOF; pub const ERROR: u16 = libc::EV_ERROR; @@ -5750,11 +5696,7 @@ pub mod darwin { /// kqueue fflags (Darwin). pub mod NOTE { pub const EXIT: u32 = libc::NOTE_EXIT; - pub const EXITSTATUS: u32 = libc::NOTE_EXITSTATUS; - pub const SIGNAL: u32 = libc::NOTE_SIGNAL; pub const FORK: u32 = libc::NOTE_FORK; - pub const EXEC: u32 = libc::NOTE_EXEC; - pub const TRIGGER: u32 = libc::NOTE_TRIGGER; pub const DELETE: u32 = libc::NOTE_DELETE; pub const WRITE: u32 = libc::NOTE_WRITE; pub const EXTEND: u32 = libc::NOTE_EXTEND; @@ -5764,7 +5706,6 @@ pub mod darwin { pub const REVOKE: u32 = libc::NOTE_REVOKE; /// `EVFILT_MEMORYSTATUS` fflags (xnu ``). Values are /// ABI-stable; libdispatch depends on them for `DISPATCH_MEMORYPRESSURE_*`. - pub const MEMORYSTATUS_PRESSURE_NORMAL: u32 = 0x00000001; pub const MEMORYSTATUS_PRESSURE_WARN: u32 = 0x00000002; pub const MEMORYSTATUS_PRESSURE_CRITICAL: u32 = 0x00000004; } @@ -8044,10 +7985,6 @@ pub mod posix { // Windows literals live in `bun_windows_sys::ws2_32` (leaf tier-0); route // through it so the hardcoded `2/23` exists in exactly one place. #[cfg(windows)] - pub const UNSPEC: c_int = bun_windows_sys::ws2_32::AF_UNSPEC; - #[cfg(windows)] - pub const UNIX: c_int = bun_windows_sys::ws2_32::AF_UNIX; - #[cfg(windows)] pub const INET: c_int = bun_windows_sys::ws2_32::AF_INET; #[cfg(windows)] pub const INET6: c_int = bun_windows_sys::ws2_32::AF_INET6; @@ -8090,11 +8027,6 @@ pub mod posix { pub const fn s_islnk(m: u32) -> bool { (m & libc::S_IFMT as u32) == libc::S_IFLNK as u32 } - #[cfg(unix)] - #[inline] - pub const fn s_isdir(m: u32) -> bool { - (m & libc::S_IFMT as u32) == libc::S_IFDIR as u32 - } // ── signals ── #[cfg(unix)] @@ -8647,7 +8579,6 @@ pub mod freebsd { pub const WRITE: i16 = libc::EVFILT_WRITE; pub const VNODE: i16 = libc::EVFILT_VNODE; pub const PROC: i16 = libc::EVFILT_PROC; - pub const SIGNAL: i16 = libc::EVFILT_SIGNAL; pub const TIMER: i16 = libc::EVFILT_TIMER; pub const USER: i16 = libc::EVFILT_USER; } @@ -8656,10 +8587,8 @@ pub mod freebsd { pub const ADD: u16 = libc::EV_ADD; pub const DELETE: u16 = libc::EV_DELETE; pub const ENABLE: u16 = libc::EV_ENABLE; - pub const DISABLE: u16 = libc::EV_DISABLE; pub const ONESHOT: u16 = libc::EV_ONESHOT; pub const CLEAR: u16 = libc::EV_CLEAR; - pub const RECEIPT: u16 = libc::EV_RECEIPT; pub const DISPATCH: u16 = libc::EV_DISPATCH; pub const EOF: u16 = libc::EV_EOF; pub const ERROR: u16 = libc::EV_ERROR; @@ -8668,8 +8597,6 @@ pub mod freebsd { pub mod NOTE { pub const EXIT: u32 = libc::NOTE_EXIT; pub const FORK: u32 = libc::NOTE_FORK; - pub const EXEC: u32 = libc::NOTE_EXEC; - pub const TRIGGER: u32 = libc::NOTE_TRIGGER; pub const DELETE: u32 = libc::NOTE_DELETE; pub const WRITE: u32 = libc::NOTE_WRITE; pub const EXTEND: u32 = libc::NOTE_EXTEND; diff --git a/src/trim/features/bun.def b/src/trim/features/bun.def index b89f2fbfdfbf..9389db2596f6 100644 --- a/src/trim/features/bun.def +++ b/src/trim/features/bun.def @@ -79,6 +79,7 @@ feature BunJsc "bun:jsc" feature ModuleGraph "Bun.ModuleGraph" feature Valkey "Bun.redis and Bun.RedisClient" + cxx-function create_no_js_no_pubsub JSValkeyClient>::create_no_js_no_pubsub$ feature RuntimeTranspiler "Bun.Transpiler and the transpiler for code loaded at run time" minimum 700000 @@ -175,11 +176,11 @@ feature X509 "crypto.X509Certificate and crypto.KeyObject" feature Tls "TLS" c-function us_internal_ssl_attach us_internal_ssl_detach us_internal_ssl_close us_internal_ssl_shutdown c-function us_internal_ssl_on_open us_internal_ssl_on_data us_internal_ssl_on_writable us_internal_ssl_on_timeout - c-function us_internal_ssl_on_end us_internal_ssl_on_end_after_rejected_send us_internal_ssl_on_close + c-function us_internal_ssl_on_end us_internal_ssl_close_after_rejected_send us_internal_ssl_on_close c-function us_internal_ssl_write us_internal_ssl_writev us_internal_ssl_fatal_write_error us_internal_ssl_spill_pending - c-function us_internal_ssl_is_shut_down us_internal_ssl_is_low_prio us_internal_ssl_is_handshake_finished - c-function us_internal_ssl_handshake_callback_has_fired us_internal_ssl_get_native_handle us_internal_ssl_verify_error - c-function us_internal_ssl_sni_servername us_internal_ssl_socket_relocated us_internal_ssl_ctx_up_ref us_internal_ssl_ctx_unref + c-function us_internal_ssl_is_shut_down us_internal_ssl_is_low_prio + c-function us_internal_ssl_get_native_handle us_internal_ssl_verify_error + c-function us_internal_ssl_sealed_close_notify us_internal_ssl_socket_relocated us_internal_ssl_ctx_up_ref us_internal_ssl_ctx_unref c-function us_internal_free_loop_ssl_data feature BunInfo "the development server's /bun:info route" diff --git a/src/trim/features/node.def b/src/trim/features/node.def index 333d3a2dcd11..2651a0c6f281 100644 --- a/src/trim/features/node.def +++ b/src/trim/features/node.def @@ -1,5 +1,5 @@ feature NodeQuic "node:quic" - c-function us_nq_loop_flush_if_pending us_nq_loop_drain + c-function us_quic_loop_process us_quic_loop_flush_if_pending feature NodeHttp2 "node:http2" diff --git a/src/trim/features/web.def b/src/trim/features/web.def index 924dc641f948..d17d04cfb44d 100644 --- a/src/trim/features/web.def +++ b/src/trim/features/web.def @@ -40,11 +40,11 @@ feature CryptoGlobal "the crypto global" feature HttpClient "the HTTP client behind fetch, S3 and WebSocket" c-function us_internal_ssl_attach us_internal_ssl_detach us_internal_ssl_close us_internal_ssl_shutdown c-function us_internal_ssl_on_open us_internal_ssl_on_data us_internal_ssl_on_writable us_internal_ssl_on_timeout - c-function us_internal_ssl_on_end us_internal_ssl_on_end_after_rejected_send us_internal_ssl_on_close + c-function us_internal_ssl_on_end us_internal_ssl_close_after_rejected_send us_internal_ssl_on_close c-function us_internal_ssl_write us_internal_ssl_writev us_internal_ssl_fatal_write_error us_internal_ssl_spill_pending - c-function us_internal_ssl_is_shut_down us_internal_ssl_is_low_prio us_internal_ssl_is_handshake_finished - c-function us_internal_ssl_handshake_callback_has_fired us_internal_ssl_get_native_handle us_internal_ssl_verify_error - c-function us_internal_ssl_sni_servername us_internal_ssl_socket_relocated us_internal_ssl_ctx_up_ref us_internal_ssl_ctx_unref + c-function us_internal_ssl_is_shut_down us_internal_ssl_is_low_prio + c-function us_internal_ssl_get_native_handle us_internal_ssl_verify_error + c-function us_internal_ssl_sealed_close_notify us_internal_ssl_socket_relocated us_internal_ssl_ctx_up_ref us_internal_ssl_ctx_unref c-function us_internal_free_loop_ssl_data c-function us_quic_loop_process us_quic_loop_flush_if_pending diff --git a/src/url/lib.rs b/src/url/lib.rs index 33e2ed50ea15..f3d590b20065 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -3,7 +3,6 @@ pub mod error; pub use error::{Error, Result}; -use core::cell::RefCell; use core::mem::MaybeUninit; use bun_collections::bit_set::{ArrayBitSet, num_masks_for}; @@ -70,20 +69,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 +191,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. @@ -211,7 +223,6 @@ pub struct URL<'a> { pub(crate) search: &'a [u8], pub(crate) search_params: Option, pub username: &'a [u8], - pub(crate) port_was_automatically_set: bool, /// The rule `parse` used, so `href_without_userinfo` cuts the same bytes. pub(crate) authority_end: AuthorityEnd, } @@ -232,7 +243,6 @@ impl<'a> Default for URL<'a> { search: b"", search_params: None, username: b"", - port_was_automatically_set: false, authority_end: AuthorityEnd::LikeNewURL, } } @@ -323,7 +333,6 @@ impl<'a> URL<'a> { search: d(self.search), search_params: self.search_params, username: d(self.username), - port_was_automatically_set: self.port_was_automatically_set, authority_end: self.authority_end, } } @@ -557,8 +566,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 { @@ -988,7 +998,6 @@ pub struct QueryStringMap { slice: *const [u8], pub(crate) buffer: Vec, pub(crate) list: ParamList, - pub(crate) name_count: Option, } impl Clone for QueryStringMap { @@ -1013,28 +1022,13 @@ impl Clone for QueryStringMap { slice, buffer, list: self.list.clone(), - name_count: self.name_count, } } } -thread_local! { - // Unused in current code (commented-out path in get_name_count). - static NAME_COUNT_BUF: RefCell<[*const [u8]; 8]> = const { RefCell::new([std::ptr::from_ref::<[u8]>(&[]); 8]) }; -} - impl QueryStringMap { pub fn get_name_count(&self) -> usize { self.list.len() - // if (this.name_count == null) { - // var count: usize = 0; - // var iterate = this.iter(); - // while (iterate.next(&_name_count) != null) { - // count += 1; - // } - // this.name_count = count; - // } - // return this.name_count.?; } pub fn iter(&self) -> Iterator<'_> { @@ -1202,7 +1196,6 @@ impl QueryStringMap { list, buffer: buf, slice: slice_ptr, - name_count: None, })) } @@ -1256,7 +1249,6 @@ impl QueryStringMap { buffer: Vec::new(), // `slice` borrows the caller's query_string; lifetime not tracked here slice: std::ptr::from_ref::<[u8]>(query_string), - name_count: None, })); } @@ -1330,7 +1322,6 @@ impl QueryStringMap { list, buffer: buf, slice: slice_ptr, - name_count: None, })) } } diff --git a/src/uws/lib.rs b/src/uws/lib.rs index b8e778bbf194..54dd4c6a6bdd 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -21,7 +21,7 @@ use bun_core::ZStr; pub use bun_uws_sys::Timer; pub use bun_uws_sys::{ AnyWebSocket, BodyReaderMixin, ConnectingSocket, NewApp, RawWebSocket, Request, - WebSocketBehavior, us_socket_stream_buffer_t, us_socket_t, uws_res, + WebSocketBehavior, us_socket_t, uws_res, }; /// `#[uws_callback]` — wraps a `&self`/`&mut self` method in an `extern "C"` @@ -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` @@ -172,19 +172,16 @@ 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, + ERR_clear_error, OwnedSslCtx, SSL, 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_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, + SSL_pending, SSL_read, SSL_set_bio, SSL_shutdown, SSL_write, }; } use crate::us_bun_verify_error_t; + use bun_uws_sys::ssl::*; bun_core::define_scoped_log!(log, SSLWrapper, hidden); @@ -222,8 +219,12 @@ pub mod ssl_wrapper { /// What the one BIO of the `SSL` reads and writes. No borrow spans an `SSL_*` call or a handler. #[derive(Default)] struct Ciphertext { - /// Sealed, not yet given to `Handlers::write`. + /// Sealed, not yet taken by `Handlers::write`. outgoing: RefCell>, + /// How much of `outgoing` the transport has taken. + sent: Cell, + /// The transport took less than it was given. `flush` tries again. + blocked: Cell, /// From the peer, not yet read by BoringSSL. incoming: RefCell>, } @@ -300,44 +301,10 @@ pub mod ssl_wrapper { .load(Ordering::Relaxed) } - /// Cap on peer-initiated TLS renegotiations per - /// [`MAX_RENEGOTIATION_WINDOW`]. Mirrors the `us_reneg_policy` defaults in - /// the uSockets C path (openssl.c) and Node's - /// `CLIENT_RENEG_LIMIT`/`CLIENT_RENEG_WINDOW`. Unbounded renegotiation is - /// a CPU DoS (CVE-2011-1473). - const MAX_RENEGOTIATIONS: u8 = 3; - /// See [`MAX_RENEGOTIATIONS`]. - const MAX_RENEGOTIATION_WINDOW: core::time::Duration = core::time::Duration::from_secs(600); - /// `US_SSL_FATAL_ERROR_REASON_MAX` in openssl.c. const FATAL_ERROR_REASON_MAX: usize = 256; - /// What BoringSSL's callbacks read and write. The `SSL` points at it: see `us_ssl_set_wrapper`. - struct CallbackState { - inline_reject: Cell, - verify_failed: Cell, - identity_checked: 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, - >, - wants_session: bool, - wants_keylog: bool, - latest_session: Cell>>, - sessions: RefCell>>, - keylog: RefCell>>, - } - - impl Drop for CallbackState { - fn drop(&mut self) { - if let Some(session) = self.latest_session.take() { - // SAFETY: `latest_session` owns one reference. - unsafe { boring_sys::SSL_SESSION_free(session.as_ptr()) }; - } - } - } - - /// Boxed: the `SSL` holds a pointer to the state, so the state must not move. + /// Boxed: the `SSL` and its BIO hold pointers into the state, so the state must not move. pub struct SSLWrapper { inner: Box>, } @@ -350,16 +317,12 @@ pub mod ssl_wrapper { } } - #[repr(C)] pub struct Inner { - /// First, so a pointer to `Inner` is a pointer to its `CallbackState`. - callbacks: CallbackState, pub handlers: Cell>, + identity_check: Cell>>, pub ssl: Cell>>, pub(crate) ctx: Cell>, pub flags: Flags, - pub(crate) renegotiation_count: Cell, - pub(crate) renegotiation_window_start: Cell>, traffic: Cell, ciphertext: Ciphertext, } @@ -374,10 +337,6 @@ pub mod ssl_wrapper { RerunRequested, } - /// CamelCase alias for callers that use the alternate spelling - /// (e.g. `http_jsc`). - pub type SslWrapper = SSLWrapper; - #[repr(transparent)] #[derive(Default)] pub struct Flags(Cell); @@ -395,7 +354,6 @@ pub mod ssl_wrapper { const RECEIVED_SSL_SHUTDOWN: u8 = 1 << 2; const SENT_SSL_SHUTDOWN: u8 = 1 << 3; const IS_CLIENT: u8 = 1 << 4; - const AUTHORIZED: u8 = 1 << 5; const FATAL_ERROR: u8 = 1 << 6; const CLOSED_NOTIFIED: u8 = 1 << 7; @@ -452,10 +410,6 @@ pub mod ssl_wrapper { self.set_bit(Self::IS_CLIENT, v) } #[inline] - pub(crate) fn set_authorized(&self, v: bool) { - self.set_bit(Self::AUTHORIZED, v) - } - #[inline] pub fn fatal_error(&self) -> bool { self.bits() & Self::FATAL_ERROR != 0 } @@ -496,30 +450,18 @@ pub mod ssl_wrapper { PeerClosed, } + type IdentityCheck = fn(T, &mut boring_sys::SSL) -> bun_boringssl::ServerIdentity; + #[derive(Clone, Copy)] pub struct Handlers { /// Backref to the parent (e.g. *mut HTTPClient / *mut WebSocketProxyTunnel / *mut UpgradedDuplex). pub ctx: T, pub on_open: fn(T), pub on_handshake: fn(T, bool, us_bun_verify_error_t), - pub write: fn(T, &[u8]), + /// Returns how many bytes the transport took. The wrapper keeps the rest until `flush`. + pub write: fn(T, &[u8]) -> usize, pub on_data: fn(T, &[u8]), pub on_close: fn(T), - /// A new resumable TLS session arrived (serialized SSL_SESSION bytes) - /// - node's `'session'` event. `None` opts the SSL out of session - /// parking entirely (fetch / WebSocket tunnels have no consumer). - pub on_session: Option, - /// An NSS key-log line (with the trailing newline node appends) - - /// node's `'keylog'` event. Same opt-in rules as `on_session`. - 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>, - } - - #[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)] - pub enum InitError { - OutOfMemory, - InvalidOptions, } #[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)] @@ -530,86 +472,28 @@ pub mod ssl_wrapper { } impl SSLWrapper { - /// Takes ownership of `ctx` (dropped on `Err`). pub fn init_with_ctx( ctx: boring_sys::OwnedSslCtx, is_client: bool, handlers: Handlers, - ) -> Result { + ) -> Self { bun_boringssl::load(); - // SAFETY: ctx is a live SSL_CTX; SSL_new returns null on OOM. - let ssl = NonNull::new(unsafe { boring_sys::SSL_new(ctx.as_ptr()) }) - .ok_or(InitError::OutOfMemory)?; + // SAFETY: ctx is a live SSL_CTX, and `us_ssl_new` never returns null. + let ssl = + unsafe { NonNull::new_unchecked(us_ssl_new(ctx.as_ptr(), c_int::from(is_client))) }; // SAFETY: the method lives as long as the process; BIO_new returns null on OOM. let bio = NonNull::new(unsafe { boring_sys::BIO_new(bio_method()) }) .unwrap_or_else(|| bun_core::out_of_memory()); - // OpenSSL enables TLS renegotiation by default and accepts - // renegotiation requests from the peer transparently. - // Renegotiation is an extremely problematic protocol feature, so - // BoringSSL rejects peer renegotiations by default. We explicitly - // set the SSL_set_renegotiate_mode so if we switch to OpenSSL we - // keep the same behavior. See: - // https://boringssl.googlesource.com/boringssl/+/HEAD/PORTING.md#TLS-renegotiation - // SAFETY: ssl is valid for the duration of this block; all calls are simple property setters. - unsafe { - if is_client { - // Set the renegotiation mode to explicit so that we can - // renegotiate on the client side if needed (better - // performance than ssl_renegotiate_freely). BoringSSL: - // Renegotiation is only supported as a client in TLS and - // the HelloRequest must be received at a quiet point in - // the application protocol. This is sufficient to support - // the common use of requesting a new client certificate - // between an HTTP request and response in (unpipelined) - // HTTP/1.1. - boring_sys::SSL_set_renegotiate_mode( - ssl.as_ptr(), - boring_sys::ssl_renegotiate_explicit, - ); - boring_sys::SSL_set_connect_state(ssl.as_ptr()); - // A client keeps no session id context (see `us_internal_ssl_attach`). - boring_sys::SSL_set_session_id_context(ssl.as_ptr(), core::ptr::null(), 0); - us_internal_ssl_client_verify_defaults(ssl.as_ptr(), ctx.as_ptr()); - } else { - // Set the renegotiation mode to never so that we can't - // renegotiate on the server side (security reasons). - // BoringSSL: There is no support for renegotiation as a - // server. (Attempts by clients will result in a fatal - // alert so that ClientHello messages cannot be used to - // flood a server and escape higher-level limits.) - boring_sys::SSL_set_renegotiate_mode( - ssl.as_ptr(), - boring_sys::ssl_renegotiate_never, - ); - boring_sys::SSL_set_accept_state(ssl.as_ptr()); - } - } - let flags = Flags::default(); flags.set_is_client(is_client); let inner = Box::new(Inner { - callbacks: CallbackState { - wants_session: handlers.on_session.is_some(), - wants_keylog: handlers.on_keylog.is_some(), - inline_reject: Cell::new(false), - verify_failed: Cell::new(false), - identity_checked: Cell::new(false), - server_identity: handlers - .server_identity - .is_some() - .then_some(Self::server_identity), - latest_session: Cell::new(None), - sessions: RefCell::default(), - keylog: RefCell::default(), - }, handlers: Cell::new(handlers), + identity_check: Cell::new(None), flags, ctx: Cell::new(Some(ctx)), ssl: Cell::new(Some(ssl)), - renegotiation_count: Cell::new(0), - renegotiation_window_start: Cell::new(None), traffic: Cell::new(Traffic::Idle), ciphertext: Ciphertext::default(), }); @@ -622,31 +506,8 @@ pub mod ssl_wrapper { ); boring_sys::BIO_set_init(bio.as_ptr(), 1); boring_sys::SSL_set_bio(ssl.as_ptr(), bio.as_ptr(), bio.as_ptr()); - us_ssl_set_wrapper( - ssl.as_ptr(), - core::ptr::from_ref(&this.callbacks).cast_mut().cast(), - ) }; - Ok(this) - } - - /// Tier-neutral constructor. - /// Higher-tier callers convert their `SSLConfig` via `.as_usockets()` and pass the - /// resulting `BunSocketContextOptions` here, so this crate stays free of the - /// `jsc`/`http_types` dependency. The original `SSLConfig`-taking `init` lives as - /// an extension in the higher tier. - pub fn init_from_options( - ctx_opts: &crate::SocketContext::BunSocketContextOptions, - is_client: bool, - handlers: Handlers, - ) -> Result { - bun_boringssl::load(); - - let mut err = crate::create_bun_socket_error_t::none; - let Some(ssl_ctx) = ctx_opts.create_ssl_context(&mut err) else { - return Err(InitError::InvalidOptions); - }; - Self::init_with_ctx(ssl_ctx, is_client, handlers) + this } /// As in `us_socket_adopt_tls`, a server's own `requestCert` / `rejectUnauthorized` replace the mode of the shared `SSL_CTX`. No-op for clients. @@ -654,24 +515,12 @@ pub mod ssl_wrapper { if self.flags.is_client() { return; } - let Some(ssl) = self.ssl.get() else { return }; - let mode = if request_cert { - boring_sys::SSL_VERIFY_PEER - | if reject_unauthorized { - boring_sys::SSL_VERIFY_FAIL_IF_NO_PEER_CERT - } else { - 0 - } - } else { - boring_sys::SSL_VERIFY_NONE - }; - // SAFETY: `ssl` is this wrapper's live `SSL*`, before any handshake - // byte has been processed. The callback always returns 1 so - // BoringSSL never aborts mid-flight; JS reads `verify_error` and - // decides. - unsafe { - boring_sys::SSL_set_verify(ssl.as_ptr(), mode, Some(always_continue_verify)); - } + let Some(ssl) = self.ssl_ref() else { return }; + us_ssl_set_verify_mode( + ssl, + c_int::from(request_cert), + c_int::from(reject_unauthorized), + ); } /// Client whose `rejectUnauthorized` policy is on: refuse a bad server @@ -679,42 +528,41 @@ pub mod ssl_wrapper { /// (see the tripped check in `update_handshake_state`). Call it before /// `start()`. No-op for servers. pub fn set_inline_reject(&self) { - if !self.flags.is_client() { - return; + if let Some(ssl) = self.ssl_ref() { + us_ssl_set_inline_reject(ssl); } - let Some(ssl) = self.ssl.get() else { return }; - self.callbacks.inline_reject.set(true); - // SAFETY: `ssl` is this wrapper's live `SSL*`. + } + + /// Client whose owner isn't a Bun socket: `check` names the server in the verify step, so a wrong name fails the handshake. Without it the owner checks after the handshake. + pub fn set_server_identity_check(&self, check: IdentityCheck) { + let Some(ssl) = self.ssl_ref() else { return }; + self.identity_check.set(Some(check)); + // SAFETY: the box outlives `ssl`. unsafe { - boring_sys::SSL_set_verify( - ssl.as_ptr(), - boring_sys::SSL_VERIFY_PEER, - Some(record_verify_failure), + us_ssl_set_identity_check( + ssl, + Self::server_identity, + core::ptr::from_ref::>(&self.inner) + .cast_mut() + .cast(), ); } } - /// SAFETY: `state` is the `callbacks` field of a live `Inner`. - unsafe fn server_identity( - state: &CallbackState, - ssl: &mut boring_sys::SSL, - ) -> bun_boringssl::ServerIdentity { - // SAFETY: caller contract; `callbacks` is the first field of the `repr(C)` `Inner`. - let this = unsafe { &*core::ptr::from_ref(state).cast::>() }; - let handlers = this.handlers.get(); - handlers - .server_identity + extern "C" fn server_identity(inner: *mut c_void, ssl: *mut boring_sys::SSL) -> c_int { + // SAFETY: openssl.c passes the box `set_server_identity_check` gave it, and its live `ssl`. + let (this, ssl) = unsafe { (&*inner.cast::>(), &mut *ssl) }; + this.identity_check + .get() .map_or(bun_boringssl::ServerIdentity::Unchecked, |check| { - check(handlers.ctx, ssl) - }) + check(this.handlers.get().ctx, ssl) + }) as c_int } - /// The session most recently given to the new-session callback, borrowed. - pub fn latest_session(&self) -> *mut boring_sys::SSL_SESSION { - self.callbacks - .latest_session + fn ssl_ref(&self) -> Option<&boring_sys::SSL> { + self.ssl .get() - .map_or(core::ptr::null_mut(), NonNull::as_ptr) + .map(|ssl| boring_sys::SSL::opaque_ref(ssl.as_ptr())) } pub fn start(&self) { @@ -832,9 +680,17 @@ pub mod ssl_wrapper { } /// flush buffered data and returns amount of pending data to write - pub fn flush(&self) -> usize { + pub fn flush(&self) { + self.ciphertext.blocked.set(false); self.handle_traffic(); - self.ciphertext.outgoing.borrow().len() + } + + /// Ciphertext behind a refusal of the transport. What a running pass hasn't offered yet doesn't count. + pub fn refused_ciphertext(&self) -> usize { + if !self.ciphertext.blocked.get() { + return 0; + } + self.ciphertext.outgoing.borrow().len() - self.ciphertext.sent.get() } /// Ciphertext is queued in either direction, or the SSL holds the rest of a partially-returned record. @@ -905,7 +761,6 @@ pub mod ssl_wrapper { self.handle_traffic(); return Err(WriteDataError::WantRead); } - let data = &data[..data.len().min(c_int::MAX as usize)]; // One growth for all the records of this write. let records = data.len() / MAX_RECORD_PLAINTEXT + 1; self.ciphertext @@ -913,38 +768,45 @@ pub mod ssl_wrapper { .borrow_mut() .try_reserve(data.len() + records * MAX_RECORD_OVERHEAD) .unwrap_or_oom(); - // SAFETY: ssl is a live SSL*; data is a valid &[u8] for len bytes. - let written = unsafe { - boring_sys::SSL_write( - ssl.as_ptr(), - data.as_ptr().cast::(), - data.len() as c_int, - ) - }; - if written <= 0 { + let mut total: usize = 0; + // `SSL_write` takes an int. + for chunk in data.chunks(c_int::MAX as usize) { + // SAFETY: ssl is a live SSL*; chunk is a valid &[u8] for len bytes. + let written = unsafe { + boring_sys::SSL_write( + ssl.as_ptr(), + chunk.as_ptr().cast::(), + chunk.len() as c_int, + ) + }; + if written > 0 { + total += usize::try_from(written).expect("int cast"); + continue; + } // SAFETY: ssl is still valid. let err = unsafe { boring_sys::SSL_get_error(ssl.as_ptr(), written) }; boring_sys::ERR_clear_error(); - - if err == boring_sys::SSL_ERROR_WANT_READ { - // we wanna read/write - self.handle_traffic(); - return Err(WriteDataError::WantRead); - } - if err == boring_sys::SSL_ERROR_WANT_WRITE { - // we wanna read/write + let retry = match err { + boring_sys::SSL_ERROR_WANT_READ => WriteDataError::WantRead, + boring_sys::SSL_ERROR_WANT_WRITE => WriteDataError::WantWrite, + _ => { + // some bad error happened here we must close + self.flags.set_fatal_error( + err == boring_sys::SSL_ERROR_SSL + || err == boring_sys::SSL_ERROR_SYSCALL, + ); + self.trigger_close_callback(); + return Err(WriteDataError::ConnectionClosed); + } + }; + if total == 0 { self.handle_traffic(); - return Err(WriteDataError::WantWrite); + return Err(retry); } - // some bad error happened here we must close - self.flags.set_fatal_error( - err == boring_sys::SSL_ERROR_SSL || err == boring_sys::SSL_ERROR_SYSCALL, - ); - self.trigger_close_callback(); - return Err(WriteDataError::ConnectionClosed); + break; } self.handle_traffic(); - Ok(usize::try_from(written).expect("int cast")) + Ok(total) } pub fn deinit(&self) { @@ -974,31 +836,26 @@ pub mod ssl_wrapper { (false, self.verify_error()) } // The callback below copies `reason` before this borrow ends. - HandshakeOutcome::HandshakeError(Some(reason)) => ( - false, - us_bun_verify_error_t { - error_no: -71, - code: c"EPROTO".as_ptr(), - reason: reason.as_ptr(), - }, - ), + HandshakeOutcome::HandshakeError(Some(reason)) => { + // SAFETY: `reason` is NUL-terminated and outlives the report. + (false, unsafe { us_ssl_protocol_error(reason.as_ptr()) }) + } // node:tls reads a failure with no error after end() as its own close. HandshakeOutcome::Aborted => (false, us_bun_verify_error_t::default()), - HandshakeOutcome::PeerClosed => (false, us_bun_verify_error_t::peer_disconnected()), + HandshakeOutcome::PeerClosed => (false, us_ssl_peer_disconnected_error()), }; - self.flags.set_authorized(success); // trigger the handshake callback let handlers = self.handlers.get(); (handlers.on_handshake)(handlers.ctx, success, result); } - fn trigger_wanna_write_callback(&self, data: &[u8]) { + fn trigger_wanna_write_callback(&self, data: &[u8]) -> usize { if self.flags.closed_notified() { - return; + return data.len(); } // trigger the write callback let handlers = self.handlers.get(); - (handlers.write)(handlers.ctx, data); + (handlers.write)(handlers.ctx, data) } fn trigger_data_callback(&self, data: &[u8]) { @@ -1022,11 +879,10 @@ pub mod ssl_wrapper { /// The SSL's X509 verdict. Shutdown state does not change it. fn verify_error(&self) -> us_bun_verify_error_t { - let Some(ssl) = self.ssl.get() else { - return us_bun_verify_error_t::default(); - }; - // SAFETY: ssl is a live SSL*; uSockets helper reads the verify result off it. - unsafe { us_ssl_socket_verify_error_from_ssl(ssl.as_ptr()) } + match self.ssl_ref() { + Some(ssl) => us_ssl_socket_verify_error_from_ssl(ssl), + None => us_bun_verify_error_t::default(), + } } /// Why the `SSL_*` call that just returned failed. Clears the thread's queue. @@ -1082,21 +938,12 @@ pub mod ssl_wrapper { // fail. All output queued since that verdict is the flight that // carries the client certificate. TLS 1.2 queues it before the // server's Finished, so `on_handshake` would be too late to stop it. - // Only the final verdict rejects: an alternate chain can recover a per-depth failure. - if self.callbacks.inline_reject.get() - && self.callbacks.verify_failed.get() - // SAFETY: ssl is a live SSL*. - && unsafe { boring_sys::SSL_get_verify_result(ssl.as_ptr()) } != 0 - { - boring_sys::ERR_clear_error(); - // Not only skipped below: a re-entered `handle_traffic` flushes the queue and never gets here. - self.ciphertext.outgoing.borrow_mut().clear(); - // The peer never gets our Finished, so it cannot read a close_notify. - self.flags.set_fatal_error(true); - self.flags - .set_handshake_state(HandshakeState::HandshakeCompleted); - self.trigger_handshake_callback(HandshakeOutcome::InlineRejected); - self.trigger_close_callback(); + if us_ssl_inline_reject_tripped(boring_sys::SSL::opaque_ref(ssl.as_ptr())) != 0 { + self.abandon_handshake(HandshakeOutcome::InlineRejected); + return false; + } + if us_ssl_sni_refused(boring_sys::SSL::opaque_ref(ssl.as_ptr())) != 0 { + self.abandon_handshake(HandshakeOutcome::PeerClosed); return false; } @@ -1121,6 +968,13 @@ pub mod ssl_wrapper { self.trigger_close_callback(); return false; } + if err == boring_sys::SSL_ERROR_PENDING_CERTIFICATE { + if self.ciphertext.incoming.borrow().len() > us_ssl_sni_held_max() as usize { + self.abandon_handshake(HandshakeOutcome::PeerClosed); + } + // 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 +1005,19 @@ pub mod ssl_wrapper { true } + /// Drops the connection with its queued flight unsent: no TLS alert, and no close_notify the peer couldn't read. + fn abandon_handshake(&self, outcome: HandshakeOutcome<'_>) { + boring_sys::ERR_clear_error(); + // Not only skipped by the caller: a re-entered `handle_traffic` flushes the queue. + self.ciphertext.outgoing.borrow_mut().clear(); + self.ciphertext.sent.set(0); + self.flags.set_fatal_error(true); + self.flags + .set_handshake_state(HandshakeState::HandshakeCompleted); + self.trigger_handshake_callback(outcome); + self.trigger_close_callback(); + } + /// 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. @@ -1200,32 +1067,7 @@ pub mod ssl_wrapper { let mut is_fatal = err == boring_sys::SSL_ERROR_SSL || err == boring_sys::SSL_ERROR_SYSCALL; if err == boring_sys::SSL_ERROR_WANT_RENEGOTIATE { - // The count resets each MAX_RENEGOTIATION_WINDOW, matching - // the C path's `us_reneg_policy`. - let now = std::time::Instant::now(); - match self.renegotiation_window_start.get() { - Some(start) - if now.duration_since(start) < MAX_RENEGOTIATION_WINDOW => {} - _ => { - self.renegotiation_window_start.set(Some(now)); - self.renegotiation_count.set(0); - } - } - let over_limit = self.renegotiation_count.get() >= MAX_RENEGOTIATIONS; - self.renegotiation_count - .set(self.renegotiation_count.get().saturating_add(1)); - // SAFETY: ssl is still valid. - let renegotiated = unsafe { - // BoringSSL refuses, so every refusal queues the same error. - if over_limit { - boring_sys::SSL_set_renegotiate_mode( - ssl.as_ptr(), - boring_sys::ssl_renegotiate_never, - ); - } - boring_sys::SSL_renegotiate(ssl.as_ptr()) - } != 0; - if renegotiated { + if us_ssl_renegotiate(boring_sys::SSL::opaque_ref(ssl.as_ptr())) != 0 { self.flags.set_handshake_state( HandshakeState::HandshakeRenegotiationPending, ); @@ -1315,15 +1157,24 @@ pub mod ssl_wrapper { /// This frame owns what it hands out: a write made from inside `write` queues behind it. fn handle_writing(&self) { - if self.ssl.get().is_none() { + if self.ssl.get().is_none() || self.ciphertext.blocked.get() { return; } let mut pending = self.ciphertext.outgoing.take(); - if !pending.is_empty() { - self.trigger_wanna_write_callback(&pending); - pending.clear(); + let mut sent = self.ciphertext.sent.take(); + if sent < pending.len() { + sent += self.trigger_wanna_write_callback(&pending[sent..]); } let mut outgoing = self.ciphertext.outgoing.borrow_mut(); + if sent < pending.len() { + // What a re-entrant call sealed meanwhile goes behind what the transport refused. + pending.extend_from_slice(&outgoing); + *outgoing = pending; + self.ciphertext.sent.set(sent); + self.ciphertext.blocked.set(true); + return; + } + pending.clear(); if outgoing.capacity() == 0 && pending.capacity() <= QUEUE_RETAIN_LIMIT { *outgoing = pending; } @@ -1381,31 +1232,21 @@ 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_ref() + .is_none_or(|ssl| us_ssl_sni_waiting(ssl) != 0) + || self.traffic.get() == Traffic::RerunRequested, "update_handshake_state stopped the pass and left the wrapper open" ); } } - /// Hand the parked sessions and keylog lines to the owner. The callbacks run JS, which may close the wrapper. + /// The handlers run JS, which may close the wrapper. `handle_traffic` keeps the `SSL` until its pass ends. fn flush_pending_events(&self) { - while self.ssl.get().is_some() && !self.flags.closed_notified() { - let Some(entry) = self.callbacks.sessions.borrow_mut().pop_front() else { - break; - }; - let handlers = self.handlers.get(); - if let Some(on_session) = handlers.on_session { - on_session(handlers.ctx, &entry); - } - } - while self.ssl.get().is_some() && !self.flags.closed_notified() { - let Some(entry) = self.callbacks.keylog.borrow_mut().pop_front() else { - break; - }; - let handlers = self.handlers.get(); - if let Some(on_keylog) = handlers.on_keylog { - on_keylog(handlers.ctx, &entry); - } + debug_assert!(self.traffic.get() != Traffic::Idle); + if let Some(ssl) = self.ssl_ref().filter(|_| !self.flags.closed_notified()) { + us_ssl_flush_pending_events(ssl); } } } @@ -1416,141 +1257,14 @@ pub mod ssl_wrapper { } } - /// `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. - // Body is a constant `1` with no preconditions; the safe fn item still - // coerces to the `SSL_verify_cb` fn-pointer type at the `Some(..)` site. - extern "C" fn always_continue_verify(_: c_int, _: *mut boring_sys::X509_STORE_CTX) -> c_int { - 1 - } - - /// `always_continue_verify` for a rejecting client: also remembers that the chain had an error. - extern "C" fn record_verify_failure(ok: c_int, ctx: *mut boring_sys::X509_STORE_CTX) -> c_int { - if ok == 0 { - // SAFETY: BoringSSL passes the live store context of an `SSL` that `init_with_ctx` made. - let state = unsafe { - us_ssl_wrapper_from_verify(ctx) - .cast::() - .as_ref() - }; - if let Some(state) = state { - state.verify_failed.set(true); - } - } - 1 - } - - /// openssl.c's name check of the verify step, for an `SSL` that no `us_socket_t` drives: a `US_IDENTITY_*` verdict. - #[unsafe(no_mangle)] - extern "C" fn us_ssl_wrapper_server_identity( - state: *const CallbackState, - ssl: *mut boring_sys::SSL, - ) -> c_int { - use bun_boringssl::ServerIdentity; - // SAFETY: openssl.c passes the pointer that `us_ssl_set_wrapper` stored on this live `ssl`. - let state = unsafe { &*state }; - let Some(check) = state.server_identity else { - return ServerIdentity::Unchecked as c_int; - }; - // SAFETY: `state` is the `callbacks` field of the wrapper that `check` was made for; `ssl` is live. - let verdict = unsafe { check(state, &mut *ssl) }; - state - .identity_checked - .set(verdict != ServerIdentity::Unchecked); - if verdict == ServerIdentity::Rejected { - state.verify_failed.set(true); - } - verdict as c_int - } - - #[unsafe(no_mangle)] - extern "C" fn us_ssl_wrapper_identity_checked(state: *const CallbackState) -> c_int { - // SAFETY: openssl.c passes the pointer that `us_ssl_set_wrapper` stored on a live `SSL`. - c_int::from(unsafe { &*state }.identity_checked.get()) - } - /// The name check after the handshake. The verify step's verdict stands when it gave one. pub fn check_server_identity(ssl: &mut boring_sys::SSL, host: &[u8]) -> bool { - // SAFETY: `ssl` is live. - if unsafe { us_ssl_identity_checked(ssl) } != 0 { + if us_ssl_identity_checked(ssl) != 0 { // SAFETY: `ssl` is live. return unsafe { boring_sys::SSL_get_verify_result(ssl) } == 0; } bun_boringssl::check_server_identity(ssl, host) } - - /// openssl.c's new-session callback, for an `SSL` that no `us_socket_t` drives. - #[unsafe(no_mangle)] - extern "C" fn us_ssl_wrapper_new_session( - state: *const CallbackState, - session: *mut boring_sys::SSL_SESSION, - ) { - // SAFETY: openssl.c passes the pointer that `us_ssl_set_wrapper` stored on the live `SSL`. - let state = unsafe { &*state }; - let Some(session) = NonNull::new(session).filter(|_| state.wants_session) else { - return; - }; - // SAFETY: `session` is live for this call; the new reference goes to `latest_session`. - unsafe { SSL_SESSION_up_ref(session.as_ptr()) }; - if let Some(prev) = state.latest_session.replace(Some(session)) { - // SAFETY: `latest_session` owned one reference. - unsafe { boring_sys::SSL_SESSION_free(prev.as_ptr()) }; - } - // SAFETY: a null `out` only measures. - let len = unsafe { i2d_SSL_SESSION(session.as_ptr(), core::ptr::null_mut()) }; - let Some(len) = usize::try_from(len) - .ok() - .filter(|&n| n > 0 && n <= BUFFER_SIZE) - else { - return; - }; - let mut bytes = vec![0u8; len].into_boxed_slice(); - let mut out = bytes.as_mut_ptr(); - // SAFETY: `out` is writable for the `len` bytes the measuring call reported. - unsafe { i2d_SSL_SESSION(session.as_ptr(), &raw mut out) }; - state.sessions.borrow_mut().push_back(bytes); - } - - /// openssl.c's keylog callback, for an `SSL` that no `us_socket_t` drives. - #[unsafe(no_mangle)] - extern "C" fn us_ssl_wrapper_keylog( - state: *const CallbackState, - line: *const c_char, - len: usize, - ) { - // SAFETY: openssl.c passes the pointer that `us_ssl_set_wrapper` stored on the live `SSL`. - let state = unsafe { &*state }; - if !state.wants_keylog { - return; - } - // SAFETY: openssl.c passes `len` readable bytes. - let line = unsafe { core::slice::from_raw_parts(line.cast::(), len) }; - // Node appends the newline before it emits 'keylog'. - let entry = [line, b"\n"].concat().into_boxed_slice(); - state.keylog.borrow_mut().push_back(entry); - } - - unsafe extern "C" { - /// openssl.c: the client half of `us_internal_ssl_attach`. `ssl` was made from `ctx`. - fn us_internal_ssl_client_verify_defaults( - ssl: *mut boring_sys::SSL, - ctx: *mut boring_sys::SSL_CTX, - ); - /// Implemented in uSockets C; reads - /// `SSL_get_verify_result` and maps it onto the C `us_bun_verify_error_t`. - fn us_ssl_socket_verify_error_from_ssl(ssl: *mut boring_sys::SSL) -> us_bun_verify_error_t; - fn SSL_SESSION_up_ref(session: *mut boring_sys::SSL_SESSION) -> c_int; - /// openssl.c: 1 when the verify step of this handshake asked the owner for the server's name. - fn us_ssl_identity_checked(ssl: *mut boring_sys::SSL) -> c_int; - /// openssl.c: formats the reason into `reason[..length]`, NUL-terminated. 0 when nothing is queued. - 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: 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; - } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1639,6 +1353,7 @@ pub type CloseKind = CloseCode; // deleted. pub use bun_uws_sys::socket::{ AnySocket, ConnectError, InternalSocket, NewSocketHandler, SocketHandler, SocketTCP, SocketTLS, + Transport, TransportHandle, }; /// Runtime-tagged TCP/TLS socket with a `None` arm for the "no active socket" diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index b47d03e02efd..19c54258c2f9 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -1,14 +1,14 @@ 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; 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; @@ -97,7 +97,12 @@ impl App { } pub fn close(&mut self) { - c::uws_app_close(Self::SSL_FLAG, self.as_raw()) + c::uws_app_close(Self::SSL_FLAG, self.as_raw(), 0) + } + + /// [`close`](Self::close) for a thread that ends: see `thread_ends` in libusockets.h. + pub fn close_as_thread_ends(&mut self) { + c::uws_app_close(Self::SSL_FLAG, self.as_raw(), 1) } /// Close every HTTP connection that is idle (no request being received, no @@ -109,14 +114,10 @@ impl App { c::uws_app_close_idle(Self::SSL_FLAG, self.as_raw(), i32::from(close_when_idle)) } - pub fn create(opts: &BunSocketContextOptions) -> Option<*mut Self> { - // SAFETY: FFI call; uws_create_app returns null on failure. - let app = unsafe { c::uws_create_app(Self::SSL_FLAG, *opts) }; - if app.is_null() { - None - } else { - Some(app.cast::()) - } + /// `ctx` is what every accepted socket starts from, verify mode included. `Some` exactly when `SSL`. + pub fn create(ctx: Option<&OwnedSslCtx>) -> *mut Self { + debug_assert_eq!(SSL, ctx.is_some()); + c::uws_create_app(ctx.map(|ctx| SslCtx::opaque_ref(ctx.as_ptr()))).cast::() } /// # Safety @@ -127,27 +128,28 @@ impl App { unsafe { c::uws_app_destroy(Self::SSL_FLAG, this.cast::()) } } - pub fn set_flags( - &mut self, - require_host_header: bool, - use_strict_method_validation: bool, - lenient_http_flags: u8, - http_allow_half_open: bool, - ) { + pub fn set_flags(&mut self, lenient_http_flags: u8, http_allow_half_open: bool) { c::uws_app_set_flags( Self::SSL_FLAG, self.as_raw(), - require_host_header, - use_strict_method_validation, lenient_http_flags, http_allow_half_open, ) } + /// Seconds a connection may stay silent before uWS closes it, 0 for never. `Response::timeout` overrides it for one request. + pub fn set_idle_timeout(&mut self, seconds: u8) { + c::uws_app_set_idle_timeout(Self::SSL_FLAG, self.as_raw(), seconds) + } + pub fn set_max_http_header_size(&mut self, max_header_size: u64) { 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) } @@ -286,44 +288,70 @@ 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`. + pub fn listen_fd( + &mut self, + fd: crate::LIBUS_SOCKET_DESCRIPTOR, + options: c_int, + ) -> Result<*mut ListenSocket, ListenError> { + let mut failure = ListenError::default(); + let socket = c::uws_app_listen_fd( + Self::SSL_FLAG, + self.as_raw(), + fd, + options, + &mut failure.error, + ); + if socket.is_null() { + Err(failure) + } else { + Ok(socket.cast::>()) } } @@ -361,26 +389,23 @@ impl App { } } - pub fn add_server_name_with_options( + /// With `own_client_auth`, a connection that names `hostname_pattern` adds `ctx`'s verify mode to the app's. + pub fn add_server_name( &mut self, hostname_pattern: &core::ffi::CStr, - opts: &BunSocketContextOptions, - apply_client_cert_policy: bool, - ) -> Result<(), AddServerNameError> { - // SAFETY: self is a valid app; hostname_pattern is NUL-terminated. - let rc = unsafe { - c::uws_add_server_name_with_options( - Self::SSL_FLAG, - std::ptr::from_mut::(self).cast::(), + ctx: &OwnedSslCtx, + own_client_auth: bool, + ) { + debug_assert!(SSL); + // SAFETY: `hostname_pattern` is NUL-terminated and C++ copies it. + unsafe { + c::uws_add_server_name( + self.as_raw(), hostname_pattern.as_ptr(), - *opts, - i32::from(apply_client_cert_policy), + SslCtx::opaque_ref(ctx.as_ptr()), + own_client_auth, ) - }; - if rc != 0 { - return Err(AddServerNameError::FailedToAddServerName); } - Ok(()) } /// Register a connection filter: `handler` is invoked with the raw @@ -458,6 +483,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 { @@ -488,12 +520,6 @@ impl ListenSocket { } } -#[derive(strum::IntoStaticStr, Debug)] -pub enum AddServerNameError { - FailedToAddServerName, -} -bun_core::impl_tag_error!(AddServerNameError); - bun_opaque::opaque_ffi! { pub struct uws_app_s; } pub(crate) type uws_app_t = uws_app_s; @@ -501,7 +527,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 @@ -510,7 +535,7 @@ pub mod c { pub(crate) type uws_filter_handler = Option; unsafe extern "C" { - pub(crate) safe fn uws_app_close(ssl: i32, app: &mut uws_app_s); + pub(crate) safe fn uws_app_close(ssl: i32, app: &mut uws_app_s, thread_ends: i32); pub(crate) safe fn uws_app_close_idle( ssl: i32, app: &mut uws_app_s, @@ -525,21 +550,26 @@ pub mod c { handler: extern "C" fn(*mut c_void, c_int, *mut us_socket_t, u8, *mut u8, c_int), 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. Never null. + pub(crate) safe fn uws_create_app(ssl_ctx: Option<&SslCtx>) -> *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, app: &mut uws_app_t, - require_host_header: bool, - use_strict_method_validation: bool, lenient_http_flags: u8, http_allow_half_open: bool, ); + pub(crate) safe fn uws_app_set_idle_timeout(ssl: i32, app: &mut uws_app_t, seconds: u8); pub(crate) safe fn uws_app_set_max_http_header_size( ssl: i32, 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, @@ -631,9 +661,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, @@ -650,13 +680,12 @@ pub mod c { opcode: Opcode, compress: bool, ) -> SendStatus; - pub(crate) fn uws_add_server_name_with_options( - ssl: i32, - app: *mut uws_app_t, + pub(crate) fn uws_add_server_name( + app: &mut uws_app_t, hostname_pattern: *const c_char, - options: BunSocketContextOptions, - apply_client_cert_policy: c_int, - ) -> i32; + ssl_ctx: &SslCtx, + own_client_auth: bool, + ); pub(crate) safe fn uws_filter( ssl: i32, app: &mut uws_app_t, @@ -670,9 +699,16 @@ 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, + 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/ConnectingSocket.rs b/src/uws_sys/ConnectingSocket.rs index 6a4eb5f40f2d..0754c098fb7a 100644 --- a/src/uws_sys/ConnectingSocket.rs +++ b/src/uws_sys/ConnectingSocket.rs @@ -59,10 +59,6 @@ impl ConnectingSocket { us_connecting_socket_is_shut_down(self) == 1 } - pub(crate) fn long_timeout(&mut self, seconds: c_uint) { - us_connecting_socket_long_timeout(self, seconds) - } - pub(crate) fn shutdown(&mut self) { us_connecting_socket_shutdown(self) } @@ -93,7 +89,6 @@ unsafe extern "C" { ) -> *mut c_void; pub(crate) safe fn us_connecting_socket_is_closed(s: &mut ConnectingSocket) -> i32; pub(crate) safe fn us_connecting_socket_is_shut_down(s: &mut ConnectingSocket) -> i32; - pub(crate) safe fn us_connecting_socket_long_timeout(s: &mut ConnectingSocket, seconds: c_uint); pub(crate) safe fn us_connecting_socket_shutdown(s: &mut ConnectingSocket); pub(crate) safe fn us_connecting_socket_shutdown_read(s: &mut ConnectingSocket); pub(crate) safe fn us_connecting_socket_timeout(s: &mut ConnectingSocket, seconds: c_uint); diff --git a/src/uws_sys/InternalLoopData.rs b/src/uws_sys/InternalLoopData.rs index 21afa58393f2..69e6ae2997ee 100644 --- a/src/uws_sys/InternalLoopData.rs +++ b/src/uws_sys/InternalLoopData.rs @@ -33,12 +33,13 @@ pub struct InternalLoopData { #[cfg(not(windows))] sweep_next_tick_ns: Cell, sweep_timer_count: Cell, + sweep_tick: Cell, + sweep_time_ms: Cell, wakeup_async: Cell<*mut us_internal_async>, head: Cell<*mut SocketGroup>, quic_head: Cell<*mut c_void>, + quic_walk_depth: Cell, quic_next_tick_us: Cell, - /// `us_nq_driver_s *` — node:quic's loop driver list. - pub(crate) nq_head: Cell<*mut c_void>, #[cfg(windows)] quic_timer: Cell<*mut Timer>, pub iterator: Cell<*mut SocketGroup>, @@ -80,16 +81,6 @@ impl InternalLoopData { self.head.get() } - /// When the QUIC engines next need a tick, if the loop has any. - #[inline] - pub fn quic_next_tick_us(&self) -> Option { - if self.quic_head.get().is_null() { - None - } else { - Some(self.quic_next_tick_us.get()) - } - } - #[inline] pub(crate) fn has_quic(&self) -> bool { !self.quic_head.get().is_null() @@ -99,6 +90,22 @@ impl InternalLoopData { self.sweep_timer_count.get() > 0 } + /// How many sockets the timeout sweep visits. + pub fn sweep_timer_count(&self) -> i32 { + self.sweep_timer_count.get() + } + + /// When the next timeout sweep is due. + #[cfg(not(windows))] + pub fn sweep_next_tick_ns(&self) -> i64 { + self.sweep_next_tick_ns.get() + } + + /// Whether a pass over the loop's sockets is on the stack. + pub fn is_iterating(&self) -> bool { + !self.iterator.get().is_null() || !self.low_prio_iterator.get().is_null() + } + /// Tag values for `parent_tag`: 1 = `jsc::EventLoop`, 2 = `jsc::MiniEventLoop`. /// Low tier stores tag+ptr only; the typed `EventLoopHandle` wrapper /// (`get_parent`) lives in the higher-tier crate that can name `bun_jsc`. diff --git a/src/uws_sys/ListenSocket.rs b/src/uws_sys/ListenSocket.rs index 4980c6b47320..66437a75771c 100644 --- a/src/uws_sys/ListenSocket.rs +++ b/src/uws_sys/ListenSocket.rs @@ -1,4 +1,4 @@ -use core::ffi::{c_char, c_int, c_void}; +use core::ffi::{c_char, c_int}; use bun_boringssl_sys::OwnedSslCtx; @@ -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. /// @@ -43,11 +48,12 @@ impl ListenSocket { /// `SSL_CTX` is a refcounted shared object — C `SSL_CTX_up_ref`s it and /// stores the pointer past this call, so the caller cannot legitimately /// hold exclusive `&mut` access. + /// A connection that names `hostname` keeps the listener's verify mode. pub fn add_server_name(&mut self, hostname: &core::ffi::CStr, ssl_ctx: *mut SslCtx) { // SAFETY: self and hostname are valid for the duration of the call; // caller guarantees `ssl_ctx` is non-null and points at a live SSL_CTX // (C up-refs and stores it). - unsafe { us_listen_socket_add_server_name(self, hostname.as_ptr(), ssl_ctx) } + unsafe { us_listen_socket_add_server_name(self, hostname.as_ptr(), ssl_ctx, 0) } } /// Makes `ctx` the default `SSL_CTX` for sockets accepted from now on. @@ -55,19 +61,6 @@ impl ListenSocket { // SAFETY: `ctx` owns a live SSL_CTX, which C up-refs before it stores the pointer. unsafe { us_listen_socket_set_default_ssl_ctx(self, ctx.as_ptr()) } } - - /// `cb` receives a null `ListenSocket` once this listen socket closed. - pub fn on_server_name( - &mut self, - cb: extern "C" fn( - *mut ListenSocket, - *const c_char, - Option<&mut c_int>, - *mut c_void, - ) -> *mut c_void, - ) { - us_listen_socket_on_server_name(self, cb) - } } // This file IS the *_sys crate, so externs live here. @@ -76,19 +69,12 @@ 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, ssl_ctx: *mut SslCtx, + own_client_auth: c_int, ); fn us_listen_socket_set_default_ssl_ctx(ls: *mut ListenSocket, ctx: *mut SslCtx); - safe fn us_listen_socket_on_server_name( - ls: &mut ListenSocket, - cb: extern "C" fn( - *mut ListenSocket, - *const c_char, - Option<&mut c_int>, - *mut c_void, - ) -> *mut c_void, - ); } diff --git a/src/uws_sys/Loop.rs b/src/uws_sys/Loop.rs index d6f4c3d29d4c..86aa2471e781 100644 --- a/src/uws_sys/Loop.rs +++ b/src/uws_sys/Loop.rs @@ -237,12 +237,6 @@ impl PosixLoop { /// process_conns. Early-returns when nothing wrote, so safe to call /// from drainMicrotasks without per-iteration cost. pub fn drain_quic_if_necessary(&self) { - if !self.internal_loop_data.nq_head.get().is_null() { - // Full pass with close dispatch deferred to the next loop point. - // SAFETY: `self` is this thread's live loop. The walk holds its successor while JS - // runs, and relies on an endpoint staying rooted until its close is delivered. - unsafe { c::us_nq_loop_drain(self) }; - } if !self.internal_loop_data.has_quic() { return; } @@ -418,12 +412,6 @@ impl WindowsLoop { } pub fn drain_quic_if_necessary(&self) { - if !self.internal_loop_data.nq_head.get().is_null() { - // Full pass with close dispatch deferred to the next loop point. - // SAFETY: `self` is this thread's live loop. The walk holds its successor while JS - // runs, and relies on an endpoint staying rooted until its close is delivered. - unsafe { c::us_nq_loop_drain(self) }; - } if !self.internal_loop_data.has_quic() { return; } @@ -537,7 +525,6 @@ mod c { ) -> *mut Loop; pub(super) fn us_loop_free(loop_: *mut Loop); pub(super) fn us_quic_loop_flush_if_pending(loop_: &Loop); - pub(super) fn us_nq_loop_drain(loop_: &Loop); #[cfg(windows)] pub(super) fn us_loop_run(loop_: &Loop); #[cfg(windows)] @@ -550,6 +537,8 @@ mod c { now_ns: u64, ); pub(super) fn us_internal_free_closed_sockets(loop_: &Loop); + /// One pass of the socket timeout sweep. Must not run recursively. + pub fn us_internal_timer_sweep(loop_: &Loop); pub(super) fn us_loop_close_all_groups(loop_: &Loop) -> c_int; #[cfg(not(windows))] pub(super) safe fn uws_get_loop() -> *mut Loop; @@ -563,6 +552,9 @@ mod c { // `HTTPThread::wakeup`, bun_io's `WindowsWaker`): `Loop` is `!Sync`. pub use c::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/Request.rs b/src/uws_sys/Request.rs index 3f258cbf9cd7..52ca0c3515ac 100644 --- a/src/uws_sys/Request.rs +++ b/src/uws_sys/Request.rs @@ -5,6 +5,7 @@ use crate::h3::Request as H3Request; /// Transport-agnostic request handle. Static/file routes (and RangeRequest) /// take this so the same handler body serves HTTP/1.1 and HTTP/3 without /// `anytype` — `inline else` keeps dispatch monomorphic. +#[derive(Clone, Copy)] pub enum AnyRequest { H1(*mut Request), H3(*mut H3Request), @@ -38,6 +39,27 @@ impl AnyRequest { Self::H3(r) => bun_opaque::opaque_deref_mut(*r).set_yield(y), } } + /// The HTTP/1 parser's verdict, not a header lookup. HTTP/2 and HTTP/3 have no transfer codings (RFC 9114 4.2). + pub fn has_transfer_encoding(&self) -> bool { + match self { + Self::H1(r) => bun_opaque::opaque_deref_mut(*r).has_transfer_encoding(), + Self::H3(_) => false, + } + } + /// The number uWS parsed and frames the body by. + pub fn content_length(&self) -> Option { + match self { + Self::H1(r) => bun_opaque::opaque_deref_mut(*r).content_length(), + Self::H3(r) => bun_opaque::opaque_deref_mut(*r).content_length(), + } + } + /// The request as C++ takes it. + pub fn as_ptr(&self) -> *mut core::ffi::c_void { + match self { + Self::H1(r) => r.cast(), + Self::H3(r) => r.cast(), + } + } } bun_opaque::opaque_ffi! { @@ -83,6 +105,10 @@ impl Request { pub fn has_transfer_encoding(&self) -> bool { c::uws_req_has_transfer_encoding(self) } + pub fn content_length(&self) -> Option { + let length = c::uws_req_get_content_length(self); + (length != u64::MAX).then_some(length) + } pub fn parameter(&self, index: u16) -> &[u8] { let mut ptr: *const u8 = core::ptr::null(); let len = c::uws_req_get_parameter(self, c_ushort::try_from(index).unwrap(), &mut ptr); @@ -115,5 +141,6 @@ mod c { dest: &mut *const u8, ) -> usize; pub(super) safe fn uws_req_has_transfer_encoding(res: &Request) -> bool; + pub(super) safe fn uws_req_get_content_length(res: &Request) -> u64; } } diff --git a/src/uws_sys/Response.rs b/src/uws_sys/Response.rs index e581779ebfc5..b5937b3fe202 100644 --- a/src/uws_sys/Response.rs +++ b/src/uws_sys/Response.rs @@ -157,10 +157,6 @@ impl Response { } } - pub(crate) fn is_connect_request(&mut self) -> bool { - c::uws_res_is_connect_request(Self::ssl_flag(), self.as_raw()) - } - pub(crate) fn flush_headers(&mut self, flush_immediately: bool) { c::uws_res_flush_headers(Self::ssl_flag(), self.as_raw(), flush_immediately) } @@ -213,9 +209,9 @@ impl Response { c::uws_res_send_when_complete(Self::ssl_flag(), self.as_raw()) } - /// Defers the close to the end of the read uws is parsing. False: not parsing, the caller closes now. - pub fn close_after_message_if_parsing(&mut self) -> bool { - c::uws_res_close_after_message_if_parsing(Self::ssl_flag(), self.as_raw()) + /// Defers the close to the end of the read uws is parsing. False: not parsing, the caller closes now. `drop_corked`: what waits in the cork buffer isn't sent. + pub fn close_after_message_if_parsing(&mut self, drop_corked: bool) -> bool { + c::uws_res_close_after_message_if_parsing(Self::ssl_flag(), self.as_raw(), drop_corked) } /// Removes the handlers that were attached with `user_data`. See `HttpResponse::clearHandlersOf`. @@ -231,6 +227,10 @@ impl Response { c::uws_res_resume(Self::ssl_flag(), self.as_raw()) } + pub(crate) fn is_paused(&mut self) -> bool { + c::uws_res_is_paused(Self::ssl_flag(), self.as_raw()) + } + pub(crate) fn write_continue(&mut self) { c::uws_res_write_continue(Self::ssl_flag(), self.as_raw()) } @@ -290,6 +290,18 @@ impl Response { c::uws_res_end_without_body(Self::ssl_flag(), self.as_raw(), close_connection) } + pub(crate) fn accept_request_body(&mut self) -> bool { + c::uws_res_accept_request_body(Self::ssl_flag(), self.as_raw()) + } + + pub(crate) fn end_head_of_stream(&mut self, close_connection: bool) { + c::uws_res_end_head_of_stream(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(), @@ -306,7 +318,7 @@ impl Response { c::uws_res_close_if_done_and_marked(Self::ssl_flag(), self.as_raw()) } - pub fn timeout(&mut self, seconds: u8) { + pub(crate) fn timeout(&mut self, seconds: u8) { c::uws_res_timeout(Self::ssl_flag(), self.as_raw(), seconds) } @@ -314,6 +326,10 @@ impl Response { c::uws_res_reset_timeout(Self::ssl_flag(), self.as_raw()) } + pub(crate) fn suspend_timeout(&mut self) { + us_socket_t::opaque_mut(self.downcast_socket()).set_timeout(0); + } + pub(crate) fn get_buffered_amount(&mut self) -> u64 { c::uws_res_get_buffered_amount(Self::ssl_flag(), self.as_raw()) } @@ -367,16 +383,9 @@ impl Response { c::uws_res_has_responded(Self::ssl_flag(), self.as_raw()) } - pub(crate) fn mark_wrote_content_length_header(&mut self) { - c::uws_res_mark_wrote_content_length_header(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 write_mark(&mut self) { - c::uws_res_write_mark(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 get_native_handle(&mut self) -> Fd { @@ -469,13 +478,6 @@ impl Response { c::uws_res_clear_on_writable(Self::ssl_flag(), self.as_raw()) } - #[inline] - pub(crate) fn mark_needs_more(&mut self) { - if !SSL { - c::us_socket_mark_needs_more_not_ssl(self.as_raw()) - } - } - pub(crate) fn on_aborted(&mut self, _handler: H, optional_data: *mut U) where H: Fn(*mut U, &mut Response) + Copy + 'static, @@ -656,29 +658,6 @@ impl Response { ) } } - - /// Like `upgrade`, but the caller already negotiated. `handshake` is the complete response head - /// and is written verbatim. `deflate_params` is `None` if permessage-deflate wasn't negotiated. - pub(crate) fn upgrade_raw( - &mut self, - data: *mut D, - handshake: &[u8], - deflate_params: Option<&PerMessageDeflateParams>, - ctx: &mut WebSocketUpgradeContext, - ) -> *mut Socket { - // SAFETY: self is a live opaque uws_res handle owned by uWS; ptr+len from &[u8]. - unsafe { - c::uws_res_upgrade_raw( - Self::ssl_flag(), - self.downcast(), - data.cast::(), - handshake.as_ptr(), - handshake.len(), - deflate_params, - ctx, - ) - } - } } pub type TCPResponse = Response; @@ -782,42 +761,22 @@ macro_rules! any_response_register_cb { } impl AnyResponse { - pub fn assert_ssl(self) -> *mut TLSResponse { - match self { - AnyResponse::SSL(resp) => resp, - AnyResponse::TCP(_) => panic!("Expected SSL response, got TCP response"), - AnyResponse::H3(_) | AnyResponse::H2(_) => { - panic!("Expected SSL response, got a stream response") - } + /// Calls `on_writable` once the socket takes more, and arms the idle timeout for the wait. For sendfile, which is plain HTTP/1 only. + pub fn request_writable(self) { + if let AnyResponse::TCP(ptr) = self { + c::uws_res_request_writable(TCPResponse::as_handle(ptr).as_raw()) } } - pub fn assert_no_ssl(self) -> *mut TCPResponse { + /// 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(_) => panic!("Expected TCP response, got SSL response"), - AnyResponse::TCP(resp) => resp, - AnyResponse::H3(_) | AnyResponse::H2(_) => { - panic!("Expected TCP response, got a stream response") - } + 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_needs_more(self) { - any_dispatch!(self, |r| r.mark_needs_more()) - } - - pub fn mark_wrote_content_length_header(self) { - any_dispatch!(self, |r| r.mark_wrote_content_length_header()) - } - - pub fn mark_wrote_date_header(self) { - any_dispatch!(self, |r| r.mark_wrote_date_header()) - } - - pub fn write_mark(self) { - any_dispatch!(self, |r| r.write_mark()) - } - pub fn end_send_file(self, write_offset: u64, close_connection: bool) { any_dispatch!(self, |r| r.end_send_file(write_offset, close_connection)) } @@ -860,6 +819,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 +842,32 @@ 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(_) => {} + } + } + + /// See `Response::send_when_complete`. HTTP/1 only. + pub fn send_when_complete(self) { + match self { + AnyResponse::SSL(ptr) => TLSResponse::as_handle(ptr).send_when_complete(), + AnyResponse::TCP(ptr) => TCPResponse::as_handle(ptr).send_when_complete(), + AnyResponse::H3(_) | AnyResponse::H2(_) => {} + } + } + + /// HTTP/2: END_STREAM on the HEADERS frame, or `content-length: 0`. A QUIC FIN is only seen by a read after dispatch. + pub fn request_body_ended(self) -> bool { + match self { + AnyResponse::H2(ptr) => H2Response::as_handle(ptr).request_body_ended(), + AnyResponse::SSL(_) | AnyResponse::TCP(_) | AnyResponse::H3(_) => false, + } + } + pub fn get_buffered_amount(self) -> u64 { any_dispatch!(self, |r| r.get_buffered_amount()) } @@ -952,6 +952,7 @@ impl AnyResponse { bun_opaque::opaque_deref_mut(r).grow_request_window(); } } + /// Stops reading the request body. uWS keeps the socket paused until everyone who paused it has resumed it. pub fn pause(self) { any_dispatch!(self, |r| r.pause()) } @@ -960,6 +961,15 @@ impl AnyResponse { any_dispatch!(self, |r| r.resume()) } + /// Returns true between `pause()` and `resume()`. node:http only, so HTTP/1 only. + pub fn is_paused(self) -> bool { + match self { + AnyResponse::SSL(ptr) => TLSResponse::as_handle(ptr).is_paused(), + AnyResponse::TCP(ptr) => TCPResponse::as_handle(ptr).is_paused(), + AnyResponse::H3(_) | AnyResponse::H2(_) => false, + } + } + pub fn write_header_int(self, key: &[u8], value: u64) { any_dispatch!(self, |r| r.write_header_int(key, value)) } @@ -968,6 +978,33 @@ impl AnyResponse { any_dispatch!(self, |r| r.end_without_body(close_connection)) } + /// For a handler that can read the request body: `maxRequestBodySize` holds from here on. Returns false, with 413 sent, if the Content-Length is already over it. + pub fn accept_request_body(self) -> bool { + any_dispatch!(self, |r| r.accept_request_body()) + } + + /// Ends the response to a HEAD whose GET would stream its body: HTTP/1.1 says `Transfer-Encoding: chunked`, as that GET would. + pub fn end_head_of_stream(self, close_connection: bool) { + match self { + AnyResponse::SSL(ptr) => { + TLSResponse::as_handle(ptr).end_head_of_stream(close_connection) + } + AnyResponse::TCP(ptr) => { + TCPResponse::as_handle(ptr).end_head_of_stream(close_connection) + } + AnyResponse::H3(_) | AnyResponse::H2(_) => self.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 { @@ -982,10 +1019,14 @@ impl AnyResponse { } /// HTTP/1 only: an HTTP/2 or HTTP/3 stream has no socket read to finish. - pub fn close_after_message_if_parsing(self) -> bool { + pub fn close_after_message_if_parsing(self, drop_corked: bool) -> bool { match self { - AnyResponse::SSL(ptr) => TLSResponse::as_handle(ptr).close_after_message_if_parsing(), - AnyResponse::TCP(ptr) => TCPResponse::as_handle(ptr).close_after_message_if_parsing(), + AnyResponse::SSL(ptr) => { + TLSResponse::as_handle(ptr).close_after_message_if_parsing(drop_corked) + } + AnyResponse::TCP(ptr) => { + TCPResponse::as_handle(ptr).close_after_message_if_parsing(drop_corked) + } AnyResponse::H3(_) | AnyResponse::H2(_) => false, } } @@ -1103,12 +1144,17 @@ impl AnyResponse { any_dispatch!(self, |r| r.reset_timeout()) } - pub fn clear_on_data(self) { - any_dispatch!(self, |r| r.clear_on_data()) + /// Disarms an HTTP/1 connection's idle timer until the next write, for a response that waits on the server's own work (a bundle build). + pub fn suspend_timeout(self) { + match self { + AnyResponse::SSL(ptr) => TLSResponse::as_handle(ptr).suspend_timeout(), + AnyResponse::TCP(ptr) => TCPResponse::as_handle(ptr).suspend_timeout(), + AnyResponse::H3(_) | AnyResponse::H2(_) => {} + } } - pub fn is_connect_request(self) -> bool { - any_dispatch!(self, |r| r.is_connect_request()) + pub fn clear_on_data(self) { + any_dispatch!(self, |r| r.clear_on_data()) } pub fn end_stream(self, close_connection: bool) { @@ -1163,25 +1209,6 @@ impl AnyResponse { ), } } - - /// See `Response::upgrade_raw`. Returns null in the same cases as `upgrade`. - pub fn upgrade_raw( - self, - data: *mut D, - handshake: &[u8], - deflate_params: Option<&PerMessageDeflateParams>, - ctx: &mut WebSocketUpgradeContext, - ) -> *mut Socket { - match self { - AnyResponse::H3(_) | AnyResponse::H2(_) => core::ptr::null_mut(), - AnyResponse::SSL(ptr) => { - TLSResponse::as_handle(ptr).upgrade_raw(data, handshake, deflate_params, ctx) - } - AnyResponse::TCP(ptr) => { - TCPResponse::as_handle(ptr).upgrade_raw(data, handshake, deflate_params, ctx) - } - } - } } impl From<*mut TLSResponse> for AnyResponse { @@ -1221,14 +1248,14 @@ bitflags::bitflags! { #[repr(transparent)] #[derive(Clone, Copy, PartialEq, Eq)] pub struct State: u32 { - const HTTP_STATUS_CALLED = 1; - const HTTP_WRITE_CALLED = 2; - const HTTP_END_CALLED = 4; - const HTTP_RESPONSE_PENDING = 8; - const HTTP_CONNECTION_CLOSE = 16; - const HTTP_WROTE_CONTENT_LENGTH_HEADER = 32; - const HTTP_NODE_RECEIVED_FIN = 1 << 15; - const HTTP_NODE_CLOSE_AFTER_MESSAGE = 1 << 20; + const HTTP_STATUS_CALLED = 1; + const HTTP_WRITE_CALLED = 2; + const HTTP_END_CALLED = 4; + const HTTP_RESPONSE_PENDING = 8; + const HTTP_CONNECTION_CLOSE = 16; + const HTTP_REQUEST_BODY_TOO_LARGE = 128; + const HTTP_NODE_RECEIVED_FIN = 1 << 15; + const HTTP_NODE_CLOSE_AFTER_MESSAGE = 1 << 20; } } @@ -1239,8 +1266,8 @@ impl State { } #[inline] - pub fn has_written_content_length_header(self) -> bool { - self.bits() & State::HTTP_WROTE_CONTENT_LENGTH_HEADER.bits() != 0 + pub fn is_request_body_too_large(self) -> bool { + self.bits() & State::HTTP_REQUEST_BODY_TOO_LARGE.bits() != 0 } #[inline] @@ -1295,12 +1322,8 @@ pub mod c { // shims are `safe fn`; (ptr,len), nullable raw, *mut c_void ctx stay // 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_wrote_date_header(ssl: i32, res: &mut uws_res); - 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_mark_fixed_length_file_body(ssl: i32, res: &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 // stores into them and returns a length — no read-through precondition. pub(crate) safe fn uws_res_get_remote_address_info( @@ -1315,6 +1338,7 @@ pub mod c { pub(crate) safe fn uws_res_close_after_message_if_parsing( ssl: i32, res: &mut uws_res, + drop_corked: bool, ) -> bool; pub(crate) safe fn uws_res_clear_handlers_of( ssl: i32, @@ -1336,6 +1360,7 @@ pub mod c { pub(crate) safe fn uws_res_is_corked(ssl: i32, res: &mut uws_res) -> bool; pub(crate) safe fn uws_res_pause(ssl: i32, res: &mut uws_res); pub(crate) safe fn uws_res_resume(ssl: i32, res: &mut uws_res); + pub(crate) safe fn uws_res_is_paused(ssl: i32, res: &mut uws_res) -> bool; pub(crate) safe fn uws_res_write_continue(ssl: i32, res: &mut uws_res); pub(crate) fn uws_res_write_informational( ssl: i32, @@ -1369,6 +1394,14 @@ pub mod c { res: &mut uws_res, close_connection: bool, ); + pub(crate) safe fn uws_res_end_head_of_stream( + ssl: i32, + res: &mut uws_res, + close_connection: bool, + ); + pub(crate) safe fn uws_res_accept_request_body(ssl: i32, res: &mut uws_res) -> bool; + pub(crate) safe fn uws_res_abandon(ssl: i32, res: &mut uws_res); + pub(crate) safe fn uws_res_request_writable(res: &mut uws_res); pub(crate) safe fn uws_res_end_sendfile( ssl: i32, res: &mut uws_res, @@ -1453,15 +1486,6 @@ pub mod c { sec_web_socket_extensions_length: usize, ws: *mut WebSocketUpgradeContext, ) -> *mut Socket; - pub(crate) fn uws_res_upgrade_raw( - ssl: i32, - res: *mut uws_res, - data: *mut c_void, - handshake: *const u8, - handshake_length: usize, - deflate_params: Option<&PerMessageDeflateParams>, - ws: *mut WebSocketUpgradeContext, - ) -> *mut Socket; // safe: cork is synchronous — `ctx` is passed straight back to // `corker` without being dereferenced by the C++ shim itself, so the // call has no preconditions beyond the live opaque handle. diff --git a/src/uws_sys/SocketContext.rs b/src/uws_sys/SocketContext.rs index 0c6860e2f38f..692319c6279e 100644 --- a/src/uws_sys/SocketContext.rs +++ b/src/uws_sys/SocketContext.rs @@ -122,6 +122,7 @@ pub struct BunSocketContextOptions { pub crl: *const *const c_char, pub crl_count: u32, pub allow_partial_trust_chain: i32, + pub ca_is_default: i32, pub sigalgs: *const c_char, pub ecdh_curve: *const c_char, } @@ -153,6 +154,7 @@ impl Default for BunSocketContextOptions { crl: ptr::null(), crl_count: 0, allow_partial_trust_chain: 0, + ca_is_default: 0, sigalgs: ptr::null(), ecdh_curve: ptr::null(), } @@ -188,6 +190,14 @@ impl BunSocketContextOptions { Some(ctx) } + /// Whether the context is built from a file, of which `digest()` only knows the path, the size and the time. + pub fn reads_files(&self) -> bool { + !self.key_file_name.is_null() + || !self.cert_file_name.is_null() + || !self.dh_params_file_name.is_null() + || !self.ca_file_name.is_null() + } + /// SHA-256 over every field this struct carries, dereferencing string /// pointers so the digest is content-addressed (not pointer-addressed). /// Two option structs that build the same `SSL_CTX*` produce the same @@ -267,6 +277,7 @@ impl BunSocketContextOptions { h.update(bun_core::bytes_of(&self.session_timeout)); feed_arr(&mut h, self.crl, self.crl_count); h.update(bun_core::bytes_of(&self.allow_partial_trust_chain)); + h.update(bun_core::bytes_of(&self.ca_is_default)); feed_z(&mut h, self.sigalgs); feed_z(&mut h, self.ecdh_curve); let mut out = [0u8; 32]; diff --git a/src/uws_sys/SocketGroup.rs b/src/uws_sys/SocketGroup.rs index 0b39ab9efa44..9ac1a6c9ef12 100644 --- a/src/uws_sys/SocketGroup.rs +++ b/src/uws_sys/SocketGroup.rs @@ -29,13 +29,10 @@ pub struct SocketGroup { pub iterator: *mut us_socket_t, pub prev: *mut SocketGroup, pub next: *mut SocketGroup, - pub global_tick: u32, /// Sockets currently parked in `loop.data.low_prio_head` with /// `s->group == this`. They are NOT in `head_sockets` while queued, so /// `close_all`/`destroy` must account for them separately. pub low_prio_count: u16, - pub timestamp: u8, - pub long_timestamp: u8, pub linked: u8, } @@ -49,7 +46,6 @@ pub struct VTable { pub on_close: Option *mut us_socket_t>, pub on_timeout: Option *mut us_socket_t>, - pub on_long_timeout: Option *mut us_socket_t>, pub on_end: Option *mut us_socket_t>, pub on_connect_error: Option *mut us_socket_t>, pub on_connecting_error: @@ -59,13 +55,13 @@ pub struct VTable { } // Must match `struct us_socket_group_t` in libusockets.h. -// 9 ptrs + u32 + u16 + 3×u8, padded to 8-byte alignment. +// 9 ptrs + u16 + u8, padded to 8-byte alignment. const _: () = assert!( - core::mem::size_of::() == 9 * core::mem::size_of::<*mut c_void>() + 16, + core::mem::size_of::() == 9 * core::mem::size_of::<*mut c_void>() + 8, "SocketGroup layout drifted from us_socket_group_t" ); const _: () = assert!( - core::mem::size_of::() == 11 * core::mem::size_of::<*mut c_void>(), + core::mem::size_of::() == 10 * core::mem::size_of::<*mut c_void>(), "VTable layout drifted from us_socket_vtable_t" ); @@ -125,6 +121,12 @@ impl SocketGroup { unsafe { us_socket_group_close_all(self) } } + /// [`close_all`](Self::close_all) for a thread that ends: see `thread_ends` in libusockets.h. + pub fn close_all_as_thread_ends(&mut self) { + // SAFETY: forwarding to C; `self` is a valid initialized group. + unsafe { us_socket_group_close_all_ex(self, 1, 1) } + } + /// Recover the embedding owner. Only valid for groups whose `init` passed a /// non-null owner (Listener, uWS App/Context). Per-kind VM groups in /// `RareData` pass null, so callers must know which they have. @@ -147,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 { @@ -159,6 +162,7 @@ impl SocketGroup { options, socket_ext_size, err, + dns_err, ) } } @@ -212,6 +216,21 @@ impl SocketGroup { } } + /// Decodes an error uSockets reported for a listen, a connect or a close: an errno, or a Windows error code. + pub fn 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, @@ -311,6 +330,11 @@ unsafe extern "C" { // `close_all` reenters Rust callbacks that touch this group via aliasing // pointers (`us_socket_group(s)`). fn us_socket_group_close_all(group: *mut SocketGroup); + fn us_socket_group_close_all_ex( + group: *mut SocketGroup, + also_listeners: c_int, + thread_ends: c_int, + ); fn us_socket_group_listen( group: *mut SocketGroup, kind: u8, @@ -320,6 +344,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/WebSocket.rs b/src/uws_sys/WebSocket.rs index 4561ddec5e51..86d2622d03a8 100644 --- a/src/uws_sys/WebSocket.rs +++ b/src/uws_sys/WebSocket.rs @@ -267,7 +267,6 @@ pub struct WebSocketBehavior { pub close_on_backpressure_limit: bool, pub reset_idle_timeout_on_send: bool, pub send_pings_automatically: bool, - pub max_lifetime: c_ushort, pub auto_pong: bool, pub skip_utf8_validation: bool, pub upgrade: uws_websocket_upgrade_handler, @@ -289,7 +288,6 @@ impl Default for WebSocketBehavior { close_on_backpressure_limit: false, reset_idle_timeout_on_send: true, send_pings_automatically: true, - max_lifetime: 0, auto_pong: true, skip_utf8_validation: false, upgrade: None, @@ -533,7 +531,6 @@ where close_on_backpressure_limit: behavior.close_on_backpressure_limit, reset_idle_timeout_on_send: behavior.reset_idle_timeout_on_send, send_pings_automatically: behavior.send_pings_automatically, - max_lifetime: behavior.max_lifetime, auto_pong: behavior.auto_pong, skip_utf8_validation: behavior.skip_utf8_validation, upgrade: Some(Self::on_upgrade), diff --git a/src/uws_sys/_libusockets.h b/src/uws_sys/_libusockets.h index acf75ae27f06..59efaf3c80ca 100644 --- a/src/uws_sys/_libusockets.h +++ b/src/uws_sys/_libusockets.h @@ -75,7 +75,6 @@ struct uws_app_s; struct uws_req_s; struct uws_res_s; struct uws_websocket_s; -struct uws_header_iterator_s; typedef struct uws_app_s uws_app_t; typedef struct uws_req_s uws_req_t; typedef struct uws_res_s uws_res_t; @@ -110,9 +109,6 @@ typedef struct { bool resetIdleTimeoutOnSend; /* A good default, esp. for newcomers */ bool sendPingsAutomatically; - /* Maximum socket lifetime in seconds before forced closure (defaults to - * disabled) */ - unsigned short maxLifetime; bool autoPong; bool skipUTF8Validation; @@ -125,12 +121,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/h2.rs b/src/uws_sys/h2.rs index e9e7b7b87f53..58315e1ef847 100644 --- a/src/uws_sys/h2.rs +++ b/src/uws_sys/h2.rs @@ -29,8 +29,11 @@ impl Response { // SAFETY: self is a live FFI handle; data ptr/len valid for read unsafe { c::uws_h2_res_try_end(self, data.as_ptr(), data.len(), total, close_connection) } } - pub fn end_without_body(&mut self, close_connection: bool) { - c::uws_h2_res_end_without_body(self, close_connection) + pub(crate) fn accept_request_body(&mut self) -> bool { + c::uws_h2_res_accept_request_body(self) + } + pub fn end_without_body(&mut self, _close_connection: bool) { + c::uws_h2_res_end_without_body(self) } pub(crate) fn end_stream(&mut self, close_connection: bool) { c::uws_h2_res_end_stream(self, close_connection) @@ -72,15 +75,6 @@ impl Response { // SAFETY: self is a live FFI handle; key ptr/len valid for read unsafe { c::uws_h2_res_write_header_int(self, key.as_ptr(), key.len(), value) } } - pub(crate) fn write_mark(&mut self) { - c::uws_h2_res_write_mark(self) - } - pub(crate) fn mark_wrote_content_length_header(&mut self) { - c::uws_h2_res_mark_wrote_content_length_header(self) - } - pub(crate) fn mark_wrote_date_header(&mut self) { - c::uws_h2_res_mark_wrote_date_header(self) - } pub(crate) fn write_continue(&mut self) { c::uws_h2_res_write_continue(self) } @@ -101,7 +95,7 @@ impl Response { pub(crate) fn resume(&mut self) { c::uws_h2_res_resume(self) } - pub fn timeout(&mut self, seconds: u8) { + pub(crate) fn timeout(&mut self, seconds: u8) { c::uws_h2_res_timeout(self, seconds) } pub(crate) fn reset_timeout(&mut self) { @@ -133,11 +127,7 @@ impl Response { false } pub(crate) fn uncork(&mut self) {} - pub(crate) fn is_connect_request(&self) -> bool { - false - } pub(crate) fn prepare_for_sendfile(&mut self) {} - pub(crate) fn mark_needs_more(&mut self) {} pub(crate) fn get_remote_socket_info(&mut self) -> Option { let mut port: i32 = 0; let mut is_ipv6: bool = false; @@ -348,6 +338,7 @@ impl App { parent: &mut crate::app::App, allow_http1: bool, idle_timeout_s: u32, + max_request_body_size: u64, ) -> Option<*mut App> { // SAFETY: parent is a live TemplatedApp; uws owns the returned handle let p = unsafe { @@ -356,6 +347,7 @@ impl App { std::ptr::from_mut(parent).cast::(), allow_http1, idle_timeout_s, + max_request_body_size, ) }; if p.is_null() { None } else { Some(p) } @@ -513,6 +505,7 @@ mod c { parent: *mut crate::app::uws_app_t, allow_http1: bool, idle_timeout_s: u32, + max_request_body_size: u64, ) -> *mut App; pub(super) fn uws_h2_app_destroy(app: *mut App); pub(super) fn uws_h2_app_on_schedule_drain( @@ -606,7 +599,8 @@ mod c { total: usize, close: bool, ) -> bool; - pub(super) safe fn uws_h2_res_end_without_body(res: &mut Response, close: bool); + pub(super) safe fn uws_h2_res_accept_request_body(res: &mut Response) -> bool; + pub(super) safe fn uws_h2_res_end_without_body(res: &mut Response); pub(super) safe fn uws_h2_res_grow_request_window(res: &mut Response); pub(super) safe fn uws_h2_res_pause(res: &mut Response); pub(super) safe fn uws_h2_res_resume(res: &mut Response); @@ -624,9 +618,6 @@ mod c { kn: usize, v: u64, ); - pub(super) safe fn uws_h2_res_mark_wrote_content_length_header(res: &mut Response); - pub(super) safe fn uws_h2_res_mark_wrote_date_header(res: &mut Response); - pub(super) safe fn uws_h2_res_write_mark(res: &mut Response); pub(super) safe fn uws_h2_res_flush_headers(res: &mut Response, immediate: bool); pub(super) fn uws_h2_res_write(res: *mut Response, p: *const u8, len: *mut usize) -> bool; pub(super) safe fn uws_h2_res_has_responded(res: &mut Response) -> bool; diff --git a/src/uws_sys/h3.rs b/src/uws_sys/h3.rs index 782a148fd547..89ffedaadce3 100644 --- a/src/uws_sys/h3.rs +++ b/src/uws_sys/h3.rs @@ -63,6 +63,10 @@ impl Request { // SAFETY: uws returns a pointer+len pair valid for the lifetime of the request unsafe { bun_core::ffi::slice(p, n) } } + pub fn content_length(&mut self) -> Option { + let length = c::uws_h3_req_get_content_length(self); + (length != u64::MAX).then_some(length) + } pub fn header(&mut self, name: &[u8]) -> Option<&[u8]> { let mut p: *const u8 = ptr::null(); // SAFETY: self is a live FFI handle; name ptr/len valid for read; out-ptr is a valid local @@ -91,8 +95,11 @@ impl Response { // SAFETY: self is a live FFI handle; data ptr/len valid for read unsafe { c::uws_h3_res_try_end(self, data.as_ptr(), data.len(), total, close_connection) } } - pub fn end_without_body(&mut self, close_connection: bool) { - c::uws_h3_res_end_without_body(self, close_connection) + pub(crate) fn accept_request_body(&mut self) -> bool { + c::uws_h3_res_accept_request_body(self) + } + pub fn end_without_body(&mut self, _close_connection: bool) { + c::uws_h3_res_end_without_body(self) } pub(crate) fn end_stream(&mut self, close_connection: bool) { c::uws_h3_res_end_stream(self, close_connection) @@ -135,15 +142,6 @@ impl Response { // SAFETY: self is a live FFI handle; key ptr/len valid for read unsafe { c::uws_h3_res_write_header_int(self, key.as_ptr(), key.len(), value) } } - pub(crate) fn write_mark(&mut self) { - c::uws_h3_res_write_mark(self) - } - pub(crate) fn mark_wrote_content_length_header(&mut self) { - c::uws_h3_res_mark_wrote_content_length_header(self) - } - pub(crate) fn mark_wrote_date_header(&mut self) { - c::uws_h3_res_mark_wrote_date_header(self) - } pub(crate) fn write_continue(&mut self) { c::uws_h3_res_write_continue(self) } @@ -159,7 +157,7 @@ impl Response { pub(crate) fn resume(&mut self) { c::uws_h3_res_resume(self) } - pub fn timeout(&mut self, seconds: u8) { + pub(crate) fn timeout(&mut self, seconds: u8) { c::uws_h3_res_timeout(self, seconds) } pub(crate) fn reset_timeout(&mut self) { @@ -185,11 +183,7 @@ impl Response { false } pub(crate) fn uncork(&mut self) {} - pub(crate) fn is_connect_request(&self) -> bool { - false - } pub(crate) fn prepare_for_sendfile(&mut self) {} - pub(crate) fn mark_needs_more(&mut self) {} pub(crate) fn get_remote_socket_info(&mut self) -> Option { let mut port: i32 = 0; let mut is_ipv6: bool = false; @@ -392,9 +386,13 @@ macro_rules! h3_route_methods { } impl App { - pub fn create(opts: &BunSocketContextOptions, idle_timeout_s: u32) -> Option<*mut App> { + pub fn create( + opts: &BunSocketContextOptions, + idle_timeout_s: u32, + max_request_body_size: u64, + ) -> Option<*mut App> { // SAFETY: opts is `#[repr(C)]` passed by value; uws owns the returned handle - let p = unsafe { c::uws_h3_create_app(*opts, idle_timeout_s) }; + let p = unsafe { c::uws_h3_create_app(*opts, idle_timeout_s, max_request_body_size) }; if p.is_null() { None } else { Some(p) } } pub fn add_server_name_with_options( @@ -418,6 +416,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) } @@ -592,9 +599,16 @@ mod c { pub(super) fn uws_h3_create_app( opts: BunSocketContextOptions, idle_timeout_s: u32, + max_request_body_size: u64, ) -> *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, @@ -699,7 +713,8 @@ mod c { total: usize, close: bool, ) -> bool; - pub(super) safe fn uws_h3_res_end_without_body(res: &mut Response, close: bool); + pub(super) safe fn uws_h3_res_accept_request_body(res: &mut Response) -> bool; + pub(super) safe fn uws_h3_res_end_without_body(res: &mut Response); pub(super) safe fn uws_h3_res_pause(res: &mut Response); pub(super) safe fn uws_h3_res_resume(res: &mut Response); pub(super) fn uws_h3_res_write_status(res: *mut Response, p: *const u8, n: usize); @@ -716,9 +731,6 @@ mod c { kn: usize, v: u64, ); - pub(super) safe fn uws_h3_res_mark_wrote_content_length_header(res: &mut Response); - pub(super) safe fn uws_h3_res_mark_wrote_date_header(res: &mut Response); - pub(super) safe fn uws_h3_res_write_mark(res: &mut Response); pub(super) safe fn uws_h3_res_flush_headers(res: &mut Response, immediate: bool); pub(super) fn uws_h3_res_write(res: *mut Response, p: *const u8, len: *mut usize) -> bool; pub(super) safe fn uws_h3_res_has_responded(res: &mut Response) -> bool; @@ -773,6 +785,7 @@ mod c { // length — no read-through precondition, so `safe fn`. pub(super) safe fn uws_h3_req_get_url(req: &mut Request, out: &mut *const u8) -> usize; pub(super) safe fn uws_h3_req_get_method(req: &mut Request, out: &mut *const u8) -> usize; + pub(super) safe fn uws_h3_req_get_content_length(req: &mut Request) -> u64; pub(super) fn uws_h3_req_get_header( req: *mut Request, name: *const u8, diff --git a/src/uws_sys/lib.rs b/src/uws_sys/lib.rs index d2ec9aaefafc..9a87274d7d17 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). @@ -49,19 +51,8 @@ pub struct us_bun_verify_error_t { impl us_bun_verify_error_t { /// `X509_V_ERR_HOSTNAME_MISMATCH`, from the in-handshake server identity check (`ERR_TLS_CERT_ALTNAME_INVALID`). pub const HOSTNAME_MISMATCH: core::ffi::c_int = 62; - /// `error` of [`Self::peer_disconnected`]. Not an X509 code. + /// `error` of openssl.c's `us_ssl_peer_disconnected_error`. Not an X509 code. pub const PEER_DISCONNECTED: core::ffi::c_int = -46; - - /// The peer left before the handshake finished, like `ssl_trigger_handshake_econnreset` in openssl.c. - pub const fn peer_disconnected() -> Self { - Self { - error_no: Self::PEER_DISCONNECTED, - code: c"ECONNRESET".as_ptr(), - reason: - c"Client network socket disconnected before secure TLS connection was established" - .as_ptr(), - } - } } impl Default for us_bun_verify_error_t { @@ -176,12 +167,67 @@ pub enum SendStatus { /// `bun.timespec` — `us_loop_run_bun_tick` takes `*const timespec`. pub use bun_core::Timespec; -// Opaque FFI handles (Nomicon pattern) — what higher tiers reach for when the -// real module body isn't needed. See `bun_core::opaque_extern!` doc for the -// `UnsafeCell<[u8;0]>` / `!Freeze` rationale; with UnsafeCell the reference is -// ABI-identical to a non-null pointer, which lets us declare value-typed shims -// as `safe fn` and drop per-call-site `unsafe { }`. -bun_core::opaque_extern!(pub UpgradedDuplex, pub WindowsNamedPipe); +/// openssl.c's functions that need only an `SSL*`: what the TLS engine over a `us_socket_t` and `SSLWrapper` share. +pub mod ssl { + use super::{SslCtx, us_bun_verify_error_t}; + use bun_boringssl_sys::{SSL, SSL_SESSION}; + use core::ffi::{c_char, c_int, c_uint, c_void}; + unsafe extern "C" { + /// `SSL_new` and the setup of a client or a server. Never null. + pub fn us_ssl_new(ctx: *mut SslCtx, is_client: c_int) -> *mut SSL; + /// `requestCert` / `rejectUnauthorized` as BoringSSL's verify mode. + pub safe fn us_ssl_set_verify_mode( + ssl: &SSL, + request_cert: c_int, + reject_unauthorized: c_int, + ); + /// A client refuses a bad server chain during the handshake, before its own certificate goes out. + pub safe fn us_ssl_set_inline_reject(ssl: &SSL); + pub safe fn us_ssl_inline_reject_tripped(ssl: &SSL) -> c_int; + /// `check(owner, ssl)` gives the verify step's `US_IDENTITY_*` verdict. `owner` must outlive `ssl`. + pub fn us_ssl_set_identity_check( + ssl: &SSL, + check: extern "C" fn(*mut c_void, *mut SSL) -> c_int, + owner: *mut c_void, + ); + /// 1 when the verify step of this handshake asked the owner for the server's name. + pub safe fn us_ssl_identity_checked(ssl: &SSL) -> c_int; + /// The X509 verdict on the peer's chain, as it stands now. + pub safe fn us_ssl_socket_verify_error_from_ssl(ssl: &SSL) -> us_bun_verify_error_t; + pub fn us_ssl_protocol_error(reason: *const c_char) -> us_bun_verify_error_t; + pub safe fn us_ssl_peer_disconnected_error() -> us_bun_verify_error_t; + /// Formats why the last `SSL_*` call failed into `reason[..length]`, NUL-terminated. 0 when nothing is queued. + pub fn us_ssl_take_error_reason(reason: *mut c_char, length: usize) -> c_int; + /// Answers `SSL_ERROR_WANT_RENEGOTIATE` within the context's budget. 0 if refused. + pub safe fn us_ssl_renegotiate(ssl: &SSL) -> c_int; + /// The `TLSSocket` that BoringSSL's callbacks on `ssl` report to. Stored verbatim, never dereferenced there. + pub safe fn us_ssl_set_bun_socket(ssl: &SSL, bun_socket: *mut c_void); + pub safe fn us_ssl_bun_socket(ssl: &SSL) -> *mut c_void; + /// Hands what the new-session and keylog callbacks parked to that socket. Runs JS. + pub safe fn us_ssl_flush_pending_events(ssl: &SSL); + /// The session the new-session callback delivered last, borrowed. Null if none. + pub safe fn us_ssl_latest_session(ssl: &SSL) -> *mut SSL_SESSION; + /// Asks `us_dispatch_server_name` for the context of this SSL's server name. + pub safe fn us_ssl_on_server_name(ssl: &SSL); + /// Takes the reference `ctx` carries. 1 if the handshake waited for this answer: drive it again. + pub fn us_ssl_sni_resolve(ssl: &SSL, ctx: *mut SslCtx, error: c_int) -> c_int; + /// How many bytes may wait behind the ClientHello of a suspended handshake. + pub safe fn us_ssl_sni_held_max() -> c_uint; + /// 1 while an asynchronous SNICallback has the handshake suspended. + pub safe fn us_ssl_sni_waiting(ssl: &SSL) -> c_int; + /// 1 once the SNICallback refused the server name. + pub safe fn us_ssl_sni_refused(ssl: &SSL) -> c_int; + /// Hands the ALPN protocols of this SSL's ClientHello to `us_dispatch_alpn_offer`. + pub safe fn us_ssl_on_alpn_offer(ssl: &SSL); + /// `setKeyCert()` on a server. Returns 0 if `ctx` has no certificate. + pub fn us_ssl_set_key_cert( + ssl: *mut SSL, + ctx: *mut SslCtx, + sid_ctx: *const u8, + sid_ctx_length: usize, + ) -> c_int; + } +} pub mod socket_transfer { use super::LIBUS_SOCKET_DESCRIPTOR; @@ -202,220 +248,11 @@ pub mod socket_transfer { out_port: *mut c_int, error: *mut c_int, ) -> LIBUS_SOCKET_DESCRIPTOR; - } -} - -// ── UpgradedDuplex (cycle-break shim) ──────────────────────────────────────── -// The full `UpgradedDuplex` lives in `bun_runtime::socket` (T6); `socket.rs` -// here dispatches to it from the low-tier `InternalSocket` enum. To avoid an -// upward dep, the opaque handle gets thin inherent methods that forward to -// `extern "C"` symbols which the runtime crate exports with `#[no_mangle]`. -// This is the same link-time-dispatch pattern as other `*_sys` crates use for -// their C backends — only here the "backend" is Rust in a higher tier. -// Signatures must stay in sync with `src/runtime/socket/UpgradedDuplex.rs`. -// SAFETY (safe fn): `UpgradedDuplex` is an `opaque_extern!` ZST handle (`!Freeze` -// via `UnsafeCell`), so `&`/`&mut` carry no `readonly`/`noalias` and are -// ABI-identical to non-null `*const`/`*mut`. Shims taking only the handle + -// scalars are `safe fn`; the two `(ptr,len)` slice writers stay `unsafe fn`. -unsafe extern "C" { - safe fn UpgradedDuplex__ssl_error(this: &UpgradedDuplex) -> us_bun_verify_error_t; - safe fn UpgradedDuplex__is_established(this: &UpgradedDuplex) -> bool; - safe fn UpgradedDuplex__is_closed(this: &UpgradedDuplex) -> bool; - 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); - safe fn UpgradedDuplex__latest_session( - this: &UpgradedDuplex, - ) -> *mut bun_boringssl_sys::SSL_SESSION; - safe fn UpgradedDuplex__set_timeout(this: &mut UpgradedDuplex, seconds: core::ffi::c_uint); - safe fn UpgradedDuplex__flush(this: &mut UpgradedDuplex); - fn UpgradedDuplex__encode_and_write( - this: *mut UpgradedDuplex, - ptr: *const u8, - len: usize, - ) -> i32; - fn UpgradedDuplex__raw_write(this: *mut UpgradedDuplex, ptr: *const u8, len: usize) -> i32; - safe fn UpgradedDuplex__shutdown(this: &mut UpgradedDuplex); - safe fn UpgradedDuplex__shutdown_read(this: &mut UpgradedDuplex); - safe fn UpgradedDuplex__close(this: &mut UpgradedDuplex); - safe fn UpgradedDuplex__abandon_js_side(this: &mut UpgradedDuplex); - safe fn UpgradedDuplex__pause_stream(this: &mut UpgradedDuplex) -> bool; - safe fn UpgradedDuplex__resume_stream(this: &mut UpgradedDuplex) -> bool; -} -impl UpgradedDuplex { - #[inline] - pub(crate) fn ssl_error(&self) -> us_bun_verify_error_t { - UpgradedDuplex__ssl_error(self) - } - #[inline] - pub(crate) fn is_established(&self) -> bool { - UpgradedDuplex__is_established(self) - } - #[inline] - pub(crate) fn is_closed(&self) -> bool { - UpgradedDuplex__is_closed(self) - } - #[inline] - pub(crate) fn is_shutdown(&self) -> bool { - UpgradedDuplex__is_shutdown(self) - } - #[inline] - pub(crate) fn ssl(&self) -> Option<*mut bun_boringssl_sys::SSL> { - let p = UpgradedDuplex__ssl(self); - if p.is_null() { None } else { Some(p) } - } - #[inline] - pub(crate) fn set_inline_reject(&self) { - UpgradedDuplex__set_inline_reject(self) - } - #[inline] - pub(crate) fn latest_session(&self) -> *mut bun_boringssl_sys::SSL_SESSION { - UpgradedDuplex__latest_session(self) - } - #[inline] - pub(crate) fn set_timeout(&mut self, seconds: core::ffi::c_uint) { - UpgradedDuplex__set_timeout(self, seconds) - } - #[inline] - pub(crate) fn flush(&mut self) { - UpgradedDuplex__flush(self) - } - #[inline] - pub(crate) fn encode_and_write(&mut self, data: &[u8]) -> i32 { - // SAFETY: `&mut self` coerces to a non-null `*mut UpgradedDuplex` valid for - // the call, and `(data.as_ptr(), data.len())` is a valid readable region - // borrowed for the call's duration; the callee only reads from it. - unsafe { UpgradedDuplex__encode_and_write(self, data.as_ptr(), data.len()) } - } - #[inline] - pub(crate) fn raw_write(&mut self, data: &[u8]) -> i32 { - // SAFETY: `&mut self` coerces to a non-null `*mut UpgradedDuplex` valid for - // the call, and `(data.as_ptr(), data.len())` is a valid readable region - // borrowed for the call's duration; the callee only reads from it. - unsafe { UpgradedDuplex__raw_write(self, data.as_ptr(), data.len()) } - } - #[inline] - pub(crate) fn shutdown(&mut self) { - UpgradedDuplex__shutdown(self) - } - #[inline] - pub(crate) fn shutdown_read(&mut self) { - UpgradedDuplex__shutdown_read(self) - } - #[inline] - pub(crate) fn close(&mut self) { - UpgradedDuplex__close(self) - } - #[inline] - pub(crate) fn abandon_js_side(&mut self) { - UpgradedDuplex__abandon_js_side(self) - } - #[inline] - pub(crate) fn pause_stream(&mut self) -> bool { - UpgradedDuplex__pause_stream(self) - } - #[inline] - pub(crate) fn resume_stream(&mut self) -> bool { - UpgradedDuplex__resume_stream(self) - } -} - -// ── WindowsNamedPipe (cycle-break shim) ───────────────────────────────────── -// Same link-time-dispatch as `UpgradedDuplex` above: the real -// `WindowsNamedPipe` lives in `bun_runtime::socket`; this opaque handle -// forwards to `extern "C"` symbols that the runtime crate exports with -// `#[no_mangle]`. -#[cfg(windows)] -unsafe extern "C" { - safe fn WindowsNamedPipe__ssl_error(this: &WindowsNamedPipe) -> us_bun_verify_error_t; - safe fn WindowsNamedPipe__is_established(this: &WindowsNamedPipe) -> bool; - safe fn WindowsNamedPipe__is_closed(this: &WindowsNamedPipe) -> bool; - safe fn WindowsNamedPipe__is_shutdown(this: &WindowsNamedPipe) -> bool; - safe fn WindowsNamedPipe__ssl(this: &WindowsNamedPipe) -> *mut bun_boringssl_sys::SSL; - safe fn WindowsNamedPipe__set_inline_reject(this: &WindowsNamedPipe); - safe fn WindowsNamedPipe__latest_session( - this: &WindowsNamedPipe, - ) -> *mut bun_boringssl_sys::SSL_SESSION; - safe fn WindowsNamedPipe__set_timeout(this: &mut WindowsNamedPipe, seconds: core::ffi::c_uint); - safe fn WindowsNamedPipe__flush(this: &mut WindowsNamedPipe); - fn WindowsNamedPipe__encode_and_write( - this: *mut WindowsNamedPipe, - ptr: *const u8, - len: usize, - ) -> i32; - fn WindowsNamedPipe__raw_write(this: *mut WindowsNamedPipe, ptr: *const u8, len: usize) -> i32; - safe fn WindowsNamedPipe__shutdown(this: &mut WindowsNamedPipe); - safe fn WindowsNamedPipe__shutdown_read(this: &mut WindowsNamedPipe); - safe fn WindowsNamedPipe__close(this: &mut WindowsNamedPipe); - safe fn WindowsNamedPipe__pause_stream(this: &mut WindowsNamedPipe) -> bool; - safe fn WindowsNamedPipe__resume_stream(this: &mut WindowsNamedPipe) -> bool; -} -#[cfg(windows)] -impl WindowsNamedPipe { - #[inline] - pub(crate) fn ssl_error(&self) -> us_bun_verify_error_t { - WindowsNamedPipe__ssl_error(self) - } - #[inline] - pub(crate) fn is_established(&self) -> bool { - WindowsNamedPipe__is_established(self) - } - #[inline] - pub(crate) fn is_closed(&self) -> bool { - WindowsNamedPipe__is_closed(self) - } - #[inline] - pub(crate) fn is_shutdown(&self) -> bool { - WindowsNamedPipe__is_shutdown(self) - } - #[inline] - pub(crate) fn ssl(&self) -> Option<*mut bun_boringssl_sys::SSL> { - let p = WindowsNamedPipe__ssl(self); - if p.is_null() { None } else { Some(p) } - } - #[inline] - pub(crate) fn set_inline_reject(&self) { - WindowsNamedPipe__set_inline_reject(self) - } - #[inline] - pub(crate) fn latest_session(&self) -> *mut bun_boringssl_sys::SSL_SESSION { - WindowsNamedPipe__latest_session(self) - } - #[inline] - pub(crate) fn set_timeout(&mut self, seconds: core::ffi::c_uint) { - WindowsNamedPipe__set_timeout(self, seconds) - } - #[inline] - pub(crate) fn flush(&mut self) { - WindowsNamedPipe__flush(self) - } - #[inline] - pub(crate) fn encode_and_write(&mut self, data: &[u8]) -> i32 { - unsafe { WindowsNamedPipe__encode_and_write(self, data.as_ptr(), data.len()) } - } - #[inline] - pub(crate) fn raw_write(&mut self, data: &[u8]) -> i32 { - unsafe { WindowsNamedPipe__raw_write(self, data.as_ptr(), data.len()) } - } - #[inline] - pub(crate) fn shutdown(&mut self) { - WindowsNamedPipe__shutdown(self) - } - #[inline] - pub(crate) fn shutdown_read(&mut self) { - WindowsNamedPipe__shutdown_read(self) - } - #[inline] - pub(crate) fn close(&mut self) { - WindowsNamedPipe__close(self) - } - #[inline] - pub(crate) fn pause_stream(&mut self) -> bool { - WindowsNamedPipe__pause_stream(self) - } - #[inline] - pub(crate) fn resume_stream(&mut self) -> bool { - WindowsNamedPipe__resume_stream(self) + pub fn bsd_create_bound_socket_unix( + path: *const c_char, + len: usize, + error: *mut c_int, + ) -> LIBUS_SOCKET_DESCRIPTOR; } } @@ -522,6 +359,7 @@ pub mod fault_inject { } pub use socket::{ AnySocket, ConnectError, InternalSocket, NewSocketHandler, SocketHandler, SocketTCP, SocketTLS, + Transport, TransportHandle, }; // ───────────────────────────── re-exports ──────────────────────────────────── @@ -543,7 +381,7 @@ pub use response::{AnyResponse, SocketAddress, WebSocketUpgradeContext}; pub use socket_context::BunSocketContextOptions; pub use socket_group::ConnectResult; pub use socket_group::SocketGroup; -pub use us_socket::{CloseCode, QueuedInput, UsIoVec, us_socket_stream_buffer_t, us_socket_t}; +pub use us_socket::{CloseCode, QueuedInput, UsIoVec, us_socket_t}; pub use web_socket::{AnyWebSocket, RawWebSocket, WebSocketBehavior}; /// Legacy aliases for `App` / `Response`. diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index 8d1faf19ebaa..d9c9eaf338ec 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -54,7 +54,6 @@ static auto makeBehavior(const uws_socket_behavior_t &behavior, void *upgradeCon .closeOnBackpressureLimit = behavior.closeOnBackpressureLimit, .resetIdleTimeoutOnSend = behavior.resetIdleTimeoutOnSend, .sendPingsAutomatically = behavior.sendPingsAutomatically, - .maxLifetime = behavior.maxLifetime, .autoPong = behavior.autoPong, .skipUTF8Validation = behavior.skipUTF8Validation, }; @@ -118,17 +117,14 @@ extern "C" ((uWS::Loop *) loop)->flushPendingWrites(); } - uws_app_t *uws_create_app(int ssl, struct us_bun_socket_context_options_t options) + uws_app_t *uws_create_app(struct ssl_ctx_st *ssl_ctx) { - uWS::SocketContextOptions socket_context_options; - memcpy(&socket_context_options, &options, - sizeof(uWS::SocketContextOptions)); - if (ssl) + if (ssl_ctx) { - return (uws_app_t *)uWS::SSLApp::create(socket_context_options); + return (uws_app_t *)uWS::SSLApp::create(ssl_ctx); } - return (uws_app_t *)uWS::App::create(socket_context_options); + return (uws_app_t *)uWS::App::create(nullptr); } void uws_app_clear_routes(int ssl, uws_app_t *app) @@ -438,31 +434,17 @@ extern "C" } } - void uws_app_run(int ssl, uws_app_t *app) - { - if (ssl) - { - uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; - uwsApp->run(); - } - else - { - uWS::App *uwsApp = (uWS::App *)app; - uwsApp->run(); - } - } - - void uws_app_close(int ssl, uws_app_t *app) + void uws_app_close(int ssl, uws_app_t *app, int thread_ends) { if (ssl) { uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; - uwsApp->close(); + uwsApp->close(thread_ends); } else { uWS::App *uwsApp = (uWS::App *)app; - uwsApp->close(); + uwsApp->close(thread_ends); } } @@ -506,54 +488,46 @@ 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 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, options, error); } + return ((uWS::App *)app)->listen_fd(fd, options, error); } + void uws_app_set_idle_timeout(int ssl, uws_app_t *app, uint8_t seconds) { + if (ssl) { + ((uWS::SSLApp *)app)->setIdleTimeout(seconds); + } else { + ((uWS::App *)app)->setIdleTimeout(seconds); + } + } void uws_app_set_max_http_header_size(int ssl, uws_app_t *app, uint64_t max_header_size) { if (ssl) { uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; @@ -563,6 +537,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; @@ -572,13 +555,13 @@ extern "C" uwsApp->setMaxHeadersCount(max_headers_count); } } - void uws_app_set_flags(int ssl, uws_app_t *app, bool require_host_header, bool use_strict_method_validation, uint8_t lenient_http_flags, bool http_allow_half_open) { + void uws_app_set_flags(int ssl, uws_app_t *app, uint8_t lenient_http_flags, bool http_allow_half_open) { if (ssl) { uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; - uwsApp->setFlags(require_host_header, use_strict_method_validation, lenient_http_flags, http_allow_half_open); + uwsApp->setFlags(lenient_http_flags, http_allow_half_open); } else { uWS::App *uwsApp = (uWS::App *)app; - uwsApp->setFlags(require_host_header, use_strict_method_validation, lenient_http_flags, http_allow_half_open); + uwsApp->setFlags(lenient_http_flags, http_allow_half_open); } } @@ -623,26 +606,9 @@ extern "C" stringViewFromC(message, message_length), (uWS::OpCode)(unsigned char)opcode, compress); } - int uws_add_server_name_with_options( - int ssl, uws_app_t *app, const char *hostname_pattern, - struct us_bun_socket_context_options_t options, - int apply_client_cert_policy) + void uws_add_server_name(uws_app_t *app, const char *hostname_pattern, struct ssl_ctx_st *ssl_ctx, bool own_client_auth) { - uWS::SocketContextOptions sco; - memcpy(&sco, &options, sizeof(uWS::SocketContextOptions)); - bool success = false; - - if (ssl) - { - uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; - uwsApp->addServerName(hostname_pattern, sco, &success, apply_client_cert_policy != 0); - } - else - { - uWS::App *uwsApp = (uWS::App *)app; - uwsApp->addServerName(hostname_pattern, sco, &success, apply_client_cert_policy != 0); - } - return !success; + ((uWS::SSLApp *)app)->addServerName(hostname_pattern, ssl_ctx, own_client_auth); } void uws_filter(int ssl, uws_app_t *app, uws_filter_handler handler, @@ -826,36 +792,35 @@ extern "C" { return; } - if (ssl) - { - uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - uwsRes->pause(); - } - else + withSSL(ssl, [&](auto isSSL) { + constexpr bool SSL = decltype(isSSL)::value; + ((uWS::HttpResponse *)res)->pauseReads(uWS::HttpResponseData::READ_PAUSE_BODY); + }); + } + + bool uws_res_is_paused(int ssl, uws_res_r res) + { + if (us_socket_is_closed((struct us_socket_t *)res)) { - uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - uwsRes->pause(); + return false; } + return withSSL(ssl, [&](auto isSSL) { + constexpr bool SSL = decltype(isSSL)::value; + return (((uWS::HttpResponse *)res)->getHttpResponseData()->readPauseReasons & (uWS::HttpResponseData::READ_PAUSE_BODY | uWS::HttpResponseData::READ_PAUSE_SOCKET)) != 0; + }); } void uws_res_resume(int ssl, uws_res_r res) { - /* No-op on a closed socket (resume's resetTimeout reads the destructed - * ext); see uws_res_on_aborted. */ + /* No-op on a closed socket; see uws_res_on_aborted. */ if (us_socket_is_closed((struct us_socket_t *)res)) { return; } - if (ssl) - { - uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - uwsRes->resume(); - } - else - { - uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - uwsRes->resume(); - } + withSSL(ssl, [&](auto isSSL) { + constexpr bool SSL = decltype(isSSL)::value; + ((uWS::HttpResponse *)res)->resumeReads(uWS::HttpResponseData::READ_PAUSE_BODY); + }); } void uws_res_write_continue(int ssl, uws_res_r res) @@ -902,33 +867,13 @@ extern "C" } } - void uws_res_mark_wrote_content_length_header(int ssl, uws_res_r res) { - if (ssl) { - uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - uwsRes->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER; - } else { - uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - uwsRes->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER; - } - } - - void uws_res_mark_wrote_date_header(int ssl, uws_res_r res) { - if (ssl) { - uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - uwsRes->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_WROTE_DATE_HEADER; - } else { - uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - uwsRes->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_WROTE_DATE_HEADER; - } - } - - void uws_res_write_mark(int ssl, uws_res_r res) { + void uws_res_mark_fixed_length_file_body(int ssl, uws_res_r res) { if (ssl) { uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - uwsRes->writeMark(); + uwsRes->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_FIXED_LENGTH_FILE_BODY; } else { uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - uwsRes->writeMark(); + uwsRes->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_FIXED_LENGTH_FILE_BODY; } } @@ -971,6 +916,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 +929,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(); @@ -1034,65 +987,49 @@ extern "C" } } + /* No close gate: callers keep using the response, so a corked one relies on cork()'s gate and the others call uws_res_close_if_done_and_marked. */ void uws_res_end_without_body(int ssl, uws_res_r res, bool close_connection) + { + withSSL(ssl, [&](auto isSSL) { + using Response = uWS::HttpResponse; + ((Response *)res)->endWithoutBody(Response::NO_BODY, close_connection); + }); + } + + bool uws_res_accept_request_body(int ssl, uws_res_r res) + { + return withSSL(ssl, [&](auto isSSL) { + return ((uWS::HttpResponse *)res)->acceptRequestBody(); + }); + } + + /* The response to a HEAD whose GET would stream its body. */ + void uws_res_end_head_of_stream(int ssl, uws_res_r res, bool close_connection) + { + withSSL(ssl, [&](auto isSSL) { + using Response = uWS::HttpResponse; + ((Response *)res)->endWithoutBody(Response::UNKNOWN_LENGTH, close_connection); + }); + } + + /* 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(); - /* 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 - * 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(). */ - 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); - } - data->state |= uWS::HttpResponseData::HTTP_END_CALLED; + data->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE | uWS::HttpResponseData::HTTP_END_CALLED; data->markDone(uwsRes); uwsRes->resetTimeout(); - /* No close gate here: callers (FileResponseStream::finish, - * DevServer/HTMLBundle error paths) keep using the response after this - * returns, so closing inside this call would destruct the ext under - * them. Corked callers get the cork() wrapper's post-uncork gate; - * uncorked ones run uws_res_close_if_done_and_marked themselves once - * they are done with the response. */ } else { uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; auto *data = uwsRes->getHttpResponseData(); - /* See the SSL arm above. */ - 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); - } - data->state |= uWS::HttpResponseData::HTTP_END_CALLED; + data->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE | uWS::HttpResponseData::HTTP_END_CALLED; data->markDone(uwsRes); uwsRes->resetTimeout(); - /* No close gate here; see the SSL arm above. */ } } @@ -1353,6 +1290,11 @@ size_t uws_req_get_header(uws_req_t *res, const char *lower_case_header, return uwsReq->getHasTransferEncoding(); } + uint64_t uws_req_get_content_length(uws_req_t *res) + { + return ((uWS::HttpRequest *)res)->getContentLength(); + } + us_socket_t *uws_res_upgrade(int ssl, uws_res_r res, void *data, const char *sec_web_socket_key, size_t sec_web_socket_key_length, @@ -1374,21 +1316,6 @@ size_t uws_req_get_header(uws_req_t *res, const char *lower_case_header, }); } - us_socket_t *uws_res_upgrade_raw(int ssl, uws_res_r res, void *data, - const char *handshake, size_t handshake_length, - const uWS::PerMessageDeflateParams *deflate_params, - uws_socket_context_t *ws) - { - return withSSL(ssl, [&](auto isSSL) { - constexpr bool SSL = decltype(isSSL)::value; - return ((uWS::HttpResponse *)res)->template upgradeRaw( - data ? std::move(data) : nullptr, - stringViewFromC(handshake, handshake_length), - deflate_params, - (uWS::WebSocketContext *)ws); - }); - } - struct us_loop_t *uws_get_loop() { return (struct us_loop_t *)uWS::Loop::get(); @@ -1459,32 +1386,20 @@ size_t uws_req_get_header(uws_req_t *res, const char *lower_case_header, } } - bool uws_res_close_after_message_if_parsing(int ssl, uws_res_r res) + bool uws_res_close_after_message_if_parsing(int ssl, uws_res_r res, bool drop_corked) { if (ssl) { uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - return uwsRes->closeAfterMessageIfParsing(); + return uwsRes->closeAfterMessageIfParsing(drop_corked); } else { uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - return uwsRes->closeAfterMessageIfParsing(); + return uwsRes->closeAfterMessageIfParsing(drop_corked); } } - 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; @@ -1506,28 +1421,19 @@ __attribute__((callback (corker, ctx))) } } + void uws_res_request_writable(uws_res_r res) + { + ((uWS::HttpResponse *)res)->requestWritable(); + } + void uws_res_prepare_for_sendfile(int ssl, uws_res_r res) { - 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->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'; + withSSL(ssl, [&](auto isSSL) { + auto *uwsRes = (uWS::HttpResponse *)res; + /* The caller declared the file's length. If sendfile(2) is refused, the rest of the body goes through write(). */ + uwsRes->startBody(); uwsRes->uncork(); - } + }); } bool uws_res_try_end(int ssl, uws_res_r res, const char *bytes, size_t len, @@ -1596,16 +1502,6 @@ __attribute__((callback (corker, ctx))) } } - bool uws_res_is_connect_request(int ssl, uws_res_r res) - { - if (ssl) { - uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - return uwsRes->isConnectRequest(); - } else { - uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - return uwsRes->isConnectRequest(); - } - } void *uws_res_get_native_handle(int ssl, uws_res_r res) { if (ssl) @@ -1628,16 +1524,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); } @@ -1649,29 +1535,9 @@ __attribute__((callback (corker, ctx))) // This function is manual inlining + modification of // us_socket_remote_address // AsyncSocket::getRemoteAddress - // AsyncSocket::addressAsText // To get { ip, port, is_ipv6 } for Bun.serve().requestIP() static thread_local char b[64]; - auto length = us_get_remote_address_info(b, (us_socket_t *)res, dest, port, (int*)is_ipv6); - - if (length == 0) return 0; - if (length == 4) { - ares_inet_ntop(AF_INET, b, &b[4], 64 - 4); - *dest = &b[4]; - *is_ipv6 = false; - return strlen(*dest); - } else { - ares_inet_ntop(AF_INET6, b, &b[16], 64 - 16); - *dest = &b[16]; - *is_ipv6 = true; - return strlen(*dest); - } - } - - uint64_t uws_res_get_local_address_info(uws_res_r res, const char **dest, int *port, bool *is_ipv6) - { - static thread_local char b[64]; - auto length = us_get_local_address_info(b, (us_socket_t *)res, dest, port, (int*)is_ipv6); + auto length = us_get_remote_address_info(b, (us_socket_t *)res, port); if (length == 0) return 0; if (length == 4) { diff --git a/src/uws_sys/libuwsockets_h2.cpp b/src/uws_sys/libuwsockets_h2.cpp index 59a9173e956f..0c9fd03056fd 100644 --- a/src/uws_sys/libuwsockets_h2.cpp +++ b/src/uws_sys/libuwsockets_h2.cpp @@ -32,18 +32,17 @@ typedef void (*uws_h2_method_handler)(uws_h2_res_t*, uws_h3_req_t*, void*); /* ───── app ───── */ -uws_h2_app_t* uws_h2_create_app(int ssl, uws_app_t* parent, bool allow_http1, unsigned int idle_timeout_s) +uws_h2_app_t* uws_h2_create_app(int ssl, uws_app_t* parent, bool allow_http1, unsigned int idle_timeout_s, uint64_t max_request_body_size) { if (ssl) { - return (uws_h2_app_t*)H2App::create((uWS::TemplatedApp*)parent, allow_http1, idle_timeout_s); + return (uws_h2_app_t*)H2App::create((uWS::TemplatedApp*)parent, allow_http1, idle_timeout_s, max_request_body_size); } - return (uws_h2_app_t*)H2App::create((uWS::TemplatedApp*)parent, allow_http1, idle_timeout_s); + return (uws_h2_app_t*)H2App::create((uWS::TemplatedApp*)parent, allow_http1, idle_timeout_s, max_request_body_size); } void uws_h2_app_destroy(uws_h2_app_t* app) { delete (H2App*)app; } void uws_h2_app_on_schedule_drain(uws_h2_app_t* app, void (*cb)(void*, void*), void* user) { ((H2App*)app)->onScheduleDrain((void (*)(void*, uWS::Http2Context*))cb, user); } bool uws_h2_app_drain(uws_h2_app_t* app) { return ((H2App*)app)->drain(); } -void uws_h2_app_close(uws_h2_app_t* app) { ((H2App*)app)->close(); } void uws_h2_app_clear_routes(uws_h2_app_t* app) { ((H2App*)app)->clearRoutes(); } #define H2_ROUTE(name, method) \ @@ -87,7 +86,7 @@ void uws_h2_res_end_stream(uws_h2_res_t* res, bool close_connection) bool uws_h2_res_is_closed(uws_h2_res_t* res) { return ((Http2Response*)res)->dead; } -/* END_STREAM on the request HEADERS, or content-length: 0 (declaredContentLength is -1 when absent). */ +/* END_STREAM on the request HEADERS, or content-length: 0. */ bool uws_h2_res_request_body_ended(uws_h2_res_t* res) { Http2Response* r = (Http2Response*)res; @@ -108,11 +107,13 @@ bool uws_h2_res_try_end(uws_h2_res_t* res, const char* bytes, size_t len, size_t return ((Http2Response*)res)->tryEnd(h2sv(bytes, len), total_len, close).first; } -void uws_h2_res_end_without_body(uws_h2_res_t* res, bool close_connection) +bool uws_h2_res_accept_request_body(uws_h2_res_t* res) { return ((Http2Response*)res)->acceptRequestBody(); } + +void uws_h2_res_end_without_body(uws_h2_res_t* res) { Http2Response* r = (Http2Response*)res; r->clearOnWritableAndAborted(); - r->endWithoutBody(std::nullopt, close_connection); + r->endWithoutBody(); } void uws_h2_res_grow_request_window(uws_h2_res_t* res) { ((uWS::Http2Response*)res)->growReceiveWindow(); } @@ -136,17 +137,6 @@ void uws_h2_res_write_header_int(uws_h2_res_t* res, const char* key, size_t key_ ((Http2Response*)res)->writeHeader(h2sv(key, key_len), value); } -void uws_h2_res_mark_wrote_content_length_header(uws_h2_res_t* res) -{ - ((Http2Response*)res)->getHttpResponseData()->state |= Http2ResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER; -} - -void uws_h2_res_mark_wrote_date_header(uws_h2_res_t* res) -{ - ((Http2Response*)res)->getHttpResponseData()->state |= Http2ResponseData::HTTP_WROTE_DATE_HEADER; -} - -void uws_h2_res_write_mark(uws_h2_res_t* res) { ((Http2Response*)res)->writeMark(); } void uws_h2_res_flush_headers(uws_h2_res_t* res, bool) { ((Http2Response*)res)->flushHeaders(); } bool uws_h2_res_write(uws_h2_res_t* res, const char* data, size_t* length) diff --git a/src/uws_sys/libuwsockets_h3.cpp b/src/uws_sys/libuwsockets_h3.cpp index fa06529014b0..1e0fce8baa5b 100644 --- a/src/uws_sys/libuwsockets_h3.cpp +++ b/src/uws_sys/libuwsockets_h3.cpp @@ -35,30 +35,25 @@ typedef struct uws_h3_res_s uws_h3_res_t; typedef struct uws_h3_req_s uws_h3_req_t; typedef void (*uws_h3_method_handler)(uws_h3_res_t*, uws_h3_req_t*, void*); -typedef void (*uws_h3_listen_handler)(us_quic_listen_socket_t*, void*); +typedef void (*uws_h3_listen_handler)(us_quic_udp_t*, void*); /* ───── app ───── */ -uws_h3_app_t* uws_h3_create_app(struct us_bun_socket_context_options_t options, unsigned int idle_timeout_s) +uws_h3_app_t* uws_h3_create_app(struct us_bun_socket_context_options_t options, unsigned int idle_timeout_s, uint64_t max_request_body_size) { - static int once = (us_quic_global_init(), 1); - (void)once; - uWS::SocketContextOptions sco; - static_assert(sizeof(sco) == sizeof(options)); - memcpy(&sco, &options, sizeof(sco)); - return (uws_h3_app_t*)H3App::create(sco, idle_timeout_s); + return (uws_h3_app_t*)H3App::create(options, idle_timeout_s, max_request_body_size); } 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, struct us_bun_socket_context_options_t options) { - uWS::SocketContextOptions sco; - memcpy(&sco, &options, sizeof(sco)); - return ((H3App*)app)->addServerNameWithOptions(hostname, sco); + return ((H3App*)app)->addServerNameWithOptions(hostname, options); } #define H3_ROUTE(name, method) \ @@ -86,17 +81,17 @@ void uws_h3_app_listen_with_config(uws_h3_app_t* app, const char* host, uint16_t int32_t options, uws_h3_listen_handler handler, void* user_data) { std::string h = host && host[0] ? std::string(host) : std::string {}; - ((H3App*)app)->listen(h, port, options, [handler, user_data](us_quic_listen_socket_t* ls) { + ((H3App*)app)->listen(h, port, options, [handler, user_data](us_quic_udp_t* ls) { handler(ls, user_data); }); } -int uws_h3_listen_socket_port(us_quic_listen_socket_t* ls) { return us_quic_listen_socket_port(ls); } -int uws_h3_listen_socket_local_address(us_quic_listen_socket_t* ls, char* buf, int len) +int uws_h3_listen_socket_port(us_quic_udp_t* ls) { return us_quic_listen_socket_port(ls); } +int uws_h3_listen_socket_local_address(us_quic_udp_t* ls, char* buf, int len) { return us_quic_listen_socket_local_address(ls, buf, len); } -void uws_h3_listen_socket_close(us_quic_listen_socket_t* ls) { us_quic_listen_socket_close(ls); } +void uws_h3_listen_socket_close(us_quic_udp_t* ls) { us_quic_listen_socket_close(ls); } /* ───── response ───── */ @@ -116,10 +111,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((us_quic_stream_t*)res, US_QUIC_H3_INTERNAL_ERROR); } bool uws_h3_res_try_end(uws_h3_res_t* res, const char* bytes, size_t len, size_t total_len, bool close) @@ -127,11 +123,13 @@ bool uws_h3_res_try_end(uws_h3_res_t* res, const char* bytes, size_t len, size_t return ((Http3Response*)res)->tryEnd(sv(bytes, len), total_len, close).first; } -void uws_h3_res_end_without_body(uws_h3_res_t* res, bool close_connection) +bool uws_h3_res_accept_request_body(uws_h3_res_t* res) { return ((Http3Response*)res)->acceptRequestBody(); } + +void uws_h3_res_end_without_body(uws_h3_res_t* res) { Http3Response* r = (Http3Response*)res; r->clearOnWritableAndAborted(); - r->endWithoutBody(std::nullopt, close_connection); + r->endWithoutBody(); } void uws_h3_res_pause(uws_h3_res_t* res) { ((Http3Response*)res)->pause(); } @@ -154,17 +152,6 @@ void uws_h3_res_write_header_int(uws_h3_res_t* res, const char* key, size_t key_ ((Http3Response*)res)->writeHeader(sv(key, key_len), value); } -void uws_h3_res_mark_wrote_content_length_header(uws_h3_res_t* res) -{ - ((Http3Response*)res)->getHttpResponseData()->state |= Http3ResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER; -} - -void uws_h3_res_mark_wrote_date_header(uws_h3_res_t* res) -{ - ((Http3Response*)res)->getHttpResponseData()->state |= Http3ResponseData::HTTP_WROTE_DATE_HEADER; -} - -void uws_h3_res_write_mark(uws_h3_res_t* res) { ((Http3Response*)res)->writeMark(); } void uws_h3_res_flush_headers(uws_h3_res_t* res, bool) { ((Http3Response*)res)->flushHeaders(); } bool uws_h3_res_write(uws_h3_res_t* res, const char* data, size_t* length) @@ -268,6 +255,8 @@ size_t uws_h3_req_get_method(uws_h3_req_t* req, const char** dest) return ffi_sv(((Http3Request*)req)->getMethod(), dest); } +uint64_t uws_h3_req_get_content_length(uws_h3_req_t* req) { return ((Http3Request*)req)->getContentLength(); } + size_t uws_h3_req_get_header(uws_h3_req_t* req, const char* lower, size_t lower_len, const char** dest) { return ffi_sv(((Http3Request*)req)->getHeader(sv(lower, lower_len)), dest); diff --git a/src/uws_sys/quic.rs b/src/uws_sys/quic.rs index 53adc9c8ae67..085ecc635e31 100644 --- a/src/uws_sys/quic.rs +++ b/src/uws_sys/quic.rs @@ -10,6 +10,8 @@ #[path = "quic/Context.rs"] pub mod context; +#[path = "quic/Driver.rs"] +pub mod driver; #[path = "quic/Header.rs"] pub mod header; #[path = "quic/PendingConnect.rs"] @@ -20,19 +22,10 @@ pub mod socket; pub mod stream; pub use self::context::Context; +pub use self::driver::{Driver, PassPoint, Udp}; pub use self::pending_connect::PendingConnect; pub use self::socket::Socket; 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..da4736318f40 100644 --- a/src/uws_sys/quic/Context.rs +++ b/src/uws_sys/quic/Context.rs @@ -2,10 +2,11 @@ //! For the client there is exactly one of these per HTTP-thread loop and it //! lives for the process; the server creates one per `Bun.serve({http3:true})`. -use core::ffi::{CStr, c_char, c_int, c_uint, c_void}; +use core::ffi::{CStr, c_char, c_int, c_uint}; use crate::Loop; use crate::quic::{PendingConnect, Socket, Stream}; +use bun_boringssl_sys::{OwnedSslCtx, SSL_CTX}; bun_opaque::opaque_ffi! { pub struct Context; } @@ -29,11 +30,13 @@ unsafe extern "C" { port: c_int, sni: *const c_char, reject_unauthorized: c_int, + ssl_ctx: *mut SSL_CTX, out_qs: *mut *mut Socket, out_pending: *mut *mut PendingConnect, - user: *mut c_void, ) -> c_int; + fn us_quic_prepare_client_ssl_ctx(ssl_ctx: *mut SSL_CTX); + safe fn us_quic_socket_context_on_hsk_done( ctx: &mut Context, cb: unsafe extern "C" fn(*mut Socket, c_int), @@ -77,6 +80,12 @@ pub enum ConnectResult { Err, } +/// Prepares a context for [`Context::connect`]: TLS 1.3 only, and the verify step that reads each connection's `reject_unauthorized`. +pub fn prepare_client_ssl_ctx(ssl_ctx: &OwnedSslCtx) { + // SAFETY: `ssl_ctx` is a live SSL_CTX. + unsafe { us_quic_prepare_client_ssl_ctx(ssl_ctx.as_ptr()) } +} + impl Context { /// # Safety /// `loop_` must point to a live `us_loop_t`. Takes a raw pointer (not `&mut Loop`) @@ -110,11 +119,11 @@ impl Context { port: u16, sni: &CStr, reject_unauthorized: bool, - user: *mut c_void, + ssl_ctx: Option<&OwnedSslCtx>, ) -> 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 takes its own reference to `ssl_ctx`. let rc = unsafe { us_quic_socket_context_connect( self, @@ -122,9 +131,9 @@ impl Context { c_int::from(port), sni.as_ptr(), reject_unauthorized as c_int, + ssl_ctx.map_or(core::ptr::null_mut(), OwnedSslCtx::as_ptr), &raw mut qs, &raw mut pc, - user, ) }; match rc { diff --git a/src/uws_sys/quic/Driver.rs b/src/uws_sys/quic/Driver.rs new file mode 100644 index 000000000000..b79f71e00a18 --- /dev/null +++ b/src/uws_sys/quic/Driver.rs @@ -0,0 +1,186 @@ +//! `us_quic_driver_t` and `us_quic_udp_t`, which every lsquic engine in Bun runs on. The HTTP/3 contexts use them from C, `node:quic` from here. + +use core::ffi::{c_char, c_int, c_void}; + +use bun_boringssl_sys::SSL_CTX; + +use crate::{Loop, udp}; + +bun_opaque::opaque_ffi! { + /// Owns lsquic engines and their UDP sockets, sends their packets and runs them from the loop. + pub struct Driver; + /// A driver's UDP socket. Valid until `Driver::close`. + pub struct Udp; +} + +/// `enum us_quic_pass_point`. +#[repr(C)] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PassPoint { + BeforePoll, + /// The loop has dispatched what the poll returned. + AfterPoll, + AfterMicrotasks, +} + +/// Runs the owner's engines if they have work. Returns the µs until they want the next pass, or -1. It may free the driver. +pub type Pass = unsafe extern "C" fn(driver: *mut Driver, point: PassPoint) -> c_int; + +unsafe extern "C" { + fn us_quic_driver_create(loop_: *mut Loop, pass: Pass, owner: *mut c_void) -> *mut Driver; + safe fn us_quic_driver_close(driver: &Driver); + fn us_quic_driver_free(driver: *mut Driver); + safe fn us_quic_driver_owner(driver: &Driver) -> *mut c_void; + safe fn us_quic_driver_process(driver: &Driver); + safe fn us_quic_driver_next_tick_us(driver: &Driver) -> c_int; + safe fn us_quic_driver_set_pending(driver: &Driver); + safe fn us_quic_driver_is_pending(driver: &Driver) -> c_int; + fn us_quic_driver_set_ssl_ctx(driver: *mut Driver, ssl_ctx: *mut SSL_CTX); + fn us_quic_udp_create( + driver: *mut Driver, + host: *const c_char, + port: c_int, + flags: c_int, + err: *mut c_int, + on_data: extern "C" fn(*mut udp::Socket, *mut udp::PacketBuffer, c_int), + on_recv_error: extern "C" fn(*mut udp::Socket, c_int, c_int), + ) -> *mut Udp; + safe fn us_quic_udp_owner(udp: &Udp) -> *mut c_void; + safe fn us_quic_udp_socket(udp: &Udp) -> *mut udp::Socket; + safe fn us_quic_udp_local(udp: &Udp) -> *const c_void; + fn us_quic_udp_count_sent(udp: *mut Udp, packets: *mut u64, bytes: *mut u64); + fn us_quic_udp_connect( + udp: *mut Udp, + engine: *mut c_void, + peer: *const c_void, + ssl_ctx: *mut SSL_CTX, + conn_ctx: *mut c_void, + sni: *const c_char, + session_resume: *const u8, + session_resume_len: usize, + ) -> *mut c_void; +} + +impl Driver { + /// `pass` runs before and after each poll of `loop_` (this thread's), and after a microtask drain while the driver is pending. `owner` must outlive the driver. + pub unsafe fn create(loop_: *mut Loop, pass: Pass, owner: *mut c_void) -> *mut Driver { + // SAFETY: caller contract. + unsafe { us_quic_driver_create(loop_, pass, owner) } + } + + /// Closes the UDP sockets and destroys the engines. `pass` keeps running. + pub fn close(&self) { + us_quic_driver_close(self) + } + + /// `this` came from `Driver::create` and isn't used again. + pub unsafe fn free(this: *mut Driver) { + // SAFETY: caller contract. + unsafe { us_quic_driver_free(this) } + } + + pub fn owner(&self) -> *mut c_void { + us_quic_driver_owner(self) + } + + /// `lsquic_engine_process_conns` on every engine. Does nothing while an engine is on the stack. + pub fn process(&self) { + us_quic_driver_process(self) + } + + /// The µs until an engine wants the next `process`, or -1. + pub fn next_tick_us(&self) -> c_int { + us_quic_driver_next_tick_us(self) + } + + /// The owner queued work in an engine. `process` clears it. + pub fn set_pending(&self) { + us_quic_driver_set_pending(self) + } + + pub fn is_pending(&self) -> bool { + us_quic_driver_is_pending(self) != 0 + } + + /// What a server engine starts a handshake with, before SNI. `ssl_ctx` must outlive the engines. + pub unsafe fn set_ssl_ctx(&self, ssl_ctx: *mut SSL_CTX) { + // SAFETY: caller contract. + unsafe { us_quic_driver_set_ssl_ctx(self.as_mut_ptr(), ssl_ctx) } + } + + /// Binds a UDP socket to the NUL-terminated `host`. Its user data is the returned `Udp`. Null on failure, with the errno in `err`. + pub unsafe fn create_udp( + &self, + host: *const c_char, + port: u16, + flags: c_int, + err: &mut c_int, + on_data: extern "C" fn(*mut udp::Socket, *mut udp::PacketBuffer, c_int), + on_recv_error: extern "C" fn(*mut udp::Socket, c_int, c_int), + ) -> *mut Udp { + // SAFETY: caller contract. `err` is a valid out-param. + unsafe { + us_quic_udp_create( + self.as_mut_ptr(), + host, + c_int::from(port), + flags, + err, + on_data, + on_recv_error, + ) + } + } +} + +impl Udp { + /// The `owner` of this socket's driver. + pub fn owner(&self) -> *mut c_void { + us_quic_udp_owner(self) + } + + /// Null once the fd has closed. + pub fn socket(&self) -> *mut udp::Socket { + us_quic_udp_socket(self) + } + + /// The bound `sockaddr`, which is the `sa_local` of every `lsquic_engine_packet_in` call for this socket. + pub fn local(&self) -> *const c_void { + us_quic_udp_local(self) + } + + /// Adds every datagram this socket sends to the two counters, which must stay valid until `Driver::close`. + pub unsafe fn count_sent(&self, packets: *mut u64, bytes: *mut u64) { + // SAFETY: caller contract. + unsafe { us_quic_udp_count_sent(self.as_mut_ptr(), packets, bytes) } + } + + /// `lsquic_engine_connect` on `engine`, one of the driver's, from this socket. Returns the `lsquic_conn_t`, or null. `sni` is null or NUL-terminated. + pub unsafe fn connect( + &self, + engine: *mut c_void, + peer: *const c_void, + ssl_ctx: *mut SSL_CTX, + conn_ctx: *mut c_void, + sni: *const c_char, + session_resume: &[u8], + ) -> *mut c_void { + // SAFETY: caller contract. lsquic copies `session_resume`. + unsafe { + us_quic_udp_connect( + self.as_mut_ptr(), + engine, + peer, + ssl_ctx, + conn_ctx, + sni, + if session_resume.is_empty() { + core::ptr::null() + } else { + session_resume.as_ptr() + }, + session_resume.len(), + ) + } + } +} diff --git a/src/uws_sys/quic/Socket.rs b/src/uws_sys/quic/Socket.rs index e0ea68dfc6fb..1f6f4ae02d1f 100644 --- a/src/uws_sys/quic/Socket.rs +++ b/src/uws_sys/quic/Socket.rs @@ -59,7 +59,7 @@ impl Socket { } /// `conn_ext_size` bytes of caller storage co-allocated with the socket. - /// Unset until the caller writes to it after `connect`/`on_open`; the + /// Unset until the caller writes to it after `connect`; the /// `Option>` slot pattern lets callbacks early-return on a null ext. #[inline] pub fn ext(&mut self) -> &mut Option> { diff --git a/src/uws_sys/quic/Stream.rs b/src/uws_sys/quic/Stream.rs index 969295d7de6a..2730cbd17f9c 100644 --- a/src/uws_sys/quic/Stream.rs +++ b/src/uws_sys/quic/Stream.rs @@ -19,16 +19,17 @@ unsafe extern "C" { safe fn us_quic_stream_socket(s: &mut Stream) -> *mut Socket; safe fn us_quic_stream_shutdown(s: &mut Stream); safe fn us_quic_stream_close(s: &mut Stream); - safe fn us_quic_stream_reset(s: &mut Stream); + safe fn us_quic_stream_reset(s: &mut Stream, error_code: u64); 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) -> 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, @@ -55,8 +56,9 @@ impl Stream { us_quic_stream_close(self) } + /// RESET_STREAM with H3_REQUEST_CANCELLED. pub fn reset(&mut self) { - us_quic_stream_reset(self) + us_quic_stream_reset(self, 0x10C) } /// The code of the last RESET_STREAM or STOP_SENDING on the stream, 0 without one. @@ -64,6 +66,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 @@ -102,12 +109,13 @@ impl Stream { } } - pub fn want_write(&mut self, want: bool) { - us_quic_stream_want_write(self, want as c_int) + pub fn want_write(&mut self) { + us_quic_stream_want_write(self); } - 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..efaef8df57c1 100644 --- a/src/uws_sys/socket.rs +++ b/src/uws_sys/socket.rs @@ -1,28 +1,23 @@ //! High-level socket wrapper over `us_socket_t` / `ConnectingSocket` / -//! `UpgradedDuplex` / `WindowsNamedPipe`. +//! [`Transport`]. //! //! THIS IS THE ONE CANONICAL IMPLEMENTATION. `bun_uws` //! re-exports everything here; do NOT add a parallel `InternalSocket` / //! `NewSocketHandler` in `bun_uws` again — the earlier "thin placeholder" //! that grew full bodies there has been deleted. //! -//! Shape: `InternalSocket` is a `Copy` tagged raw pointer (passed by value -//! through the entire HTTP-client state machine), all -//! `NewSocketHandler` methods take `&self`, and the `#[cfg(windows)]` Pipe -//! split is owned exactly once by the `on_socket!` macro below. +//! Shape: `InternalSocket` is a `Copy` tagged raw pointer, passed by value, and all `NewSocketHandler` methods take `&self`. +use core::cell::Cell; use core::ffi::{c_int, c_uint, c_void}; use core::mem::size_of; use core::ptr::NonNull; use bun_core::{Fd, ZStr}; -#[cfg(windows)] -use crate::WindowsNamedPipe; use crate::{ - CloseCode, ConnectResult, ConnectingSocket, LIBUS_SOCKET_ALLOW_HALF_OPEN, - LIBUS_SOCKET_DESCRIPTOR, QueuedInput, SocketGroup, SocketKind, SslCtx, UpgradedDuplex, - us_bun_verify_error_t, us_socket_t, + CloseCode, ConnectResult, ConnectingSocket, LIBUS_SOCKET_DESCRIPTOR, QueuedInput, SocketGroup, + SocketKind, SslCtx, us_bun_verify_error_t, us_socket_t, }; bun_core::declare_scope!(uws, visible); @@ -48,18 +43,62 @@ impl CloseCode { /// State of a single connection. `Copy` — passed by value /// through the entire HTTP-client / Bun.Socket state machines, so the handle -/// is a trivially-copyable tagged pointer. The `UpgradedDuplex` / `Pipe` -/// payloads are raw `*mut` (NOT `&mut`): they are stored in long-lived -/// `Cell` fields and re-borrowed per call via the opaque -/// deref helpers below. +/// is a trivially-copyable tagged pointer, re-borrowed per call via the deref helpers below. #[derive(Copy, Clone)] pub enum InternalSocket { Connected(*mut us_socket_t), + /// A node:http server connection while uWS still owes HTTP on it. Its group, kind and ext are uWS's. + UwsHttp(*mut us_socket_t), Connecting(*mut ConnectingSocket), Detached, - UpgradedDuplex(*mut UpgradedDuplex), - #[cfg(windows)] - Pipe(*mut WindowsNamedPipe), + Transport(*const TransportHandle), +} + +const _: () = assert!(size_of::() == 2 * size_of::()); + +/// A field of every [`Transport`]: the wide pointer to the transport it sits in. A socket handle points here, which keeps it at two words. +#[derive(Default)] +pub struct TransportHandle(Cell>>); + +impl TransportHandle { + /// `transport` is the heap allocation that holds `self`. + pub fn set(&self, transport: *mut dyn Transport) { + self.0.set(NonNull::new(transport)); + } +} + +/// What a socket handle drives when it isn't a `us_socket_t`: TLS over a JS stream, a Windows named pipe, a pipe descriptor. `bun_runtime::socket` has the implementors. +pub trait Transport { + fn is_closed(&self) -> bool; + fn is_shutdown(&self) -> bool; + /// Returns the bytes taken and the errno of a write that failed for good. Fewer bytes with no errno owe the owner one `on_writable`. + fn write(&self, data: &[u8]) -> (i32, i32); + /// Writes below the TLS layer, if there is one. + fn raw_write(&self, data: &[u8]) -> i32; + fn shutdown(&self); + fn close(&self); + fn pause_stream(&self) -> bool; + fn resume_stream(&self) -> bool; + fn shutdown_read(&self) {} + fn flush(&self) {} + fn set_timeout(&self, _seconds: c_uint) {} + fn ssl(&self) -> Option<*mut bun_boringssl_sys::SSL> { + None + } + fn fd(&self) -> Fd { + Fd::INVALID + } + /// See [`NewSocketHandler::reject_send`]. The close comes from the event loop, never from inside this call. + fn reject_send(&self, _error: i32) {} + fn rejected_send_error(&self) -> i32 { + 0 + } + /// See [`NewSocketHandler::prepare_for_finalize`]. + fn abandon_js_side(&self) {} + /// Whether a write runs user JS before it returns. + fn write_runs_js(&self) -> bool { + false + } } // Variant + pointer-identity equality. @@ -67,13 +106,10 @@ impl PartialEq for InternalSocket { fn eq(&self, other: &Self) -> bool { match (*self, *other) { (InternalSocket::Connected(a), InternalSocket::Connected(b)) => core::ptr::eq(a, b), + (InternalSocket::UwsHttp(a), InternalSocket::UwsHttp(b)) => core::ptr::eq(a, b), (InternalSocket::Connecting(a), InternalSocket::Connecting(b)) => core::ptr::eq(a, b), (InternalSocket::Detached, InternalSocket::Detached) => true, - (InternalSocket::UpgradedDuplex(a), InternalSocket::UpgradedDuplex(b)) => { - core::ptr::eq(a, b) - } - #[cfg(windows)] - (InternalSocket::Pipe(a), InternalSocket::Pipe(b)) => core::ptr::eq(a, b), + (InternalSocket::Transport(a), InternalSocket::Transport(b)) => core::ptr::eq(a, b), _ => false, } } @@ -92,21 +128,14 @@ impl InternalSocket { pub fn is_detached(&self) -> bool { matches!(self, InternalSocket::Detached) } - #[inline] - pub(crate) fn is_named_pipe(&self) -> bool { - #[cfg(windows)] - return matches!(self, InternalSocket::Pipe(_)); - #[cfg(not(windows))] - return false; - } } // ── Safe deref helpers for `InternalSocket` payloads ──────────────────────── -// All four payload types are `#[repr(C)] UnsafeCell<[u8; 0]>` opaque ZSTs +// Both uSockets payload types are `#[repr(C)] UnsafeCell<[u8; 0]>` opaque ZSTs // (`opaque_ffi!` / `opaque_extern!`): zero-sized, align-1, no `noalias` / // `readonly`. Materializing `&mut T` from `*mut T` therefore has exactly one // validity requirement — non-null — which uSockets guarantees for every -// pointer it stores in `Connected` / `Connecting` / `UpgradedDuplex` / `Pipe`. +// pointer it stores in `Connected` / `Connecting`. // Centralizing the deref keeps the proof local instead of repeating // `unsafe { &mut *s }` at ~50 match arms. @@ -120,56 +149,75 @@ fn sock<'a>(p: *mut us_socket_t) -> &'a mut us_socket_t { fn conn<'a>(p: *mut ConnectingSocket) -> &'a mut ConnectingSocket { bun_opaque::opaque_deref_mut(p) } -/// Reborrow the `InternalSocket::UpgradedDuplex` payload (cycle-break shim). +/// Reborrow the `InternalSocket::Transport` payload. #[inline(always)] -fn duplex<'a>(p: *mut UpgradedDuplex) -> &'a mut UpgradedDuplex { - bun_opaque::opaque_deref_mut(p) +fn transport<'a>(p: *const TransportHandle) -> &'a dyn Transport { + // SAFETY: a transport sets its handle before it gives it out, and detaches its owner before it frees itself. + unsafe { (*p).0.get().unwrap_unchecked().as_ref() } } -/// Reborrow the `InternalSocket::Pipe` payload (Windows only). -#[cfg(windows)] -#[inline(always)] -fn pipe<'a>(p: *mut WindowsNamedPipe) -> &'a mut WindowsNamedPipe { - bun_opaque::opaque_deref_mut(p) + +/// `InternalSocket::UwsHttp`: a write queues behind the HTTP response in uWS's buffer, and the FIN and the close wait for that buffer. +mod uws_http { + use super::us_socket_t; + unsafe extern "C" { + fn Bun__NodeHTTPServerSocket__write( + s: *mut us_socket_t, + ssl: bool, + data: *const u8, + len: usize, + ); + pub(super) safe fn Bun__NodeHTTPServerSocket__shutdown(s: &mut us_socket_t, ssl: bool); + pub(super) safe fn Bun__NodeHTTPServerSocket__close( + s: &mut us_socket_t, + ssl: bool, + code: i32, + ); + pub(super) safe fn Bun__NodeHTTPServerSocket__setReadsPaused( + s: &mut us_socket_t, + ssl: bool, + paused: bool, + ); + pub(super) safe fn Bun__NodeHTTPServerSocket__hasUnsentBytes( + s: &mut us_socket_t, + ssl: bool, + ) -> bool; + } + pub(super) fn write(s: *mut us_socket_t, ssl: bool, data: &[u8]) -> i32 { + let taken = data.len().min(i32::MAX as usize); + // SAFETY: `s` is the live socket of an `InternalSocket::UwsHttp`, and `data` is valid for `taken` bytes. + unsafe { Bun__NodeHTTPServerSocket__write(s, ssl, data.as_ptr(), taken) }; + taken as i32 + } } -/// Five-arm `match self.socket` with the `#[cfg(windows)]` Pipe split owned -/// exactly once. Each arm binds the deref'd opaque (`$s` / `$c` / `$d` / `$p`) -/// so method bodies are one-liners and the per-arm `#[cfg]` noise lives here. -/// -/// Arms not supplied default to: `connecting`/`detached` → `$det` expr; -/// `pipe` → `$det` on non-Windows (no payload), supplied body on Windows. +/// `match self.socket` with each arm bound to the deref'd payload, so method bodies are one-liners. macro_rules! on_socket { ( $sock:expr; connected $s:ident => $conn:expr, connecting $c:ident => $cing:expr, detached => $det:expr, - duplex $d:ident => $dup:expr, - pipe $p:ident => $pip:expr $(,)? + transport $t:ident => $tra:expr $(,)? ) => { match $sock { - InternalSocket::Connected(__s) => { let $s = sock(__s); $conn } + InternalSocket::Connected(__s) | InternalSocket::UwsHttp(__s) => { let $s = sock(__s); $conn } InternalSocket::Connecting(__c) => { let $c = conn(__c); $cing } InternalSocket::Detached => $det, - InternalSocket::UpgradedDuplex(__d) => { let $d = duplex(__d); $dup } - #[cfg(windows)] - InternalSocket::Pipe(__p) => { let $p = pipe(__p); $pip } + InternalSocket::Transport(__t) => { let $t = transport(__t); $tra } } }; - // Short form: connecting/detached/pipe-absent collapse to one default. + // Short form: connecting/detached collapse to one default. ( $sock:expr; connected $s:ident => $conn:expr, - duplex $d:ident => $dup:expr, - pipe $p:ident => $pip:expr, + transport $t:ident => $tra:expr, else => $det:expr $(,)? ) => { on_socket!($sock; connected $s => $conn, connecting _c => { let _ = _c; $det }, detached => $det, - duplex $d => $dup, - pipe $p => $pip, + transport $t => $tra, ) }; } @@ -239,20 +287,25 @@ impl NewSocketHandler { pub fn is_detached(&self) -> bool { self.socket.is_detached() } + /// No `us_socket_t`, so no descriptor that TLS could adopt. #[inline] - pub fn is_named_pipe(&self) -> bool { - self.socket.is_named_pipe() + pub fn is_transport(&self) -> bool { + matches!(self.socket, InternalSocket::Transport(_)) + } + pub fn write_runs_js(&self) -> bool { + matches!(self.socket, InternalSocket::Transport(t) if transport(t).write_runs_js()) } // ── state queries ─────────────────────────────────────────────────────── - /// Write that also reports a fatal write error as a positive errno (0 = - /// none); non-Connected sockets fall back to the plain write (no fatal signal). + /// Write that also reports a fatal write error as a positive errno (0 = none). pub fn write_check_error(&self, data: &[u8]) -> (i32, i32) { + if let InternalSocket::UwsHttp(s) = self.socket { + return (uws_http::write(s, IS_SSL, data), 0); + } on_socket!(self.socket; connected s => s.write_check_error(data), - duplex d => (d.encode_and_write(data), 0), - pipe p => (p.encode_and_write(data), 0), + transport t => t.write(data), else => (0, 0), ) } @@ -262,8 +315,7 @@ impl NewSocketHandler { connected s => s.is_closed(), connecting c => c.is_closed(), detached => true, - duplex d => d.is_closed(), - pipe p => p.is_closed(), + transport t => t.is_closed(), ) } @@ -272,16 +324,14 @@ impl NewSocketHandler { connected s => s.is_shutdown(), connecting c => c.is_shutdown(), detached => true, - duplex d => d.is_shutdown(), - pipe p => p.is_shutdown(), + transport t => t.is_shutdown(), ) } pub fn is_established(&self) -> bool { on_socket!(self.socket; connected s => s.is_established(), - duplex d => d.is_established(), - pipe p => p.is_established(), + transport t => !t.is_closed(), else => false, ) } @@ -291,14 +341,38 @@ impl NewSocketHandler { self.is_closed() || self.is_shutdown() || self.get_error() != 0 } - /// 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 { + /// Returns true if bytes that `write` already counted as written still wait in user space for the kernel: TLS records, or all that uWS buffers for a node:http connection. + pub fn ssl_spill_pending(&self) -> bool { + if let InternalSocket::UwsHttp(s) = self.socket { + return uws_http::Bun__NodeHTTPServerSocket__hasUnsentBytes(sock(s), IS_SSL); + } + self.ssl_spill_len() > 0 + } + + /// How many bytes of sealed TLS records wait in user space for the kernel. + pub fn ssl_spill_len(&self) -> u32 { on_socket!(self.socket; - connected s => s.ssl_send_rejected(), - duplex _d => false, - pipe _p => false, - else => false, + connected s => s.ssl_spill_pending(), + transport _t => 0, + else => 0, + ) + } + + /// Gives up on a send that `write_check_error` failed with `error`: no more writes, and once the peer's bytes are read the loop closes the socket. `rejected_send_error()` answers `error` from then on. + pub fn reject_send(&self, error: i32) { + on_socket!(self.socket; + connected s => s.reject_send(error), + transport t => t.reject_send(error), + else => {}, + ) + } + + /// The error of a rejected send, in usockets' numbering, or 0. + pub fn rejected_send_error(&self) -> i32 { + on_socket!(self.socket; + connected s => s.rejected_send_error(), + transport t => t.rejected_send_error(), + else => 0, ) } @@ -307,19 +381,16 @@ impl NewSocketHandler { pub fn queued_input(&self) -> QueuedInput { on_socket!(self.socket; connected s => s.queued_input(), - duplex _d => QueuedInput::None, - pipe _p => QueuedInput::None, + transport _t => QueuedInput::None, else => QueuedInput::None, ) } pub fn get_verify_error(&self) -> us_bun_verify_error_t { - on_socket!(self.socket; - connected s => s.get_verify_error(), - duplex d => d.ssl_error(), - pipe p => p.ssl_error(), - else => us_bun_verify_error_t::default(), - ) + match self.ssl_mut() { + Some(ssl) if !self.is_closed() => crate::ssl::us_ssl_socket_verify_error_from_ssl(ssl), + _ => us_bun_verify_error_t::default(), + } } pub fn get_error(&self) -> i32 { @@ -327,8 +398,7 @@ impl NewSocketHandler { connected s => s.get_error(), connecting c => c.get_error(), detached => 0, - duplex d => d.ssl_error().error_no, - pipe p => p.ssl_error().error_no, + transport _t => 0, ) } @@ -346,12 +416,14 @@ impl NewSocketHandler { // ── lifecycle ─────────────────────────────────────────────────────────── pub fn close(&self, code: CloseCode) { + if let InternalSocket::UwsHttp(s) = self.socket { + return uws_http::Bun__NodeHTTPServerSocket__close(sock(s), IS_SSL, code as i32); + } on_socket!(self.socket; connected s => s.close(code), connecting c => c.close(), detached => {}, - duplex d => d.close(), - pipe p => p.close(), + transport t => t.close(), ) } @@ -370,18 +442,19 @@ impl NewSocketHandler { connected _s => {}, connecting _c => {}, detached => {}, - duplex d => d.abandon_js_side(), - pipe _p => {}, + transport t => t.abandon_js_side(), ) } pub fn shutdown(&self) { + if let InternalSocket::UwsHttp(s) = self.socket { + return uws_http::Bun__NodeHTTPServerSocket__shutdown(sock(s), IS_SSL); + } on_socket!(self.socket; connected s => s.shutdown(), connecting c => c.shutdown(), detached => {}, - duplex d => d.shutdown(), - pipe p => p.shutdown(), + transport t => t.shutdown(), ) } @@ -390,18 +463,19 @@ impl NewSocketHandler { connected s => s.shutdown_read(), connecting c => c.shutdown_read(), detached => {}, - duplex d => d.shutdown_read(), - pipe p => p.shutdown_read(), + transport t => t.shutdown_read(), ) } // ── I/O ───────────────────────────────────────────────────────────────── pub fn write(&self, data: &[u8]) -> i32 { + if let InternalSocket::UwsHttp(s) = self.socket { + return uws_http::write(s, IS_SSL, data); + } on_socket!(self.socket; connected s => s.write(data), - duplex d => d.encode_and_write(data), - pipe p => p.encode_and_write(data), + transport t => t.write(data).0, else => 0, ) } @@ -418,35 +492,24 @@ impl NewSocketHandler { InternalSocket::Connected(s) => { sock(s).write_fd(data, Fd::from_native(file_descriptor)) } - // A duplex falls back to a plain write (the fd is silently dropped). - InternalSocket::UpgradedDuplex(_) => self.write(data), + // Falls back to a plain write (the fd is silently dropped). + InternalSocket::Transport(_) | InternalSocket::UwsHttp(_) => self.write(data), InternalSocket::Connecting(_) | InternalSocket::Detached => 0, } } /// Vectored raw write: one writev on real sockets; sequential raw writes on - /// transports without an fd (duplex/pipe). Plain-TCP callers only — raw + /// a [`Transport`]. Plain-TCP callers only — raw /// writes bypass TLS framing. pub fn raw_writev(&self, iov: &[crate::UsIoVec]) -> i32 { on_socket!(self.socket; connected s => s.raw_writev(iov), - duplex d => { + transport t => { let mut total: i32 = 0; for v in iov { // SAFETY: UsIoVec contract — base/len reference caller-owned memory let slice = unsafe { core::slice::from_raw_parts(v.base.cast::(), v.len) }; - let w = d.raw_write(slice); - if w > 0 { total += w; } - if w < slice.len() as i32 { break; } - } - total - }, - pipe p => { - let mut total: i32 = 0; - for v in iov { - // SAFETY: UsIoVec contract — base/len reference caller-owned memory - let slice = unsafe { core::slice::from_raw_parts(v.base.cast::(), v.len) }; - let w = p.raw_write(slice); + let w = t.raw_write(slice); if w > 0 { total += w; } if w < slice.len() as i32 { break; } } @@ -460,8 +523,7 @@ impl NewSocketHandler { pub fn raw_write(&self, data: &[u8]) -> i32 { on_socket!(self.socket; connected s => s.raw_write(data), - duplex d => d.raw_write(data), - pipe p => p.raw_write(data), + transport t => t.raw_write(data), else => 0, ) } @@ -469,34 +531,24 @@ impl NewSocketHandler { pub fn flush(&self) { on_socket!(self.socket; connected s => s.flush(), - duplex d => d.flush(), - pipe p => p.flush(), + transport t => t.flush(), else => {}, ) } // ── timeouts ──────────────────────────────────────────────────────────── - /// Splits >240s onto the minute-granularity long-timeout wheel. + /// Fires `on_timeout` once, at the first 4 s sweep after `seconds` have passed. 0 disarms. pub fn set_timeout(&self, seconds: c_uint) { + // The timer of a connection that uWS owns is uWS's. + if matches!(self.socket, InternalSocket::UwsHttp(_)) { + return; + } on_socket!(self.socket; - connected s => if seconds > 240 { - s.set_timeout(0); - s.set_long_timeout(seconds / 60); - } else { - s.set_timeout(seconds); - s.set_long_timeout(0); - }, - connecting c => if seconds > 240 { - c.timeout(0); - c.long_timeout(seconds / 60); - } else { - c.timeout(seconds); - c.long_timeout(0); - }, + connected s => s.set_timeout(seconds), + connecting c => c.timeout(seconds), detached => {}, - duplex d => d.set_timeout(seconds), - pipe p => p.set_timeout(seconds), + transport t => t.set_timeout(seconds), ) } @@ -506,28 +558,34 @@ impl NewSocketHandler { /// `connecting` arm): the open re-arms reads, so latching a pause here /// would only make the next real `pause()` a no-op. pub fn pause_stream(&self) -> bool { + if let InternalSocket::UwsHttp(s) = self.socket { + uws_http::Bun__NodeHTTPServerSocket__setReadsPaused(sock(s), IS_SSL, true); + return true; + } on_socket!(self.socket; connected s => if s.is_established() { s.pause(); true } else { false }, connecting _c => false, detached => true, - duplex d => d.pause_stream(), - pipe p => p.pause_stream(), + transport t => t.pause_stream(), ) } pub fn resume_stream(&self) -> bool { + if let InternalSocket::UwsHttp(s) = self.socket { + uws_http::Bun__NodeHTTPServerSocket__setReadsPaused(sock(s), IS_SSL, false); + return true; + } on_socket!(self.socket; connected s => if s.is_established() { s.resume(); true } else { false }, connecting _c => false, detached => true, - duplex d => d.resume_stream(), - pipe p => p.resume_stream(), + transport t => t.resume_stream(), ) } pub fn set_no_delay(&self, enabled: bool) -> bool { match self.socket { - InternalSocket::Connected(s) => { + InternalSocket::Connected(s) | InternalSocket::UwsHttp(s) => { sock(s).set_nodelay(enabled); true } @@ -537,7 +595,9 @@ impl NewSocketHandler { pub fn set_keep_alive(&self, enabled: bool, delay: u32) -> bool { match self.socket { - InternalSocket::Connected(s) => sock(s).set_keepalive(enabled, delay) == 0, + InternalSocket::Connected(s) | InternalSocket::UwsHttp(s) => { + sock(s).set_keepalive(enabled, delay) == 0 + } _ => false, } } @@ -547,7 +607,7 @@ impl NewSocketHandler { /// the way Node's no-handle fallback does. pub fn set_tos(&self, tos: i32) -> i32 { match self.socket { - InternalSocket::Connected(s) => sock(s).set_tos(tos), + InternalSocket::Connected(s) | InternalSocket::UwsHttp(s) => sock(s).set_tos(tos), _ => -9, } } @@ -555,24 +615,25 @@ impl NewSocketHandler { /// Get the IP type-of-service (>= 0) or a negative errno. pub fn get_tos(&self) -> i32 { match self.socket { - InternalSocket::Connected(s) => sock(s).get_tos(), + InternalSocket::Connected(s) | InternalSocket::UwsHttp(s) => sock(s).get_tos(), _ => -9, } } /// 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) }; - } - } + if let InternalSocket::Connected(s) | InternalSocket::UwsHttp(s) = self.socket { + return sock(s).sni_resolve(ctx, error); + } + let Some(ssl) = self.ssl_mut() else { + // SAFETY: the caller passed an owned SSL_CTX reference, or null. + return unsafe { bun_boringssl_sys::SSL_CTX_free(ctx) }; + }; + // SAFETY: `ssl` is live, and the callee takes the reference `ctx` carries. + if unsafe { crate::ssl::us_ssl_sni_resolve(ssl, ctx, c_int::from(error)) } != 0 { + // Drives the handshake again. + self.flush(); } } @@ -581,12 +642,8 @@ impl NewSocketHandler { /// Refuse a bad server chain during the handshake, before the client /// certificate goes out. Client-only; call it before the handshake is driven. pub fn set_inline_reject(&self) { - match self.socket { - InternalSocket::Connected(s) => sock(s).set_inline_reject(), - InternalSocket::UpgradedDuplex(d) => duplex(d).set_inline_reject(), - #[cfg(windows)] - InternalSocket::Pipe(p) => pipe(p).set_inline_reject(), - _ => {} + if let Some(ssl) = self.ssl_mut() { + crate::ssl::us_ssl_set_inline_reject(ssl); } } @@ -604,16 +661,6 @@ impl NewSocketHandler { } } - /// The session an SSLWrapper-backed socket got last from the new-session callback, borrowed. - pub fn wrapper_latest_session(&self) -> *mut bun_boringssl_sys::SSL_SESSION { - match self.socket { - InternalSocket::UpgradedDuplex(d) => duplex(d).latest_session(), - #[cfg(windows)] - InternalSocket::Pipe(p) => pipe(p).latest_session(), - _ => core::ptr::null_mut(), - } - } - /// `SSL*` if this is a TLS socket, else `None`. #[inline] pub fn ssl(&self) -> Option<*mut bun_boringssl_sys::SSL> { @@ -640,17 +687,14 @@ impl NewSocketHandler { /// callers `cast()` immediately anyway. pub fn get_native_handle(&self) -> Option<*mut c_void> { match self.socket { - InternalSocket::Connected(s) => sock(s).get_native_handle(), + InternalSocket::Connected(s) | InternalSocket::UwsHttp(s) => { + sock(s).get_native_handle() + } InternalSocket::Connecting(s) => { let h = conn(s).get_native_handle(); if h.is_null() { None } else { Some(h) } } - InternalSocket::UpgradedDuplex(s) if IS_SSL => duplex(s).ssl().map(|p| p.cast()), - InternalSocket::UpgradedDuplex(_) => None, - #[cfg(windows)] - InternalSocket::Pipe(s) if IS_SSL => pipe(s).ssl().map(|p| p.cast()), - #[cfg(windows)] - InternalSocket::Pipe(_) => None, + InternalSocket::Transport(t) => transport(t).ssl().map(|p| p.cast()), InternalSocket::Detached => None, } } @@ -690,21 +734,22 @@ impl NewSocketHandler { #[inline] pub fn fd(&self) -> Fd { match self.socket { - InternalSocket::Connected(s) => sock(s).get_fd(), + InternalSocket::Connected(s) | InternalSocket::UwsHttp(s) => sock(s).get_fd(), + InternalSocket::Transport(t) => transport(t).fd(), _ => Fd::INVALID, } } pub fn local_port(&self) -> Option { match self.socket { - InternalSocket::Connected(s) => sock(s).local_port(), + InternalSocket::Connected(s) | InternalSocket::UwsHttp(s) => sock(s).local_port(), _ => None, } } pub fn remote_port(&self) -> Option { match self.socket { - InternalSocket::Connected(s) => sock(s).remote_port(), + InternalSocket::Connected(s) | InternalSocket::UwsHttp(s) => sock(s).remote_port(), _ => None, } } @@ -713,14 +758,18 @@ impl NewSocketHandler { match self.socket { // getsockname() can fail (EBADF/ENOTCONN/…) on a socket the OS // closed or reset underneath us; callers treat that as "no address". - InternalSocket::Connected(s) => sock(s).local_address(buf).ok(), + InternalSocket::Connected(s) | InternalSocket::UwsHttp(s) => { + sock(s).local_address(buf).ok() + } _ => None, } } pub fn remote_address<'b>(&self, buf: &'b mut [u8]) -> Option<&'b [u8]> { match self.socket { - InternalSocket::Connected(s) => sock(s).remote_address(buf).ok(), + InternalSocket::Connected(s) | InternalSocket::UwsHttp(s) => { + sock(s).remote_address(buf).ok() + } _ => None, } } @@ -728,7 +777,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(); } } @@ -741,6 +790,12 @@ impl NewSocketHandler { } } #[inline] + pub fn from_uws_http(socket: *mut us_socket_t) -> Self { + Self { + socket: InternalSocket::UwsHttp(socket), + } + } + #[inline] pub fn from_connecting(connecting: *mut ConnectingSocket) -> Self { Self { socket: InternalSocket::Connecting(connecting), @@ -751,9 +806,9 @@ impl NewSocketHandler { Self { socket } } #[inline] - pub fn from_duplex(d: *mut UpgradedDuplex) -> Self { + pub fn from_transport(transport: *const TransportHandle) -> Self { Self { - socket: InternalSocket::UpgradedDuplex(d), + socket: InternalSocket::Transport(transport), } } @@ -799,13 +854,7 @@ impl NewSocketHandler { raw_host: &[u8], port: c_int, owner: *mut Owner, - allow_half_open: bool, ) -> Result { - let opts: c_int = if allow_half_open { - LIBUS_SOCKET_ALLOW_HALF_OPEN - } else { - 0 - }; // getaddrinfo doesn't understand bracketed IPv6 literals; URL parsing // leaves them in (`[::1]`). let host = bun_core::ip_address::strip_ipv6_brackets(raw_host); @@ -831,7 +880,7 @@ impl NewSocketHandler { // layout — NOT `Option<*mut Owner>` (16 bytes, discriminant-first), // which would hand the trampoline `1` instead of the owner pointer. let ext_size = size_of::>>() as c_int; - match g.connect(kind, ssl_ctx, host_z, port, None, opts, ext_size) { + match g.connect(kind, ssl_ctx, host_z, port, None, 0, ext_size) { ConnectResult::Failed => Err(ConnectError::FailedToOpenSocket), ConnectResult::Socket(s) => { *sock(s).ext::>>() = NonNull::new(owner); @@ -854,16 +903,10 @@ impl NewSocketHandler { ssl_ctx: Option<*mut SslCtx>, path: &[u8], owner: *mut Owner, - allow_half_open: bool, ) -> Result { - let opts: c_int = if allow_half_open { - LIBUS_SOCKET_ALLOW_HALF_OPEN - } else { - 0 - }; // See connect_group above for the ext-slot layout rationale. let ext_size = size_of::>>() as c_int; - let s = g.connect_unix(kind, ssl_ctx, path, opts, ext_size); + let s = g.connect_unix(kind, ssl_ctx, path, 0, ext_size); if s.is_null() { return Err(ConnectError::FailedToOpenSocket); } diff --git a/src/uws_sys/udp.rs b/src/uws_sys/udp.rs index dfe2b06247c5..7518f776f216 100644 --- a/src/uws_sys/udp.rs +++ b/src/uws_sys/udp.rs @@ -283,6 +283,8 @@ pub mod raw { protocol: c_int, err: &mut c_int, ) -> LIBUS_SOCKET_DESCRIPTOR; + pub safe fn bsd_adopt_socket(fd: LIBUS_SOCKET_DESCRIPTOR, type_: c_int) -> c_int; + pub safe fn bsd_disown_socket(fd: LIBUS_SOCKET_DESCRIPTOR); pub safe fn bsd_close_socket(fd: LIBUS_SOCKET_DESCRIPTOR); pub safe fn bsd_set_reuseaddr(fd: LIBUS_SOCKET_DESCRIPTOR) -> c_int; /// SAFETY: `addr` must point to `addrlen` bytes of a `sockaddr_in`/`in6`. diff --git a/src/uws_sys/us_socket_t.rs b/src/uws_sys/us_socket_t.rs index aba2f4d51dca..e0a786350133 100644 --- a/src/uws_sys/us_socket_t.rs +++ b/src/uws_sys/us_socket_t.rs @@ -3,7 +3,7 @@ use core::ptr::{self, NonNull}; use bun_core::Fd; -use crate::{LIBUS_SOCKET_DESCRIPTOR, SocketGroup, SocketKind, SslCtx, us_bun_verify_error_t}; +use crate::{LIBUS_SOCKET_DESCRIPTOR, SocketGroup, SocketKind, SslCtx}; bun_core::declare_scope!(uws, visible); @@ -185,10 +185,6 @@ impl us_socket_t { c::us_socket_timeout(self, seconds); } - pub(crate) fn set_long_timeout(&mut self, minutes: u32) { - c::us_socket_long_timeout(self, minutes); - } - pub(crate) fn set_nodelay(&mut self, enabled: bool) { c::us_socket_nodelay(self, enabled as c_int); } @@ -215,13 +211,6 @@ impl us_socket_t { c::us_socket_sni_resolve(self, ctx, error as c_int); } - /// Install a socket-level SNI resolver on an already-adopted server-side - /// TLS socket (there is no listen socket to hang it off). Must run before - /// the handshake is driven. - pub fn on_server_name(&mut self) { - c::us_socket_on_server_name(self); - } - /// Node-compat `_handle` shape: `SSL*` for TLS sockets, fd-as-pointer for /// plain TCP. Consumers that want one or the other should call `ssl()` / /// `get_fd()` directly; this is the round-trip-to-JS form. @@ -308,12 +297,6 @@ impl us_socket_t { c::us_socket_start_tls_handshake(self); } - /// Refuse a bad server chain during the handshake, before the client - /// certificate goes out. No-op on a server socket or after the handshake. - pub fn set_inline_reject(&mut self) { - c::us_socket_set_inline_reject(self); - } - /// Drop the handshake flight that is held across the handshake callback. pub fn release_held_flight(&mut self) { c::us_socket_release_held_flight(self); @@ -389,28 +372,6 @@ impl us_socket_t { rc } - pub fn write2(&mut self, first: &[u8], second: &[u8]) -> i32 { - let rc = unsafe { - // SAFETY: both slices valid for their respective lengths - c::us_socket_write2( - self, - first.as_ptr(), - first.len(), - second.as_ptr(), - second.len(), - ) - }; - bun_core::scoped_log!( - uws, - "us_socket_write2({:p}, {}, {}) = {}", - self, - first.len(), - second.len(), - rc - ); - rc - } - /// Vectored raw write — all chunks reach the fd in one writev (sequential /// sends on platforms without it). Same closed/shutdown gating and /// partial-write poll handling as `raw_write`. Plain-TCP only by contract: @@ -445,8 +406,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 @@ -470,10 +431,6 @@ impl us_socket_t { } } - pub(crate) fn get_verify_error(&self) -> us_bun_verify_error_t { - c::us_socket_verify_error(self) - } - pub(crate) fn get_error(&self) -> i32 { c::us_socket_get_error(self) } @@ -482,8 +439,16 @@ impl us_socket_t { c::us_socket_is_established(self) > 0 } - pub(crate) fn ssl_send_rejected(&self) -> bool { - c::us_socket_ssl_send_rejected(self) > 0 + pub(crate) fn reject_send(&mut self, error: i32) { + c::us_internal_socket_reject_send(self, error); + } + + pub(crate) fn rejected_send_error(&self) -> i32 { + c::us_socket_rejected_send_error(self) + } + + pub(crate) fn ssl_spill_pending(&self) -> u32 { + c::us_socket_ssl_spill_pending(self) } pub(crate) fn queued_input(&self) -> QueuedInput { @@ -525,7 +490,6 @@ mod c { length: *mut i32, ); pub(super) safe fn us_socket_timeout(s: &mut us_socket_t, seconds: c_uint); - pub(super) safe fn us_socket_long_timeout(s: &mut us_socket_t, minutes: c_uint); pub(super) safe fn us_socket_nodelay(s: &mut us_socket_t, enable: c_int); pub(super) safe fn us_socket_set_tos(s: &mut us_socket_t, tos: c_int) -> c_int; pub(super) safe fn us_socket_get_tos(s: &mut us_socket_t) -> c_int; @@ -534,7 +498,6 @@ mod c { ctx: *mut SslCtx, error: c_int, ); - pub(super) safe fn us_socket_on_server_name(s: &mut us_socket_t); pub(super) safe fn us_socket_keepalive( s: &mut us_socket_t, enable: c_int, @@ -555,13 +518,6 @@ mod c { length: i32, fd: i32, ) -> i32; - pub(super) fn us_socket_write2( - s: *mut us_socket_t, - header: *const u8, - len: usize, - payload: *const u8, - len2: usize, - ) -> i32; pub(super) fn us_socket_raw_writev( s: *mut us_socket_t, iov: *const super::UsIoVec, @@ -589,7 +545,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, @@ -597,11 +553,12 @@ mod c { size: c_int, out: &mut c_int, ) -> c_int; - pub(super) safe fn us_socket_verify_error(s: &us_socket_t) -> us_bun_verify_error_t; pub(super) safe fn us_socket_get_error(s: &us_socket_t) -> c_int; 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_internal_socket_reject_send(s: &mut us_socket_t, error: c_int); + pub(super) safe fn us_socket_rejected_send_error(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( @@ -623,83 +580,7 @@ mod c { length: i32, ) -> *mut us_socket_t; pub(super) safe fn us_socket_start_tls_handshake(s: &mut us_socket_t); - pub(super) safe fn us_socket_set_inline_reject(s: &mut us_socket_t); pub(super) safe fn us_socket_release_held_flight(s: &mut us_socket_t); pub(super) safe fn us_socket_set_first_flight_before_fin(s: &mut us_socket_t); } } - -#[repr(C)] -pub struct us_socket_stream_buffer_t { - pub(crate) list_ptr: *mut u8, - pub(crate) list_cap: usize, - pub(crate) list_len: usize, - pub(crate) total_bytes_written: usize, - pub(crate) cursor: usize, -} - -/// Minimal structural mirror of `bun_io::StreamBuffer` for tier-0 interop. -/// The higher-tier `bun_io::StreamBuffer` is field-identical and converts via -/// `From`/`Into` (added in the move-in pass). -pub struct StreamBuffer { - pub list: Vec, - pub cursor: usize, -} - -impl us_socket_stream_buffer_t { - pub fn update(&mut self, stream_buffer: StreamBuffer) { - // Decompose the Vec backing `stream_buffer.list` into raw parts so - // the C side can read ptr/len/cap directly. - let mut list = core::mem::ManuallyDrop::new(stream_buffer.list); - if list.capacity() > 0 { - self.list_ptr = list.as_mut_ptr(); - } else { - self.list_ptr = ptr::null_mut(); - } - self.list_len = list.len(); - self.list_cap = list.capacity(); - self.cursor = stream_buffer.cursor; - } - - pub fn wrote(&mut self, written: usize) { - self.total_bytes_written = self.total_bytes_written.saturating_add(written); - } - - pub fn to_stream_buffer(&self) -> StreamBuffer { - StreamBuffer { - list: if !self.list_ptr.is_null() { - unsafe { - // SAFETY: list_ptr/list_len/list_cap were produced by decomposing a - // Vec in `update`; global allocator (mimalloc) matches. - Vec::from_raw_parts(self.list_ptr, self.list_len, self.list_cap) - } - } else { - Vec::new() - }, - cursor: self.cursor, - } - } - - /// Explicit teardown — this struct is `#[repr(C)]` and freed via the - /// exported `us_socket_free_stream_buffer`, so no `Drop` impl. - /// - /// SAFETY: `list_ptr`/`list_cap` were produced by `update` (decomposed - /// `Vec` on the global mimalloc allocator). Not called more than once. - pub(crate) unsafe fn destroy(&mut self) { - if !self.list_ptr.is_null() { - unsafe { - // SAFETY: list_ptr/list_cap came from a decomposed Vec (global mimalloc). - drop(Vec::from_raw_parts(self.list_ptr, 0, self.list_cap)); - } - self.list_ptr = core::ptr::null_mut(); - self.list_cap = 0; - } - } -} - -#[unsafe(no_mangle)] -extern "C" fn us_socket_free_stream_buffer(buffer: *mut us_socket_stream_buffer_t) { - // SAFETY: caller (C) passes a live us_socket_stream_buffer_t* - unsafe { (*buffer).destroy() }; -} -// us_socket_buffered_js_write moved to src/runtime/socket/uws_jsc.rs diff --git a/src/uws_sys/vtable.rs b/src/uws_sys/vtable.rs index d74c7ce4a35e..c972d800fbc6 100644 --- a/src/uws_sys/vtable.rs +++ b/src/uws_sys/vtable.rs @@ -14,12 +14,7 @@ //! fn on_writable(ext, *us_socket_t) //! fn on_close(ext, *us_socket_t, code: i32, reason: Option<*mut c_void>) //! -//! `Ext` may be omitted entirely (HAS_EXT = false); handlers then take -//! `(*us_socket_t, …)` and recover their owner from `s.group().owner::()` -//! instead. -//! //! fn on_timeout(ext, *us_socket_t) -//! fn on_long_timeout(ext, *us_socket_t) //! fn on_end(ext, *us_socket_t) //! fn on_fd(ext, *us_socket_t, fd: c_int) //! fn on_connect_error(ext, *us_socket_t, code: i32) @@ -36,11 +31,8 @@ use crate::{ConnectingSocket, us_bun_verify_error_t, us_socket_t}; // each method they actually provide. Default impls are `unreachable!()` and // the corresponding vtable slot is left `None` when the const is `false`. pub trait Handler: 'static { - /// What `us_socket_ext` holds. Ignored when `HAS_EXT == false`. + /// What `us_socket_ext` holds. type Ext; - /// When false, handlers take `(s, …)` instead - /// of `(ext, s, …)` and recover their owner from `s.group().owner::()`. - const HAS_EXT: bool = true; const HAS_ON_OPEN: bool = false; const HAS_ON_DATA: bool = false; @@ -48,7 +40,6 @@ pub trait Handler: 'static { const HAS_ON_WRITABLE: bool = false; const HAS_ON_CLOSE: bool = false; const HAS_ON_TIMEOUT: bool = false; - const HAS_ON_LONG_TIMEOUT: bool = false; const HAS_ON_END: bool = false; const HAS_ON_CONNECT_ERROR: bool = false; const HAS_ON_CONNECTING_ERROR: bool = false; @@ -77,9 +68,6 @@ pub trait Handler: 'static { fn on_timeout(_ext: &mut Self::Ext, _s: *mut us_socket_t) { unreachable!() } - fn on_long_timeout(_ext: &mut Self::Ext, _s: *mut us_socket_t) { - unreachable!() - } fn on_end(_ext: &mut Self::Ext, _s: *mut us_socket_t) { unreachable!() } @@ -97,40 +85,6 @@ pub trait Handler: 'static { ) { unreachable!() } - - // When `HAS_EXT == false`, handlers take `(s, …)`. Rust can't change a - // trait method's arity by a const, so the no-ext variants are separate - // methods. Only called when `HAS_EXT == false`. - fn on_open_no_ext(_s: *mut us_socket_t, _is_client: bool, _ip: &[u8]) { - unreachable!() - } - fn on_data_no_ext(_s: *mut us_socket_t, _data: &[u8]) { - unreachable!() - } - fn on_fd_no_ext(_s: *mut us_socket_t, _fd: c_int) { - unreachable!() - } - fn on_writable_no_ext(_s: *mut us_socket_t) { - unreachable!() - } - fn on_close_no_ext(_s: *mut us_socket_t, _code: i32, _reason: Option<*mut c_void>) { - unreachable!() - } - fn on_timeout_no_ext(_s: *mut us_socket_t) { - unreachable!() - } - fn on_long_timeout_no_ext(_s: *mut us_socket_t) { - unreachable!() - } - fn on_end_no_ext(_s: *mut us_socket_t) { - unreachable!() - } - fn on_connect_error_no_ext(_s: *mut us_socket_t, _code: i32) { - unreachable!() - } - fn on_handshake_no_ext(_s: *mut us_socket_t, _ok: bool, _err: us_bun_verify_error_t) { - unreachable!() - } } /// Produce a `&'static VTable` for `H`. The result is a const address into @@ -173,11 +127,6 @@ impl Make { } else { None }, - on_long_timeout: if H::HAS_ON_LONG_TIMEOUT { - Some(Trampolines::::on_long_timeout) - } else { - None - }, on_end: if H::HAS_ON_END { Some(Trampolines::::on_end) } else { @@ -206,8 +155,6 @@ impl Make { struct Trampolines(core::marker::PhantomData); impl Trampolines { - // Rust can't splat tuples into a call, so each trampoline inlines the - // HAS_EXT branch. #[inline(always)] fn ext(s: *mut us_socket_t) -> &'static mut H::Ext { // S008: `us_socket_t` is an `opaque_ffi!` ZST — `opaque_mut` is the @@ -227,40 +174,24 @@ impl Trampolines { // SAFETY: usockets guarantees `ip[0..ip_len]` is valid when non-null. let ip_slice: &[u8] = unsafe { thunk::c_slice(ip, usize::try_from(ip_len).expect("int cast")) }; - if H::HAS_EXT { - H::on_open(Self::ext(s), s, is_client != 0, ip_slice); - } else { - H::on_open_no_ext(s, is_client != 0, ip_slice); - } + H::on_open(Self::ext(s), s, is_client != 0, ip_slice); s } extern "C" fn on_data(s: *mut us_socket_t, data: *mut u8, len: c_int) -> *mut us_socket_t { // SAFETY: usockets guarantees `data[0..len]` is valid. let data_slice = unsafe { thunk::c_slice(data, usize::try_from(len).expect("int cast")) }; - if H::HAS_EXT { - H::on_data(Self::ext(s), s, data_slice); - } else { - H::on_data_no_ext(s, data_slice); - } + H::on_data(Self::ext(s), s, data_slice); s } extern "C" fn on_fd(s: *mut us_socket_t, fd: c_int) -> *mut us_socket_t { - if H::HAS_EXT { - H::on_fd(Self::ext(s), s, fd); - } else { - H::on_fd_no_ext(s, fd); - } + H::on_fd(Self::ext(s), s, fd); s } extern "C" fn on_writable(s: *mut us_socket_t) -> *mut us_socket_t { - if H::HAS_EXT { - H::on_writable(Self::ext(s), s); - } else { - H::on_writable_no_ext(s); - } + H::on_writable(Self::ext(s), s); s } @@ -270,47 +201,22 @@ impl Trampolines { reason: *mut c_void, ) -> *mut us_socket_t { let reason = if reason.is_null() { None } else { Some(reason) }; - if H::HAS_EXT { - H::on_close(Self::ext(s), s, code, reason); - } else { - H::on_close_no_ext(s, code, reason); - } + H::on_close(Self::ext(s), s, code, reason); s } extern "C" fn on_timeout(s: *mut us_socket_t) -> *mut us_socket_t { - if H::HAS_EXT { - H::on_timeout(Self::ext(s), s); - } else { - H::on_timeout_no_ext(s); - } - s - } - - extern "C" fn on_long_timeout(s: *mut us_socket_t) -> *mut us_socket_t { - if H::HAS_EXT { - H::on_long_timeout(Self::ext(s), s); - } else { - H::on_long_timeout_no_ext(s); - } + H::on_timeout(Self::ext(s), s); s } extern "C" fn on_end(s: *mut us_socket_t) -> *mut us_socket_t { - if H::HAS_EXT { - H::on_end(Self::ext(s), s); - } else { - H::on_end_no_ext(s); - } + H::on_end(Self::ext(s), s); s } extern "C" fn on_connect_error(s: *mut us_socket_t, code: c_int) -> *mut us_socket_t { - if H::HAS_EXT { - H::on_connect_error(Self::ext(s), s, code); - } else { - H::on_connect_error_no_ext(s, code); - } + H::on_connect_error(Self::ext(s), s, code); s } @@ -328,10 +234,6 @@ impl Trampolines { err: us_bun_verify_error_t, _user: *mut c_void, ) { - if H::HAS_EXT { - H::on_handshake(Self::ext(s), s, ok != 0, err); - } else { - H::on_handshake_no_ext(s, ok != 0, err); - } + H::on_handshake(Self::ext(s), s, ok != 0, err); } } diff --git a/src/zlib/lib.rs b/src/zlib/lib.rs index 5e86b3f868cf..d03a77df85e8 100644 --- a/src/zlib/lib.rs +++ b/src/zlib/lib.rs @@ -150,7 +150,11 @@ pub enum ZlibError { bun_core::impl_tag_error!(ZlibError); +/// 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, @@ -158,6 +162,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> { @@ -191,10 +197,12 @@ impl<'a> ZlibReaderArrayList<'a> { options: Options, ) -> Result { let mut zlib_reader = Self { + input, list_ptr: list, zlib: ZStream::new(), state: ZlibReaderArrayListState::Uninitialized, max_output_size: usize::MAX, + multi_member: (16..32).contains(&options.window_bits), }; zlib_reader @@ -280,6 +288,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(self.zlib.as_ptr()) } != ReturnCode::Ok { + self.state = ZlibReaderArrayListState::Error; + return Err(ZlibError::ZlibError); + } + self.zlib.set_total_out(total_out); + continue; + } self.end(); return Ok(()); } @@ -880,6 +900,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( @@ -936,19 +975,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 { @@ -979,15 +1007,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/src/zlib/stream.rs b/src/zlib/stream.rs index c6ed4976f26e..a191113ed3d1 100644 --- a/src/zlib/stream.rs +++ b/src/zlib/stream.rs @@ -141,6 +141,13 @@ impl ZStream { } } + /// `inflateReset` zeroes it, so an owner that decodes one member after another into one buffer puts the sum back. + #[inline] + pub fn set_total_out(&mut self, total_out: uLong) { + // SAFETY: see `set_input`. + unsafe { (*self.as_ptr()).total_out = total_out }; + } + /// Point `next_out`/`avail_out` at the next output window. #[inline] pub fn set_output(&mut self, next_out: *mut u8, avail_out: uInt) { diff --git a/test/aot/a-test-that-replaced-built-in-functions-sees-the-same-calls-while-a-module-loads.test.ts b/test/aot/a-test-that-replaced-built-in-functions-sees-the-same-calls-while-a-module-loads.test.ts index b72b243cc80b..471d749c7d54 100644 --- a/test/aot/a-test-that-replaced-built-in-functions-sees-the-same-calls-while-a-module-loads.test.ts +++ b/test/aot/a-test-that-replaced-built-in-functions-sees-the-same-calls-while-a-module-loads.test.ts @@ -99,7 +99,6 @@ const named = secondTexts const loadedWith: Record = { "internal/http.ts": "node:http2", "internal/http/FakeSocket.ts": "node:_http_outgoing", - "internal/http1_server_fallback.ts": "node:http2", "internal/net/isIP.ts": "node:net", "internal/net/symbols.ts": "node:net", "internal/primordials.js": "node:util", diff --git a/test/aot/built-ins-alone/fresh-results-calls.ts b/test/aot/built-ins-alone/fresh-results-calls.ts index c4dd1e3d3886..ef8e5c02b725 100644 --- a/test/aot/built-ins-alone/fresh-results-calls.ts +++ b/test/aot/built-ins-alone/fresh-results-calls.ts @@ -304,6 +304,9 @@ export const calls: Record = { "native:Bindgen:pack_command.bind.ts:readTarball": [ `import { readTarball } from "bun:internal-for-testing";\n${temporary}\nawait Bun.write(folder + "/a.tgz", await new Bun.Archive({ "package/package.json": "{}" }, { compress: "gzip" }).bytes());\nreadTarball(folder + "/a.tgz")`, ], + "native:Bindgen:node_tls_binding.bind.ts:getDefaultCA": [ + 'import { getCACertificates, setDefaultCACertificates } from "node:tls";\nsetDefaultCACertificates(getCACertificates("default"));\ngetCACertificates("default")', + ], "native:NewCppFunction:NodeTLS.cpp:getBundledRootCertificates": [ 'import { getCACertificates } from "node:tls";\nconst list = getCACertificates("bundled");\nlist', ], @@ -624,6 +627,7 @@ export const notCalled: Record = { "native:Bindgen:ffi.bind.ts:openLibrary": ofOneModule("bun:ffi"), "native:Bindgen:ffi.bind.ts:printSource": ofOneModule("bun:ffi"), "native:Bindgen:node_fs_binding.bind.ts:binding": ofOneModule("internal/fs/binding"), + "native:Bindgen:node_tls_binding.bind.ts:getDefaultCACertificateTypes": ofOneModule("node:tls"), "native:Bindgen:node_util_binding.bind.ts:extractedSplitNewLinesFastPathStringsOnly": ofOneModule("internal/util/inspect"), "native:Bindgen:node_util_binding.bind.ts:internalErrorEntries": ofOneModule("node:util"), diff --git a/test/aot/built-ins-alone/small-views.ts b/test/aot/built-ins-alone/small-views.ts index 173fba51b28e..4649085d283e 100644 --- a/test/aot/built-ins-alone/small-views.ts +++ b/test/aot/built-ins-alone/small-views.ts @@ -196,6 +196,9 @@ const routes: Record = { "WebAssembly.validate": [[view => WebAssembly.validate(view), emptyModule]], "BigInt64Array": [[view => new BigInt64Array(view), () => new BigInt64Array(1)]], "BigUint64Array": [[view => new BigUint64Array(view), () => new BigUint64Array(1)]], + "%SecureContext%.prototype.setTicketKeys": [ + [view => require("node:tls").createSecureContext().context.setTicketKeys(view), () => new Uint8Array(48)], + ], "Blob.prototype.write": [[view => Bun.file("written").write(view)]], "Bun.CryptoHasher": [[view => new Bun.CryptoHasher("sha256", view)]], "Bun.CryptoHasher.hash": [ diff --git a/test/aot/builtins/differential/not-checked.txt b/test/aot/builtins/differential/not-checked.txt index 1a31f5908216..1202bb00ff1c 100644 --- a/test/aot/builtins/differential/not-checked.txt +++ b/test/aot/builtins/differential/not-checked.txt @@ -224,6 +224,7 @@ NativeFunctionsNode_fs_bindingBindTsCreateMemfdForTesting/0: it is not called: a NativeFunctionsNode_net_bindingBindTsDoConnect/0: it is not called: it opens a connection or writes to the channel of a parent process NativeFunctionsNode_osBindTsGetPriority/1: no set of operands selects it NativeFunctionsNode_osBindTsSetPriority/1: no set of operands selects it +NativeFunctionsNode_tls_bindingBindTsSetDefaultCA/0: it is not called: it changes how the rest of the process behaves NativeFunctionsNpm_jscBindTsArchitectureIsMatch/0: it is not called: a helper of Bun's own tests that does not come back for some operands NativeFunctionsNpm_jscBindTsOperatingSystemIsMatch/0: it is not called: a helper of Bun's own tests that does not come back for some operands NativeFunctionsPack_commandBindTsReadTarball/0: it is not called: it works on files that its operands name outside the folder of the test @@ -234,7 +235,6 @@ NativeFunctionsShell_bodyBindTsShellLex/0: it is not called: a helper of Bun's o NativeFunctionsShell_bodyBindTsShellParse/0: it is not called: a helper of Bun's own tests that does not come back for some operands NativeFunctionsSocket_bodyBindTsClearSocketFaults/0: it is not called: it changes how the rest of the process behaves NativeFunctionsSocket_bodyBindTsSetSocketFault/0: it is not called: it changes how the rest of the process behaves -NativeFunctionsSocket_bodyBindTsUpgradeDuplexToTLS/0: no set of operands selects it NativeFunctionsStatBindTsCreateStatsForIno/0: it is not called: a helper of Bun's own tests that takes the kinds of its operands on trust or reads freed memory on purpose NativeFunctionsSubprocessBindTsInjectStdioReadError/0: it is not called: it changes how the rest of the process behaves NativeFunctionsTrim_testingBindTsReadTrimTable/0: it is not called: it works on files that its operands name outside the folder of the test diff --git a/test/aot/builtins/differential/operands.ts b/test/aot/builtins/differential/operands.ts index a542f038111a..92ba2bb42271 100644 --- a/test/aot/builtins/differential/operands.ts +++ b/test/aot/builtins/differential/operands.ts @@ -67,7 +67,7 @@ export const neverCalled: ReadonlyMap = new Map([ // Only the code of Bun and of the engine names these, and they take the kinds of their operands on trust: with a value outside the // declared types there is no such call, and native code may do anything. -const takesOperandsOnTrust = /^%LinkTimeConstant:|^%NodeHTTPInternalBinding%\.setServer|^%(CompilerHelpers|PrivateGlobals|NativeFunctions|NodeURLFunctions)%\./; +const takesOperandsOnTrust = /^%LinkTimeConstant:|^%(CompilerHelpers|PrivateGlobals|NativeFunctions|NodeURLFunctions)%\./; export function reasonNeverCalled(path: string): string | undefined { for (const [pattern, reason] of neverCalled) if (pattern.test(path)) return reason; diff --git a/test/aot/builtins/native-contract/host-instances.ts b/test/aot/builtins/native-contract/host-instances.ts index 63cfec2a6513..e65acb0c647a 100644 --- a/test/aot/builtins/native-contract/host-instances.ts +++ b/test/aot/builtins/native-contract/host-instances.ts @@ -60,7 +60,7 @@ function openServer(createOptions: () => object): Open { }; } -// What node:http is handed for a request: at 4 the response handle. +// What node:http is handed for a request: at 3 the response handle. function openNodeHTTPHandle(position: number): Open { return async () => { const { promise, resolve } = Promise.withResolvers(); @@ -300,7 +300,7 @@ const openers: (Open & { className?: string })[] = [ openServer(() => ({ development: false, tls: createTLSOptions() })), openServer(() => ({ development: true })), openServer(() => ({ development: true, tls: createTLSOptions() })), - openNodeHTTPHandle(4), + openNodeHTTPHandle(3), openSubprocess, openResourceUsage, openFileSink, diff --git a/test/aot/builtins/native-contract/targets.ts b/test/aot/builtins/native-contract/targets.ts index 5fec579a168d..bc4cce35a483 100644 --- a/test/aot/builtins/native-contract/targets.ts +++ b/test/aot/builtins/native-contract/targets.ts @@ -40,14 +40,14 @@ const neverCalledNatives: ReadonlyMap = new Map([ [/ffi\.bind\.ts:cc$|\.FfiBindTsCc$|^%FFI%|napi_body/i, "it compiles or loads machine code"], [/^Bun\.FFI\.(CString|closeCallback|toArrayBuffer|toBuffer)$/, "it takes a number for an address, and reads or frees what is there"], [/upgrade_?command|install_?binding|trim_testing|patch_testing\.?bind\.?ts:?apply|npmManifestTestHelpers|readTarball|loadNpmrc/i, "it works on files that its operands name outside the folder of the test"], - [/setSocketFault|clearSocketFaults|jsSetAsyncHooksEnabled|psiTrigger|injectStdioReadError|setMax\w+ForTesting|emitMemoryPressure|seedCacheForTesting/i, "it changes how the rest of the process behaves"], + [/setSocketFault|clearSocketFaults|jsSetAsyncHooksEnabled|psiTrigger|injectStdioReadError|setMax\w+ForTesting|emitMemoryPressure|seedCacheForTesting|setDefaultCA$/i, "it changes how the rest of the process behaves"], [/^%EventEmitter%\.prototype\.(removeAllListeners|off|removeListener|emit|setMaxListeners)$|^dispatchEvent$/, "with its own receiver it takes away or runs the handlers of the process"], [/^Bun\.Image\.(fromClipboard|hasClipboardImage|clipboardChangeCount)$/, "it reads the clipboard"], [/^Bun\.plugin\b|setConfigPlugins|setPages|checkFramework/, "it changes how modules are loaded"], [/doConnect|clusterRawBind|sendHelperPrimary|emitHandleIPCMessage/, "it opens a connection or writes to the channel of a parent process"], [/^%(Quic\w+|PostgresSQL\w+|MySQL\w+|PostgresBinding|MysqlBinding)%|node_quic_binding|postgres\.bind\.ts|mysql\.bind\.ts/, "it needs a server that the test does not have"], [ - /isLiveCellAtRawAddress|createNoOpForTesting|parseRoutePattern|TestEnvironment\.?[Cc]pp|jsFunction_arrayBufferViewHasBuffer|setServerCustomOptions|^%AsyncFromSyncIterator%|^%LinkTimeConstant:|^%NodeCryptoBinding%\.Cipher$|^private global makeErrorWithCode ERR_INVALID_ARG_VALUE$/, + /isLiveCellAtRawAddress|createNoOpForTesting|parseRoutePattern|TestEnvironment\.?[Cc]pp|jsFunction_arrayBufferViewHasBuffer|^%AsyncFromSyncIterator%|^%LinkTimeConstant:|^%NodeCryptoBinding%\.Cipher$|^private global makeErrorWithCode ERR_INVALID_ARG_VALUE$/, "only the code of Bun and of the engine calls it, and it takes the kinds of its operands on trust", ], [ @@ -63,7 +63,7 @@ const neverCalledNatives: ReadonlyMap = new Map([ // Behind such a call others do something else: no connection is secured with ciphers that do not exist, no request has a head of one byte, and what `process` lacks is looked for on an operand of the test. // It is among the last calls of its process. -export const leavesProcessChanged = /setTLSDefaultCiphers|setMaxHTTPHeaderSize|MakeProcessInheritFrom/i; +export const leavesProcessChanged = /setTLSDefaultCiphers|setMaxHTTPHeaderSize|setDefaultCA$|MakeProcessInheritFrom/i; export function reasonTargetIsSkipped(path: string): string | undefined { for (const [pattern, reason] of neverCalledNatives) if (pattern.test(path)) return reason; diff --git a/test/aot/builtins/ways.test.ts b/test/aot/builtins/ways.test.ts index 455c08bc9e9f..3a4919eb1935 100644 --- a/test/aot/builtins/ways.test.ts +++ b/test/aot/builtins/ways.test.ts @@ -44,10 +44,8 @@ const held = [ "self: is an accessor, and the table says Namespace", ]; const engine = [ - // What the library adds when node:net, node:zlib, node:perf_hooks and node:worker_threads are loaded. - "%BlockList%.prototype[kBlockListHandle]: is not in the table", + // What the library adds when node:zlib, node:perf_hooks and node:worker_threads are loaded. "%NativeZlib%.prototype.jsref: is not in the table", - "%SocketAddress%.prototype[kSocketAddressHandle]: is not in the table", // injectFakeEmitter() in src/js/node/worker_threads.ts lays an object with `on`, `emit` and their kin between the prototype object and EventTarget.prototype. Two paths lead to the class. // The class comes to the library out of a native binding, so the compiler does not see whose prototype object that statement moves. It need not: BuiltinTable::hasLockedPrototypeLink() is false of this object, // no lookup of Link or of the walk climbs past an object of which it is false, and the run time locks a link by the same question: in an executable the statement moves this one and throws at Map.prototype. diff --git a/test/aot/bun-test-isolate-compiled.test.ts b/test/aot/bun-test-isolate-compiled.test.ts index 63b726a3fef9..1142ec2bd203 100644 --- a/test/aot/bun-test-isolate-compiled.test.ts +++ b/test/aot/bun-test-isolate-compiled.test.ts @@ -23,11 +23,15 @@ const files = { "c.test.ts": testFile("c"), }; -async function runTests(flags: string[], moreFiles: Record = {}) { +async function outputOfTests(flags: string[], moreFiles: Record) { using dir = tempDir("bun-test-isolate-compiled", { ...files, ...moreFiles }); const cmd = [bunExe(), "test", "--aot", "--preload", "./preload.ts", ...flags]; const { stdout, stderr, exitCode } = await run(cmd, String(dir), { ...bunEnv, BUN_AOT_VERBOSE: "1" }); - const output = stdout + stderr; + return { output: stdout + stderr, exitCode }; +} + +async function runTests(flags: string[], moreFiles: Record = {}) { + const { output, exitCode } = await outputOfTests(flags, moreFiles); return { compiledPreloads: output .split("\n") @@ -403,7 +407,7 @@ test("nothing", () => { expect(1).toBe(1); });`, }); test("a stand-in does not keep the test file that read it", async () => { - const { summary, exitCode } = await runTests(["--isolate", "./d.test.ts", "./e.test.ts", "./f.test.ts"], { + const { output, exitCode } = await outputOfTests(["--isolate", "./d.test.ts", "./e.test.ts", "./f.test.ts"], { "d.test.ts": ` import { expect, test } from "bun:test"; import { existsSync } from "node:fs"; @@ -420,5 +424,10 @@ test("reads what no code that the compiler can follow reads", () => { "e.test.ts": fileThatDoesNothing, "f.test.ts": lastFile(""), }); - expect({ summary, exitCode }).toEqual({ summary: [" 3 pass", " 0 fail"], exitCode: 0 }); + // One line each, so that the line says which: the reads, the count of globals, the marked object, the process. + expect(output).not.toMatch(/^(\(fail\)|✗) reads what/m); + expect(output).not.toContain("toBeLessThanOrEqual"); + expect(output).not.toMatch(/^(\(fail\)|✗) the global of/m); + expect(output).toMatch(/^ 3 pass$/m); + expect(exitCode).toBe(0); }); diff --git a/test/aot/engine-tests/link/accesses-by-a-family-are-one-shared-access.ir b/test/aot/engine-tests/link/accesses-by-a-family-are-one-shared-access.ir index c9b5b60b5f7f..d042d1aefaf6 100644 --- a/test/aot/engine-tests/link/accesses-by-a-family-are-one-shared-access.ir +++ b/test/aot/engine-tests/link/accesses-by-a-family-are-one-shared-access.ir @@ -4,7 +4,7 @@ # class A { m() {} static make() {} } class B1 extends A { m() {} static make() {} } .. function r(C) { return C.make; } r(B7); r(B1); .. function s(C) { C.tag = 1; } .. function p(C) { return C.prototype.m; } .. # Each field of a layout of the table of built-ins is one edge in every program: a layout more in the table moves the number by its fields. # RUN: link counts mechanisms=all -# CHECK: counts flow | nodes {{*}} | edges 5082 | observers 3806 | +# CHECK: counts flow | nodes {{*}} | edges 5073 | observers 3806 | layout L0 kind=Instance name="A" module=m0 constructor=f1 # REPEAT: i = 1..200 diff --git a/test/aot/engine-tests/link/calls-by-a-family-are-one-shared-call.ir b/test/aot/engine-tests/link/calls-by-a-family-are-one-shared-call.ir index aaa29da985b3..962a765b24f0 100644 --- a/test/aot/engine-tests/link/calls-by-a-family-are-one-shared-call.ir +++ b/test/aot/engine-tests/link/calls-by-a-family-are-one-shared-call.ir @@ -4,7 +4,7 @@ # class A { m() {} static make() {} } class B1 extends A { m() {} static make() {} } .. function f(x) { return x.m(); } f(new B7); f(new B1); .. function g(C) { return C.make(); } g(B7); g(B1); .. # Each field of a layout of the table of built-ins is one edge in every program: a layout more in the table moves the number by its fields. # RUN: link counts mechanisms=all -# CHECK: counts flow | nodes {{*}} | edges 8493 | observers 4211 | +# CHECK: counts flow | nodes {{*}} | edges 8484 | observers 4211 | # CHECK: counts who may name a function | groups 1 | referrers 2410 | layout L0 kind=Instance name="A" module=m0 constructor=f1 diff --git a/test/aot/parallel/host-classes/native-object-server-response-locked.js b/test/aot/parallel/host-classes/native-object-server-response-locked.js index 31b383ff2df0..d9ea31b9bba3 100644 --- a/test/aot/parallel/host-classes/native-object-server-response-locked.js +++ b/test/aot/parallel/host-classes/native-object-server-response-locked.js @@ -16,14 +16,14 @@ const server = http.createServer((req, res) => { attempt("store a method", () => { prototype.end = function () { return -1; }; return prototype.end === end; }); attempt("store the same method", () => { prototype.end = end; return prototype.end === end; }); attempt("Reflect.set", () => Reflect.set(prototype, "write", () => 0)); - attempt("delete a member", () => delete prototype.cork); + attempt("delete a member", () => delete prototype.flushHeaders); attempt("Reflect.deleteProperty", () => Reflect.deleteProperty(prototype, "writeHead")); attempt("add a name", () => { prototype.added = 1; return "added" in prototype; }); attempt("another parent", () => Object.setPrototypeOf(prototype, null) === prototype); attempt("the same parent", () => Object.setPrototypeOf(prototype, Object.getPrototypeOf(prototype)) === prototype); attempt("Reflect.setPrototypeOf", () => Reflect.setPrototypeOf(prototype, {})); attempt("Object.assign", () => Object.assign(prototype, { abort() {} }) === prototype); - attempt("as it was", () => Object.getOwnPropertyDescriptor(prototype, "flags").get === flags.get && prototype.end === end && "cork" in prototype && "writeHead" in prototype); + attempt("as it was", () => Object.getOwnPropertyDescriptor(prototype, "flags").get === flags.get && prototype.end === end && "flushHeaders" in prototype && "writeHead" in prototype); } else if (round === 2) { let reads = 0; const finished = Object.getOwnPropertyDescriptor(prototype, "finished").get; Object.defineProperty(handle, "finished", { get() { reads++; return finished.call(this); }, configurable: true }); diff --git a/test/aot/parallel/node-http/frozen-sealed-and-closed-objects.ts b/test/aot/parallel/node-http/frozen-sealed-and-closed-objects.ts index 1f10213629fb..67c3dea4daa7 100644 --- a/test/aot/parallel/node-http/frozen-sealed-and-closed-objects.ts +++ b/test/aot/parallel/node-http/frozen-sealed-and-closed-objects.ts @@ -264,6 +264,7 @@ nobody caught TypeError Attempted to assign to readonly property. response 1 close request 1 end request 1 close +nobody caught Error ServerResponse has an already assigned socket nobody caught TypeError Attempted to assign to readonly property. nobody caught TypeError Attempted to assign to readonly property. response 2 finish @@ -274,8 +275,6 @@ connections 2 socket error TypeError Attempted to assign to readonly property. socket close socket close, heard through an heir -socket close -socket close, heard through an heir == freeze socket during 0: 200 0 first last 1: 200 1 first last @@ -291,6 +290,7 @@ nobody caught TypeError Attempted to assign to readonly property. response 1 close request 1 end request 1 close +nobody caught Error ServerResponse has an already assigned socket nobody caught TypeError Attempted to assign to readonly property. nobody caught TypeError Attempted to assign to readonly property. response 2 finish @@ -301,8 +301,6 @@ connections 2 socket error TypeError Attempted to assign to readonly property. socket close socket close, heard through an heir -socket close -socket close, heard through an heir == freeze socket after 0: 200 0 first last 1: 200 1 first last @@ -318,6 +316,7 @@ nobody caught TypeError Attempted to assign to readonly property. response 1 close request 1 end request 1 close +nobody caught Error ServerResponse has an already assigned socket nobody caught TypeError Attempted to assign to readonly property. nobody caught TypeError Attempted to assign to readonly property. response 2 finish @@ -328,8 +327,6 @@ connections 2 socket error TypeError Attempted to assign to readonly property. socket close socket close, heard through an heir -socket close -socket close, heard through an heir == seal request before 0: 200 0 first last 1: 200 1 first last @@ -343,7 +340,7 @@ seal request before closed sealed response 1 finish response 1 close request 1 end -nobody caught TypeError Attempting to define property on object that is not extensible. +request 1 close response 2 finish response 2 close request 2 end @@ -368,7 +365,7 @@ seal request during closed sealed response 1 finish response 1 close request 1 end -nobody caught TypeError Attempting to define property on object that is not extensible. +request 1 close response 2 finish response 2 close request 2 end @@ -393,7 +390,7 @@ seal request after closed sealed response 1 finish response 1 close request 1 end -nobody caught TypeError Attempting to define property on object that is not extensible. +request 1 close response 2 finish response 2 close request 2 end @@ -574,7 +571,7 @@ preventExtensions request before closed response 1 finish response 1 close request 1 end -nobody caught TypeError Attempting to define property on object that is not extensible. +request 1 close response 2 finish response 2 close request 2 end @@ -599,7 +596,7 @@ preventExtensions request during closed response 1 finish response 1 close request 1 end -nobody caught TypeError Attempting to define property on object that is not extensible. +request 1 close response 2 finish response 2 close request 2 end @@ -624,7 +621,7 @@ preventExtensions request after closed response 1 finish response 1 close request 1 end -nobody caught TypeError Attempting to define property on object that is not extensible. +request 1 close response 2 finish response 2 close request 2 end diff --git a/test/aot/parallel/node-http/objects-closed-proxies-without-traps.ts b/test/aot/parallel/node-http/objects-closed-proxies-without-traps.ts index 6c1460846e81..26da4c97d3a8 100644 --- a/test/aot/parallel/node-http/objects-closed-proxies-without-traps.ts +++ b/test/aot/parallel/node-http/objects-closed-proxies-without-traps.ts @@ -334,596 +334,596 @@ process.exitCode = unlike ? 1 : 0; /* Output: == a server's socket, before the answer: nothing itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself open 61 keys, 61 found, 61 writable, 61 configurable -as that open 61 keys, 61 found, 61 writable, 61 configurable +itself open 61 keys, 61 found, 58 writable, 61 configurable +as that open 61 keys, 61 found, 58 writable, 61 configurable defined anew true added true removed true absent removed true absent found false -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 61 keys, 61 found, 61 writable, 0 configurable -as that closed sealed 61 keys, 61 found, 61 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 61 keys, 61 found, 61 writable, 0 configurable -as that closed sealed 61 keys, 61 found, 61 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 61 keys, 61 found, 61 writable, 0 configurable -as that closed sealed 61 keys, 61 found, 61 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 61 keys, 61 found, 61 writable, 0 configurable -as that closed sealed 61 keys, 61 found, 61 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal a Proxy with every trap, then used itself traps while closing: 63 of them for a key that is not listed: 0 -itself closed sealed 61 keys, 61 found, 61 writable, 0 configurable -as that closed sealed 61 keys, 61 found, 61 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal a Proxy with every trap, then used as that traps while closing: 63 of them for a key that is not listed: 0 -itself closed sealed 61 keys, 61 found, 61 writable, 0 configurable -as that closed sealed 61 keys, 61 found, 61 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 312 of them for a key that is not listed: 0 -itself closed sealed 61 keys, 61 found, 61 writable, 0 configurable -as that closed sealed 61 keys, 61 found, 61 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 61 keys, 61 found, 61 writable, 61 configurable +itself open 61 keys, 61 found, 58 writable, 61 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal a Proxy of a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 61 keys, 61 found, 61 writable, 61 configurable +itself open 61 keys, 61 found, 58 writable, 61 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal a Proxy that traps defineProperty, then used itself traps while closing: 61 of them for a key that is not listed: 0 -itself closed sealed 61 keys, 61 found, 61 writable, 0 configurable -as that closed sealed 61 keys, 61 found, 61 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 61 keys, 61 found, 61 writable, 0 configurable -as that closed sealed 61 keys, 61 found, 61 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 61 keys, 61 found, 61 writable, 0 configurable -as that closed sealed 61 keys, 61 found, 61 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 61 keys, 61 found, 61 writable, 0 configurable -as that closed sealed 61 keys, 61 found, 61 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 61 keys, 61 found, 61 writable, 0 configurable -as that closed sealed 61 keys, 61 found, 61 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 61 keys, 61 found, 61 writable, 0 configurable -as that closed sealed 61 keys, 61 found, 61 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 61 keys, 61 found, 61 writable, 0 configurable -as that closed sealed 61 keys, 61 found, 61 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal a Proxy that traps ownKeys, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed sealed 61 keys, 61 found, 61 writable, 0 configurable -as that closed sealed 61 keys, 61 found, 61 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed sealed 61 keys, 61 found, 61 writable, 0 configurable -as that closed sealed 61 keys, 61 found, 61 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: seal a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 61 keys, 61 found, 61 writable, 0 configurable -as that closed sealed 61 keys, 61 found, 61 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 61 keys, 61 found, 61 writable, 61 configurable -as that closed 61 keys, 61 found, 61 writable, 61 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 61 keys, 61 found, 61 writable, 61 configurable -as that closed 61 keys, 61 found, 61 writable, 61 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 -itself closed 61 keys, 61 found, 61 writable, 61 configurable -as that closed 61 keys, 61 found, 61 writable, 61 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 61 keys, 61 found, 61 writable, 61 configurable -as that closed 61 keys, 61 found, 61 writable, 61 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions a Proxy with every trap, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed 61 keys, 61 found, 61 writable, 61 configurable -as that closed 61 keys, 61 found, 61 writable, 61 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions a Proxy with every trap, then used as that traps while closing: 1 of them for a key that is not listed: 0 -itself closed 61 keys, 61 found, 61 writable, 61 configurable -as that closed 61 keys, 61 found, 61 writable, 61 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 3 of them for a key that is not listed: 0 -itself closed 61 keys, 61 found, 61 writable, 61 configurable -as that closed 61 keys, 61 found, 61 writable, 61 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 61 keys, 61 found, 61 writable, 61 configurable +itself open 61 keys, 61 found, 58 writable, 61 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions a Proxy of a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 61 keys, 61 found, 61 writable, 61 configurable +itself open 61 keys, 61 found, 58 writable, 61 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions a Proxy that traps defineProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 61 keys, 61 found, 61 writable, 61 configurable -as that closed 61 keys, 61 found, 61 writable, 61 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 61 keys, 61 found, 61 writable, 61 configurable -as that closed 61 keys, 61 found, 61 writable, 61 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 61 keys, 61 found, 61 writable, 61 configurable -as that closed 61 keys, 61 found, 61 writable, 61 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 61 keys, 61 found, 61 writable, 61 configurable -as that closed 61 keys, 61 found, 61 writable, 61 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 61 keys, 61 found, 61 writable, 61 configurable -as that closed 61 keys, 61 found, 61 writable, 61 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 61 keys, 61 found, 61 writable, 61 configurable -as that closed 61 keys, 61 found, 61 writable, 61 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 61 keys, 61 found, 61 writable, 61 configurable -as that closed 61 keys, 61 found, 61 writable, 61 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions a Proxy that traps ownKeys, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 61 keys, 61 found, 61 writable, 61 configurable -as that closed 61 keys, 61 found, 61 writable, 61 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed 61 keys, 61 found, 61 writable, 61 configurable -as that closed 61 keys, 61 found, 61 writable, 61 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, before the answer: preventExtensions a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 61 keys, 61 found, 61 writable, 61 configurable -as that closed 61 keys, 61 found, 61 writable, 61 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- the listener threw TypeError in setTimeout | request end | request close | response close | socket close | the client saw TypeError +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: nothing itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself open 62 keys, 62 found, 62 writable, 62 configurable -as that open 62 keys, 62 found, 62 writable, 62 configurable +itself open 61 keys, 61 found, 58 writable, 61 configurable +as that open 61 keys, 61 found, 58 writable, 61 configurable defined anew true added true removed true absent removed true absent found false -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: freeze itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable -as that closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable +itself closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable +as that closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in | nobody caught TypeError in setTimeout | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | socket close | nobody caught TypeError in releaseServerParserShim +-- nobody caught TypeError in | nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | nobody caught TypeError in onConnectionClosed | nobody caught TypeError in error | the socket's close did not come == a server's socket, after the answer: freeze a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable -as that closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable +itself closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable +as that closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in | nobody caught TypeError in setTimeout | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | socket close | nobody caught TypeError in releaseServerParserShim +-- nobody caught TypeError in | nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | nobody caught TypeError in onConnectionClosed | nobody caught TypeError in error | the socket's close did not come == a server's socket, after the answer: freeze a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable -as that closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable +itself closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable +as that closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in | nobody caught TypeError in setTimeout | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | socket close | nobody caught TypeError in releaseServerParserShim +-- nobody caught TypeError in | nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | nobody caught TypeError in onConnectionClosed | nobody caught TypeError in error | the socket's close did not come == a server's socket, after the answer: freeze a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable -as that closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable +itself closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable +as that closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in | nobody caught TypeError in setTimeout | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | socket close | nobody caught TypeError in releaseServerParserShim +-- nobody caught TypeError in | nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | nobody caught TypeError in onConnectionClosed | nobody caught TypeError in error | the socket's close did not come == a server's socket, after the answer: freeze a Proxy with every trap, then used itself -traps while closing: 126 of them for a key that is not listed: 0 -itself closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable -as that closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable +traps while closing: 124 of them for a key that is not listed: 0 +itself closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable +as that closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in | nobody caught TypeError in setTimeout | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | socket close | nobody caught TypeError in releaseServerParserShim +-- nobody caught TypeError in | nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | nobody caught TypeError in onConnectionClosed | nobody caught TypeError in error | the socket's close did not come == a server's socket, after the answer: freeze a Proxy with every trap, then used as that -traps while closing: 126 of them for a key that is not listed: 0 -itself closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable -as that closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable +traps while closing: 124 of them for a key that is not listed: 0 +itself closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable +as that closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in | nobody caught TypeError in setTimeout | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | socket close | nobody caught TypeError in releaseServerParserShim +-- nobody caught TypeError in | nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | nobody caught TypeError in onConnectionClosed | nobody caught TypeError in error | the socket's close did not come == a server's socket, after the answer: freeze a Proxy with every trap of a Proxy with every trap, then used itself -traps while closing: 565 of them for a key that is not listed: 0 -itself closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable -as that closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable +traps while closing: 556 of them for a key that is not listed: 0 +itself closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable +as that closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in | nobody caught TypeError in setTimeout | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | socket close | nobody caught TypeError in releaseServerParserShim +-- nobody caught TypeError in | nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | nobody caught TypeError in onConnectionClosed | nobody caught TypeError in error | the socket's close did not come == a server's socket, after the answer: freeze a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 62 keys, 62 found, 62 writable, 62 configurable +itself open 61 keys, 61 found, 58 writable, 61 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: freeze a Proxy of a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 62 keys, 62 found, 62 writable, 62 configurable +itself open 61 keys, 61 found, 58 writable, 61 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: freeze a Proxy that traps defineProperty, then used itself -traps while closing: 62 of them for a key that is not listed: 0 -itself closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable -as that closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable +traps while closing: 61 of them for a key that is not listed: 0 +itself closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable +as that closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in | nobody caught TypeError in setTimeout | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | socket close | nobody caught TypeError in releaseServerParserShim +-- nobody caught TypeError in | nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | nobody caught TypeError in onConnectionClosed | nobody caught TypeError in error | the socket's close did not come == a server's socket, after the answer: freeze a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable -as that closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable +itself closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable +as that closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in | nobody caught TypeError in setTimeout | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | socket close | nobody caught TypeError in releaseServerParserShim +-- nobody caught TypeError in | nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | nobody caught TypeError in onConnectionClosed | nobody caught TypeError in error | the socket's close did not come == a server's socket, after the answer: freeze a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable -as that closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable +itself closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable +as that closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in | nobody caught TypeError in setTimeout | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | socket close | nobody caught TypeError in releaseServerParserShim +-- nobody caught TypeError in | nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | nobody caught TypeError in onConnectionClosed | nobody caught TypeError in error | the socket's close did not come == a server's socket, after the answer: freeze a Proxy that traps getOwnPropertyDescriptor, then used itself -traps while closing: 62 of them for a key that is not listed: 0 -itself closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable -as that closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable +traps while closing: 61 of them for a key that is not listed: 0 +itself closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable +as that closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in | nobody caught TypeError in setTimeout | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | socket close | nobody caught TypeError in releaseServerParserShim +-- nobody caught TypeError in | nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | nobody caught TypeError in onConnectionClosed | nobody caught TypeError in error | the socket's close did not come == a server's socket, after the answer: freeze a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable -as that closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable +itself closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable +as that closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in | nobody caught TypeError in setTimeout | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | socket close | nobody caught TypeError in releaseServerParserShim +-- nobody caught TypeError in | nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | nobody caught TypeError in onConnectionClosed | nobody caught TypeError in error | the socket's close did not come == a server's socket, after the answer: freeze a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable -as that closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable +itself closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable +as that closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in | nobody caught TypeError in setTimeout | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | socket close | nobody caught TypeError in releaseServerParserShim +-- nobody caught TypeError in | nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | nobody caught TypeError in onConnectionClosed | nobody caught TypeError in error | the socket's close did not come == a server's socket, after the answer: freeze a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable -as that closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable +itself closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable +as that closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in | nobody caught TypeError in setTimeout | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | socket close | nobody caught TypeError in releaseServerParserShim +-- nobody caught TypeError in | nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | nobody caught TypeError in onConnectionClosed | nobody caught TypeError in error | the socket's close did not come == a server's socket, after the answer: freeze a Proxy that traps ownKeys, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable -as that closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable +itself closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable +as that closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in | nobody caught TypeError in setTimeout | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | socket close | nobody caught TypeError in releaseServerParserShim +-- nobody caught TypeError in | nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | nobody caught TypeError in onConnectionClosed | nobody caught TypeError in error | the socket's close did not come == a server's socket, after the answer: freeze a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable -as that closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable +itself closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable +as that closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in | nobody caught TypeError in setTimeout | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | socket close | nobody caught TypeError in releaseServerParserShim +-- nobody caught TypeError in | nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | nobody caught TypeError in onConnectionClosed | nobody caught TypeError in error | the socket's close did not come == a server's socket, after the answer: freeze a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable -as that closed sealed frozen 62 keys, 62 found, 0 writable, 0 configurable +itself closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable +as that closed sealed frozen 61 keys, 61 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in | nobody caught TypeError in setTimeout | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | socket close | nobody caught TypeError in releaseServerParserShim +-- nobody caught TypeError in | nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket error TypeError in _destroy | nobody caught TypeError in onConnectionClosed | nobody caught TypeError in error | the socket's close did not come == a server's socket, after the answer: seal itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 62 keys, 62 found, 62 writable, 0 configurable -as that closed sealed 62 keys, 62 found, 62 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: seal a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 62 keys, 62 found, 62 writable, 0 configurable -as that closed sealed 62 keys, 62 found, 62 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: seal a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 62 keys, 62 found, 62 writable, 0 configurable -as that closed sealed 62 keys, 62 found, 62 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: seal a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 62 keys, 62 found, 62 writable, 0 configurable -as that closed sealed 62 keys, 62 found, 62 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: seal a Proxy with every trap, then used itself -traps while closing: 64 of them for a key that is not listed: 0 -itself closed sealed 62 keys, 62 found, 62 writable, 0 configurable -as that closed sealed 62 keys, 62 found, 62 writable, 0 configurable +traps while closing: 63 of them for a key that is not listed: 0 +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: seal a Proxy with every trap, then used as that -traps while closing: 64 of them for a key that is not listed: 0 -itself closed sealed 62 keys, 62 found, 62 writable, 0 configurable -as that closed sealed 62 keys, 62 found, 62 writable, 0 configurable +traps while closing: 63 of them for a key that is not listed: 0 +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: seal a Proxy with every trap of a Proxy with every trap, then used itself -traps while closing: 317 of them for a key that is not listed: 0 -itself closed sealed 62 keys, 62 found, 62 writable, 0 configurable -as that closed sealed 62 keys, 62 found, 62 writable, 0 configurable +traps while closing: 312 of them for a key that is not listed: 0 +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: seal a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 62 keys, 62 found, 62 writable, 62 configurable +itself open 61 keys, 61 found, 58 writable, 61 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: seal a Proxy of a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 62 keys, 62 found, 62 writable, 62 configurable +itself open 61 keys, 61 found, 58 writable, 61 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: seal a Proxy that traps defineProperty, then used itself -traps while closing: 62 of them for a key that is not listed: 0 -itself closed sealed 62 keys, 62 found, 62 writable, 0 configurable -as that closed sealed 62 keys, 62 found, 62 writable, 0 configurable +traps while closing: 61 of them for a key that is not listed: 0 +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: seal a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 62 keys, 62 found, 62 writable, 0 configurable -as that closed sealed 62 keys, 62 found, 62 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: seal a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 62 keys, 62 found, 62 writable, 0 configurable -as that closed sealed 62 keys, 62 found, 62 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: seal a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 62 keys, 62 found, 62 writable, 0 configurable -as that closed sealed 62 keys, 62 found, 62 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: seal a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 62 keys, 62 found, 62 writable, 0 configurable -as that closed sealed 62 keys, 62 found, 62 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: seal a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 62 keys, 62 found, 62 writable, 0 configurable -as that closed sealed 62 keys, 62 found, 62 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: seal a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 62 keys, 62 found, 62 writable, 0 configurable -as that closed sealed 62 keys, 62 found, 62 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: seal a Proxy that traps ownKeys, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed sealed 62 keys, 62 found, 62 writable, 0 configurable -as that closed sealed 62 keys, 62 found, 62 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: seal a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed sealed 62 keys, 62 found, 62 writable, 0 configurable -as that closed sealed 62 keys, 62 found, 62 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: seal a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 62 keys, 62 found, 62 writable, 0 configurable -as that closed sealed 62 keys, 62 found, 62 writable, 0 configurable +itself closed sealed 61 keys, 61 found, 58 writable, 0 configurable +as that closed sealed 61 keys, 61 found, 58 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 62 keys, 62 found, 62 writable, 62 configurable -as that closed 62 keys, 62 found, 62 writable, 62 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 62 keys, 62 found, 62 writable, 62 configurable -as that closed 62 keys, 62 found, 62 writable, 62 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 -itself closed 62 keys, 62 found, 62 writable, 62 configurable -as that closed 62 keys, 62 found, 62 writable, 62 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 62 keys, 62 found, 62 writable, 62 configurable -as that closed 62 keys, 62 found, 62 writable, 62 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions a Proxy with every trap, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed 62 keys, 62 found, 62 writable, 62 configurable -as that closed 62 keys, 62 found, 62 writable, 62 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions a Proxy with every trap, then used as that traps while closing: 1 of them for a key that is not listed: 0 -itself closed 62 keys, 62 found, 62 writable, 62 configurable -as that closed 62 keys, 62 found, 62 writable, 62 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 3 of them for a key that is not listed: 0 -itself closed 62 keys, 62 found, 62 writable, 62 configurable -as that closed 62 keys, 62 found, 62 writable, 62 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 62 keys, 62 found, 62 writable, 62 configurable +itself open 61 keys, 61 found, 58 writable, 61 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions a Proxy of a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 62 keys, 62 found, 62 writable, 62 configurable +itself open 61 keys, 61 found, 58 writable, 61 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions a Proxy that traps defineProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 62 keys, 62 found, 62 writable, 62 configurable -as that closed 62 keys, 62 found, 62 writable, 62 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 62 keys, 62 found, 62 writable, 62 configurable -as that closed 62 keys, 62 found, 62 writable, 62 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 62 keys, 62 found, 62 writable, 62 configurable -as that closed 62 keys, 62 found, 62 writable, 62 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 62 keys, 62 found, 62 writable, 62 configurable -as that closed 62 keys, 62 found, 62 writable, 62 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 62 keys, 62 found, 62 writable, 62 configurable -as that closed 62 keys, 62 found, 62 writable, 62 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 62 keys, 62 found, 62 writable, 62 configurable -as that closed 62 keys, 62 found, 62 writable, 62 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 62 keys, 62 found, 62 writable, 62 configurable -as that closed 62 keys, 62 found, 62 writable, 62 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions a Proxy that traps ownKeys, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 62 keys, 62 found, 62 writable, 62 configurable -as that closed 62 keys, 62 found, 62 writable, 62 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed 62 keys, 62 found, 62 writable, 62 configurable -as that closed 62 keys, 62 found, 62 writable, 62 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's socket, after the answer: preventExtensions a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 62 keys, 62 found, 62 writable, 62 configurable -as that closed 62 keys, 62 found, 62 writable, 62 configurable +itself closed 61 keys, 61 found, 58 writable, 61 configurable +as that closed 61 keys, 61 found, 58 writable, 61 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in onResponseFinishHandleSocket | response close | request end | request close | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: nothing itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself open 28 keys, 28 found, 28 writable, 28 configurable @@ -935,43 +935,43 @@ traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, before the answer: freeze a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, before the answer: freeze a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, before the answer: freeze a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, before the answer: freeze a Proxy with every trap, then used itself traps while closing: 58 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, before the answer: freeze a Proxy with every trap, then used as that traps while closing: 58 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, before the answer: freeze a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 259 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, before the answer: freeze a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 @@ -991,103 +991,103 @@ traps while closing: 28 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, before the answer: freeze a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, before the answer: freeze a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, before the answer: freeze a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 28 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, before the answer: freeze a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, before the answer: freeze a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, before the answer: freeze a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, before the answer: freeze a Proxy that traps ownKeys, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, before the answer: freeze a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, before the answer: freeze a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, before the answer: seal itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: seal a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: seal a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: seal a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: seal a Proxy with every trap, then used itself traps while closing: 30 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: seal a Proxy with every trap, then used as that traps while closing: 30 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: seal a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 147 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: seal a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 @@ -1107,103 +1107,103 @@ traps while closing: 28 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: seal a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: seal a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: seal a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: seal a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: seal a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: seal a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: seal a Proxy that traps ownKeys, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: seal a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: seal a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: preventExtensions itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: preventExtensions a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: preventExtensions a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: preventExtensions a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: preventExtensions a Proxy with every trap, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: preventExtensions a Proxy with every trap, then used as that traps while closing: 1 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: preventExtensions a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 3 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: preventExtensions a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 @@ -1223,61 +1223,61 @@ traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: preventExtensions a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: preventExtensions a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: preventExtensions a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: preventExtensions a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: preventExtensions a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: preventExtensions a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: preventExtensions a Proxy that traps ownKeys, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: preventExtensions a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, before the answer: preventExtensions a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: nothing itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself open 28 keys, 28 found, 28 writable, 28 configurable @@ -1289,43 +1289,43 @@ traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, after the answer: freeze a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, after the answer: freeze a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, after the answer: freeze a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, after the answer: freeze a Proxy with every trap, then used itself traps while closing: 58 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, after the answer: freeze a Proxy with every trap, then used as that traps while closing: 58 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, after the answer: freeze a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 259 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, after the answer: freeze a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 @@ -1345,103 +1345,103 @@ traps while closing: 28 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, after the answer: freeze a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, after the answer: freeze a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, after the answer: freeze a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 28 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, after the answer: freeze a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, after the answer: freeze a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, after the answer: freeze a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, after the answer: freeze a Proxy that traps ownKeys, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, after the answer: freeze a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, after the answer: freeze a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable as that closed sealed frozen 28 keys, 28 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in emitEOFIncomingMessage | answered 200 1 answer | socket close +-- nobody caught TypeError in _dump | request error TypeError in _destroy | request close | response close | nobody caught TypeError in onDataIncomingMessage | answered 200 1 answer | socket close == a server's request, after the answer: seal itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: seal a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: seal a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: seal a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: seal a Proxy with every trap, then used itself traps while closing: 30 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: seal a Proxy with every trap, then used as that traps while closing: 30 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: seal a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 147 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: seal a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 @@ -1461,103 +1461,103 @@ traps while closing: 28 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: seal a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: seal a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: seal a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: seal a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: seal a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: seal a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: seal a Proxy that traps ownKeys, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: seal a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: seal a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 28 keys, 28 found, 28 writable, 0 configurable as that closed sealed 28 keys, 28 found, 28 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: preventExtensions itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: preventExtensions a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: preventExtensions a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: preventExtensions a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: preventExtensions a Proxy with every trap, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: preventExtensions a Proxy with every trap, then used as that traps while closing: 1 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: preventExtensions a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 3 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: preventExtensions a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 @@ -1577,651 +1577,651 @@ traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: preventExtensions a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: preventExtensions a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: preventExtensions a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: preventExtensions a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: preventExtensions a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: preventExtensions a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: preventExtensions a Proxy that traps ownKeys, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: preventExtensions a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's request, after the answer: preventExtensions a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 28 keys, 28 found, 28 writable, 28 configurable as that closed 28 keys, 28 found, 28 writable, 28 configurable defined anew true added false removed true absent removed true absent found false --- response finish | response close | request end | nobody caught TypeError in endReadableNT | answered 200 1 answer | socket close +-- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: nothing itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself open 48 keys, 48 found, 48 writable, 48 configurable -as that open 48 keys, 48 found, 48 writable, 48 configurable +itself open 49 keys, 49 found, 49 writable, 49 configurable +as that open 49 keys, 49 found, 49 writable, 49 configurable defined anew true added true removed true absent removed true absent found false -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal a Proxy with every trap, then used itself -traps while closing: 50 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +traps while closing: 51 of them for a key that is not listed: 0 +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal a Proxy with every trap, then used as that -traps while closing: 50 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +traps while closing: 51 of them for a key that is not listed: 0 +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal a Proxy with every trap of a Proxy with every trap, then used itself -traps while closing: 247 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +traps while closing: 252 of them for a key that is not listed: 0 +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 48 keys, 48 found, 48 writable, 48 configurable +itself open 49 keys, 49 found, 49 writable, 49 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal a Proxy of a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 48 keys, 48 found, 48 writable, 48 configurable +itself open 49 keys, 49 found, 49 writable, 49 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal a Proxy that traps defineProperty, then used itself -traps while closing: 48 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +traps while closing: 49 of them for a key that is not listed: 0 +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal a Proxy that traps ownKeys, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: seal a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions a Proxy with every trap, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions a Proxy with every trap, then used as that traps while closing: 1 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 3 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 48 keys, 48 found, 48 writable, 48 configurable +itself open 49 keys, 49 found, 49 writable, 49 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions a Proxy of a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 48 keys, 48 found, 48 writable, 48 configurable +itself open 49 keys, 49 found, 49 writable, 49 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions a Proxy that traps defineProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions a Proxy that traps ownKeys, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, before the answer: preventExtensions a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: nothing itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself open 48 keys, 48 found, 48 writable, 48 configurable -as that open 48 keys, 48 found, 48 writable, 48 configurable +itself open 49 keys, 49 found, 49 writable, 49 configurable +as that open 49 keys, 49 found, 49 writable, 49 configurable defined anew true added true removed true absent removed true absent found false -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable -as that closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable +itself closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable +as that closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | nobody caught TypeError in set | nobody caught TypeError in emitCloseNT | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable -as that closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable +itself closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable +as that closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | nobody caught TypeError in set | nobody caught TypeError in emitCloseNT | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable -as that closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable +itself closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable +as that closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | nobody caught TypeError in set | nobody caught TypeError in emitCloseNT | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable -as that closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable +itself closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable +as that closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | nobody caught TypeError in set | nobody caught TypeError in emitCloseNT | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze a Proxy with every trap, then used itself -traps while closing: 98 of them for a key that is not listed: 0 -itself closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable -as that closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable +traps while closing: 100 of them for a key that is not listed: 0 +itself closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable +as that closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | nobody caught TypeError in set | nobody caught TypeError in emitCloseNT | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze a Proxy with every trap, then used as that -traps while closing: 98 of them for a key that is not listed: 0 -itself closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable -as that closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable +traps while closing: 100 of them for a key that is not listed: 0 +itself closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable +as that closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | nobody caught TypeError in set | nobody caught TypeError in emitCloseNT | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze a Proxy with every trap of a Proxy with every trap, then used itself -traps while closing: 439 of them for a key that is not listed: 0 -itself closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable -as that closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable +traps while closing: 448 of them for a key that is not listed: 0 +itself closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable +as that closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | nobody caught TypeError in set | nobody caught TypeError in emitCloseNT | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 48 keys, 48 found, 48 writable, 48 configurable +itself open 49 keys, 49 found, 49 writable, 49 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze a Proxy of a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 48 keys, 48 found, 48 writable, 48 configurable +itself open 49 keys, 49 found, 49 writable, 49 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze a Proxy that traps defineProperty, then used itself -traps while closing: 48 of them for a key that is not listed: 0 -itself closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable -as that closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable +traps while closing: 49 of them for a key that is not listed: 0 +itself closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable +as that closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | nobody caught TypeError in set | nobody caught TypeError in emitCloseNT | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable -as that closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable +itself closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable +as that closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | nobody caught TypeError in set | nobody caught TypeError in emitCloseNT | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable -as that closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable +itself closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable +as that closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | nobody caught TypeError in set | nobody caught TypeError in emitCloseNT | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze a Proxy that traps getOwnPropertyDescriptor, then used itself -traps while closing: 48 of them for a key that is not listed: 0 -itself closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable -as that closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable +traps while closing: 49 of them for a key that is not listed: 0 +itself closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable +as that closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | nobody caught TypeError in set | nobody caught TypeError in emitCloseNT | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable -as that closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable +itself closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable +as that closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | nobody caught TypeError in set | nobody caught TypeError in emitCloseNT | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable -as that closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable +itself closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable +as that closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | nobody caught TypeError in set | nobody caught TypeError in emitCloseNT | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable -as that closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable +itself closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable +as that closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | nobody caught TypeError in set | nobody caught TypeError in emitCloseNT | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze a Proxy that traps ownKeys, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable -as that closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable +itself closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable +as that closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | nobody caught TypeError in set | nobody caught TypeError in emitCloseNT | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable -as that closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable +itself closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable +as that closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | nobody caught TypeError in set | nobody caught TypeError in emitCloseNT | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: freeze a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable -as that closed sealed frozen 48 keys, 48 found, 0 writable, 0 configurable +itself closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable +as that closed sealed frozen 49 keys, 49 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | nobody caught TypeError in set | nobody caught TypeError in emitCloseNT | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal a Proxy with every trap, then used itself -traps while closing: 50 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +traps while closing: 51 of them for a key that is not listed: 0 +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal a Proxy with every trap, then used as that -traps while closing: 50 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +traps while closing: 51 of them for a key that is not listed: 0 +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal a Proxy with every trap of a Proxy with every trap, then used itself -traps while closing: 247 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +traps while closing: 252 of them for a key that is not listed: 0 +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 48 keys, 48 found, 48 writable, 48 configurable +itself open 49 keys, 49 found, 49 writable, 49 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal a Proxy of a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 48 keys, 48 found, 48 writable, 48 configurable +itself open 49 keys, 49 found, 49 writable, 49 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal a Proxy that traps defineProperty, then used itself -traps while closing: 48 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +traps while closing: 49 of them for a key that is not listed: 0 +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal a Proxy that traps ownKeys, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: seal a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 48 keys, 48 found, 48 writable, 0 configurable -as that closed sealed 48 keys, 48 found, 48 writable, 0 configurable +itself closed sealed 49 keys, 49 found, 49 writable, 0 configurable +as that closed sealed 49 keys, 49 found, 49 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions a Proxy with every trap, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions a Proxy with every trap, then used as that traps while closing: 1 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 3 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 48 keys, 48 found, 48 writable, 48 configurable +itself open 49 keys, 49 found, 49 writable, 49 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions a Proxy of a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 48 keys, 48 found, 48 writable, 48 configurable +itself open 49 keys, 49 found, 49 writable, 49 configurable asking threw TypeError changing threw TypeError -- response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions a Proxy that traps defineProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions a Proxy that traps ownKeys, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a server's response, after the answer: preventExtensions a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 48 keys, 48 found, 48 writable, 48 configurable -as that closed 48 keys, 48 found, 48 writable, 48 configurable +itself closed 49 keys, 49 found, 49 writable, 49 configurable +as that closed 49 keys, 49 found, 49 writable, 49 configurable defined anew true added false removed true absent removed true absent found false -- nobody caught TypeError in onNodeHTTPRequest | nobody caught TypeError in markResponseEndedNT | response finish | response close | request end | request close | answered 200 1 answer | socket close == a client's request: nothing itself, then used itself @@ -2589,43 +2589,43 @@ traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 34 keys, 34 found, 34 writable, 0 configurable as that closed sealed 34 keys, 34 found, 34 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: seal a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 34 keys, 34 found, 34 writable, 0 configurable as that closed sealed 34 keys, 34 found, 34 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: seal a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 34 keys, 34 found, 34 writable, 0 configurable as that closed sealed 34 keys, 34 found, 34 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: seal a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 34 keys, 34 found, 34 writable, 0 configurable as that closed sealed 34 keys, 34 found, 34 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: seal a Proxy with every trap, then used itself traps while closing: 36 of them for a key that is not listed: 0 itself closed sealed 34 keys, 34 found, 34 writable, 0 configurable as that closed sealed 34 keys, 34 found, 34 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: seal a Proxy with every trap, then used as that traps while closing: 36 of them for a key that is not listed: 0 itself closed sealed 34 keys, 34 found, 34 writable, 0 configurable as that closed sealed 34 keys, 34 found, 34 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: seal a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 177 of them for a key that is not listed: 0 itself closed sealed 34 keys, 34 found, 34 writable, 0 configurable as that closed sealed 34 keys, 34 found, 34 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: seal a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 @@ -2645,103 +2645,103 @@ traps while closing: 34 of them for a key that is not listed: 0 itself closed sealed 34 keys, 34 found, 34 writable, 0 configurable as that closed sealed 34 keys, 34 found, 34 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: seal a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 34 keys, 34 found, 34 writable, 0 configurable as that closed sealed 34 keys, 34 found, 34 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: seal a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 34 keys, 34 found, 34 writable, 0 configurable as that closed sealed 34 keys, 34 found, 34 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: seal a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 34 keys, 34 found, 34 writable, 0 configurable as that closed sealed 34 keys, 34 found, 34 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: seal a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 34 keys, 34 found, 34 writable, 0 configurable as that closed sealed 34 keys, 34 found, 34 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: seal a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 34 keys, 34 found, 34 writable, 0 configurable as that closed sealed 34 keys, 34 found, 34 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: seal a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 34 keys, 34 found, 34 writable, 0 configurable as that closed sealed 34 keys, 34 found, 34 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: seal a Proxy that traps ownKeys, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed 34 keys, 34 found, 34 writable, 0 configurable as that closed sealed 34 keys, 34 found, 34 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: seal a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed 34 keys, 34 found, 34 writable, 0 configurable as that closed sealed 34 keys, 34 found, 34 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: seal a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 34 keys, 34 found, 34 writable, 0 configurable as that closed sealed 34 keys, 34 found, 34 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: preventExtensions itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 34 keys, 34 found, 34 writable, 34 configurable as that closed 34 keys, 34 found, 34 writable, 34 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: preventExtensions a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 34 keys, 34 found, 34 writable, 34 configurable as that closed 34 keys, 34 found, 34 writable, 34 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: preventExtensions a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 itself closed 34 keys, 34 found, 34 writable, 34 configurable as that closed 34 keys, 34 found, 34 writable, 34 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: preventExtensions a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 34 keys, 34 found, 34 writable, 34 configurable as that closed 34 keys, 34 found, 34 writable, 34 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: preventExtensions a Proxy with every trap, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed 34 keys, 34 found, 34 writable, 34 configurable as that closed 34 keys, 34 found, 34 writable, 34 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: preventExtensions a Proxy with every trap, then used as that traps while closing: 1 of them for a key that is not listed: 0 itself closed 34 keys, 34 found, 34 writable, 34 configurable as that closed 34 keys, 34 found, 34 writable, 34 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: preventExtensions a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 3 of them for a key that is not listed: 0 itself closed 34 keys, 34 found, 34 writable, 34 configurable as that closed 34 keys, 34 found, 34 writable, 34 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: preventExtensions a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 @@ -2761,61 +2761,61 @@ traps while closing: 0 of them for a key that is not listed: 0 itself closed 34 keys, 34 found, 34 writable, 34 configurable as that closed 34 keys, 34 found, 34 writable, 34 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: preventExtensions a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 34 keys, 34 found, 34 writable, 34 configurable as that closed 34 keys, 34 found, 34 writable, 34 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: preventExtensions a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 34 keys, 34 found, 34 writable, 34 configurable as that closed 34 keys, 34 found, 34 writable, 34 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: preventExtensions a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 34 keys, 34 found, 34 writable, 34 configurable as that closed 34 keys, 34 found, 34 writable, 34 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: preventExtensions a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 34 keys, 34 found, 34 writable, 34 configurable as that closed 34 keys, 34 found, 34 writable, 34 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: preventExtensions a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 34 keys, 34 found, 34 writable, 34 configurable as that closed 34 keys, 34 found, 34 writable, 34 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: preventExtensions a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 34 keys, 34 found, 34 writable, 34 configurable as that closed 34 keys, 34 found, 34 writable, 34 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: preventExtensions a Proxy that traps ownKeys, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 34 keys, 34 found, 34 writable, 34 configurable as that closed 34 keys, 34 found, 34 writable, 34 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: preventExtensions a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed 34 keys, 34 found, 34 writable, 34 configurable as that closed 34 keys, 34 found, 34 writable, 34 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's response: preventExtensions a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 34 keys, 34 found, 34 writable, 34 configurable as that closed 34 keys, 34 found, 34 writable, 34 configurable defined anew true added false removed true absent removed true absent found false --- request finish | response end | nobody caught TypeError in endReadableNT | request close | the response's close did not come +-- request finish | response end | response close | request close == a client's socket: nothing itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself open 46 keys, 46 found, 46 writable, 46 configurable @@ -3056,240 +3056,240 @@ defined anew true added false removed true absent removed true absent found fals -- request finish | response end | response close | request close == a server: nothing itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself open 37 keys, 37 found, 35 writable, 37 configurable -as that open 37 keys, 37 found, 35 writable, 37 configurable +itself open 45 keys, 45 found, 43 writable, 45 configurable +as that open 45 keys, 45 found, 43 writable, 45 configurable defined anew true added true removed true absent removed true absent found false -- answered answer | server close == a server: seal itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 37 keys, 37 found, 35 writable, 0 configurable -as that closed sealed 37 keys, 37 found, 35 writable, 0 configurable +itself closed sealed 45 keys, 45 found, 43 writable, 0 configurable +as that closed sealed 45 keys, 45 found, 43 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: seal a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 37 keys, 37 found, 35 writable, 0 configurable -as that closed sealed 37 keys, 37 found, 35 writable, 0 configurable +itself closed sealed 45 keys, 45 found, 43 writable, 0 configurable +as that closed sealed 45 keys, 45 found, 43 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: seal a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 37 keys, 37 found, 35 writable, 0 configurable -as that closed sealed 37 keys, 37 found, 35 writable, 0 configurable +itself closed sealed 45 keys, 45 found, 43 writable, 0 configurable +as that closed sealed 45 keys, 45 found, 43 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: seal a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 37 keys, 37 found, 35 writable, 0 configurable -as that closed sealed 37 keys, 37 found, 35 writable, 0 configurable +itself closed sealed 45 keys, 45 found, 43 writable, 0 configurable +as that closed sealed 45 keys, 45 found, 43 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: seal a Proxy with every trap, then used itself -traps while closing: 39 of them for a key that is not listed: 0 -itself closed sealed 37 keys, 37 found, 35 writable, 0 configurable -as that closed sealed 37 keys, 37 found, 35 writable, 0 configurable +traps while closing: 47 of them for a key that is not listed: 0 +itself closed sealed 45 keys, 45 found, 43 writable, 0 configurable +as that closed sealed 45 keys, 45 found, 43 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: seal a Proxy with every trap, then used as that -traps while closing: 39 of them for a key that is not listed: 0 -itself closed sealed 37 keys, 37 found, 35 writable, 0 configurable -as that closed sealed 37 keys, 37 found, 35 writable, 0 configurable +traps while closing: 47 of them for a key that is not listed: 0 +itself closed sealed 45 keys, 45 found, 43 writable, 0 configurable +as that closed sealed 45 keys, 45 found, 43 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: seal a Proxy with every trap of a Proxy with every trap, then used itself -traps while closing: 192 of them for a key that is not listed: 0 -itself closed sealed 37 keys, 37 found, 35 writable, 0 configurable -as that closed sealed 37 keys, 37 found, 35 writable, 0 configurable +traps while closing: 232 of them for a key that is not listed: 0 +itself closed sealed 45 keys, 45 found, 43 writable, 0 configurable +as that closed sealed 45 keys, 45 found, 43 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: seal a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 37 keys, 37 found, 35 writable, 37 configurable +itself open 45 keys, 45 found, 43 writable, 45 configurable asking threw TypeError changing threw TypeError -- answered answer | server close == a server: seal a Proxy of a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 37 keys, 37 found, 35 writable, 37 configurable +itself open 45 keys, 45 found, 43 writable, 45 configurable asking threw TypeError changing threw TypeError -- answered answer | server close == a server: seal a Proxy that traps defineProperty, then used itself -traps while closing: 37 of them for a key that is not listed: 0 -itself closed sealed 37 keys, 37 found, 35 writable, 0 configurable -as that closed sealed 37 keys, 37 found, 35 writable, 0 configurable +traps while closing: 45 of them for a key that is not listed: 0 +itself closed sealed 45 keys, 45 found, 43 writable, 0 configurable +as that closed sealed 45 keys, 45 found, 43 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: seal a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 37 keys, 37 found, 35 writable, 0 configurable -as that closed sealed 37 keys, 37 found, 35 writable, 0 configurable +itself closed sealed 45 keys, 45 found, 43 writable, 0 configurable +as that closed sealed 45 keys, 45 found, 43 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: seal a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 37 keys, 37 found, 35 writable, 0 configurable -as that closed sealed 37 keys, 37 found, 35 writable, 0 configurable +itself closed sealed 45 keys, 45 found, 43 writable, 0 configurable +as that closed sealed 45 keys, 45 found, 43 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: seal a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 37 keys, 37 found, 35 writable, 0 configurable -as that closed sealed 37 keys, 37 found, 35 writable, 0 configurable +itself closed sealed 45 keys, 45 found, 43 writable, 0 configurable +as that closed sealed 45 keys, 45 found, 43 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: seal a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 37 keys, 37 found, 35 writable, 0 configurable -as that closed sealed 37 keys, 37 found, 35 writable, 0 configurable +itself closed sealed 45 keys, 45 found, 43 writable, 0 configurable +as that closed sealed 45 keys, 45 found, 43 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: seal a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 37 keys, 37 found, 35 writable, 0 configurable -as that closed sealed 37 keys, 37 found, 35 writable, 0 configurable +itself closed sealed 45 keys, 45 found, 43 writable, 0 configurable +as that closed sealed 45 keys, 45 found, 43 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: seal a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 37 keys, 37 found, 35 writable, 0 configurable -as that closed sealed 37 keys, 37 found, 35 writable, 0 configurable +itself closed sealed 45 keys, 45 found, 43 writable, 0 configurable +as that closed sealed 45 keys, 45 found, 43 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: seal a Proxy that traps ownKeys, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed sealed 37 keys, 37 found, 35 writable, 0 configurable -as that closed sealed 37 keys, 37 found, 35 writable, 0 configurable +itself closed sealed 45 keys, 45 found, 43 writable, 0 configurable +as that closed sealed 45 keys, 45 found, 43 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: seal a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed sealed 37 keys, 37 found, 35 writable, 0 configurable -as that closed sealed 37 keys, 37 found, 35 writable, 0 configurable +itself closed sealed 45 keys, 45 found, 43 writable, 0 configurable +as that closed sealed 45 keys, 45 found, 43 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: seal a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed sealed 37 keys, 37 found, 35 writable, 0 configurable -as that closed sealed 37 keys, 37 found, 35 writable, 0 configurable +itself closed sealed 45 keys, 45 found, 43 writable, 0 configurable +as that closed sealed 45 keys, 45 found, 43 writable, 0 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: preventExtensions itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 37 keys, 37 found, 35 writable, 37 configurable -as that closed 37 keys, 37 found, 35 writable, 37 configurable +itself closed 45 keys, 45 found, 43 writable, 45 configurable +as that closed 45 keys, 45 found, 43 writable, 45 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: preventExtensions a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 37 keys, 37 found, 35 writable, 37 configurable -as that closed 37 keys, 37 found, 35 writable, 37 configurable +itself closed 45 keys, 45 found, 43 writable, 45 configurable +as that closed 45 keys, 45 found, 43 writable, 45 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: preventExtensions a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 -itself closed 37 keys, 37 found, 35 writable, 37 configurable -as that closed 37 keys, 37 found, 35 writable, 37 configurable +itself closed 45 keys, 45 found, 43 writable, 45 configurable +as that closed 45 keys, 45 found, 43 writable, 45 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: preventExtensions a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 37 keys, 37 found, 35 writable, 37 configurable -as that closed 37 keys, 37 found, 35 writable, 37 configurable +itself closed 45 keys, 45 found, 43 writable, 45 configurable +as that closed 45 keys, 45 found, 43 writable, 45 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: preventExtensions a Proxy with every trap, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed 37 keys, 37 found, 35 writable, 37 configurable -as that closed 37 keys, 37 found, 35 writable, 37 configurable +itself closed 45 keys, 45 found, 43 writable, 45 configurable +as that closed 45 keys, 45 found, 43 writable, 45 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: preventExtensions a Proxy with every trap, then used as that traps while closing: 1 of them for a key that is not listed: 0 -itself closed 37 keys, 37 found, 35 writable, 37 configurable -as that closed 37 keys, 37 found, 35 writable, 37 configurable +itself closed 45 keys, 45 found, 43 writable, 45 configurable +as that closed 45 keys, 45 found, 43 writable, 45 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: preventExtensions a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 3 of them for a key that is not listed: 0 -itself closed 37 keys, 37 found, 35 writable, 37 configurable -as that closed 37 keys, 37 found, 35 writable, 37 configurable +itself closed 45 keys, 45 found, 43 writable, 45 configurable +as that closed 45 keys, 45 found, 43 writable, 45 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: preventExtensions a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 37 keys, 37 found, 35 writable, 37 configurable +itself open 45 keys, 45 found, 43 writable, 45 configurable asking threw TypeError changing threw TypeError -- answered answer | server close == a server: preventExtensions a Proxy of a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 -itself open 37 keys, 37 found, 35 writable, 37 configurable +itself open 45 keys, 45 found, 43 writable, 45 configurable asking threw TypeError changing threw TypeError -- answered answer | server close == a server: preventExtensions a Proxy that traps defineProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 37 keys, 37 found, 35 writable, 37 configurable -as that closed 37 keys, 37 found, 35 writable, 37 configurable +itself closed 45 keys, 45 found, 43 writable, 45 configurable +as that closed 45 keys, 45 found, 43 writable, 45 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: preventExtensions a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 37 keys, 37 found, 35 writable, 37 configurable -as that closed 37 keys, 37 found, 35 writable, 37 configurable +itself closed 45 keys, 45 found, 43 writable, 45 configurable +as that closed 45 keys, 45 found, 43 writable, 45 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: preventExtensions a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 37 keys, 37 found, 35 writable, 37 configurable -as that closed 37 keys, 37 found, 35 writable, 37 configurable +itself closed 45 keys, 45 found, 43 writable, 45 configurable +as that closed 45 keys, 45 found, 43 writable, 45 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: preventExtensions a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 37 keys, 37 found, 35 writable, 37 configurable -as that closed 37 keys, 37 found, 35 writable, 37 configurable +itself closed 45 keys, 45 found, 43 writable, 45 configurable +as that closed 45 keys, 45 found, 43 writable, 45 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: preventExtensions a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 37 keys, 37 found, 35 writable, 37 configurable -as that closed 37 keys, 37 found, 35 writable, 37 configurable +itself closed 45 keys, 45 found, 43 writable, 45 configurable +as that closed 45 keys, 45 found, 43 writable, 45 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: preventExtensions a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 37 keys, 37 found, 35 writable, 37 configurable -as that closed 37 keys, 37 found, 35 writable, 37 configurable +itself closed 45 keys, 45 found, 43 writable, 45 configurable +as that closed 45 keys, 45 found, 43 writable, 45 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: preventExtensions a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 37 keys, 37 found, 35 writable, 37 configurable -as that closed 37 keys, 37 found, 35 writable, 37 configurable +itself closed 45 keys, 45 found, 43 writable, 45 configurable +as that closed 45 keys, 45 found, 43 writable, 45 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: preventExtensions a Proxy that traps ownKeys, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 37 keys, 37 found, 35 writable, 37 configurable -as that closed 37 keys, 37 found, 35 writable, 37 configurable +itself closed 45 keys, 45 found, 43 writable, 45 configurable +as that closed 45 keys, 45 found, 43 writable, 45 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: preventExtensions a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 -itself closed 37 keys, 37 found, 35 writable, 37 configurable -as that closed 37 keys, 37 found, 35 writable, 37 configurable +itself closed 45 keys, 45 found, 43 writable, 45 configurable +as that closed 45 keys, 45 found, 43 writable, 45 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a server: preventExtensions a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 -itself closed 37 keys, 37 found, 35 writable, 37 configurable -as that closed 37 keys, 37 found, 35 writable, 37 configurable +itself closed 45 keys, 45 found, 43 writable, 45 configurable +as that closed 45 keys, 45 found, 43 writable, 45 configurable defined anew true added false removed true absent removed true absent found false -- answered answer | server close == a readable stream: nothing itself, then used itself @@ -3303,43 +3303,43 @@ traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable as that closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: freeze a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable as that closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: freeze a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable as that closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: freeze a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable as that closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: freeze a Proxy with every trap, then used itself traps while closing: 18 of them for a key that is not listed: 0 itself closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable as that closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: freeze a Proxy with every trap, then used as that traps while closing: 18 of them for a key that is not listed: 0 itself closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable as that closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: freeze a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 79 of them for a key that is not listed: 0 itself closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable as that closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: freeze a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 @@ -3359,103 +3359,103 @@ traps while closing: 8 of them for a key that is not listed: 0 itself closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable as that closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: freeze a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable as that closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: freeze a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable as that closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: freeze a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 8 of them for a key that is not listed: 0 itself closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable as that closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: freeze a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable as that closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: freeze a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable as that closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: freeze a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable as that closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: freeze a Proxy that traps ownKeys, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable as that closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: freeze a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable as that closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: freeze a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable as that closed sealed frozen 8 keys, 8 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: seal itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 8 keys, 8 found, 8 writable, 0 configurable as that closed sealed 8 keys, 8 found, 8 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: seal a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 8 keys, 8 found, 8 writable, 0 configurable as that closed sealed 8 keys, 8 found, 8 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: seal a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 8 keys, 8 found, 8 writable, 0 configurable as that closed sealed 8 keys, 8 found, 8 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: seal a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 8 keys, 8 found, 8 writable, 0 configurable as that closed sealed 8 keys, 8 found, 8 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: seal a Proxy with every trap, then used itself traps while closing: 10 of them for a key that is not listed: 0 itself closed sealed 8 keys, 8 found, 8 writable, 0 configurable as that closed sealed 8 keys, 8 found, 8 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: seal a Proxy with every trap, then used as that traps while closing: 10 of them for a key that is not listed: 0 itself closed sealed 8 keys, 8 found, 8 writable, 0 configurable as that closed sealed 8 keys, 8 found, 8 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: seal a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 47 of them for a key that is not listed: 0 itself closed sealed 8 keys, 8 found, 8 writable, 0 configurable as that closed sealed 8 keys, 8 found, 8 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: seal a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 @@ -3475,103 +3475,103 @@ traps while closing: 8 of them for a key that is not listed: 0 itself closed sealed 8 keys, 8 found, 8 writable, 0 configurable as that closed sealed 8 keys, 8 found, 8 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: seal a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 8 keys, 8 found, 8 writable, 0 configurable as that closed sealed 8 keys, 8 found, 8 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: seal a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 8 keys, 8 found, 8 writable, 0 configurable as that closed sealed 8 keys, 8 found, 8 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: seal a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 8 keys, 8 found, 8 writable, 0 configurable as that closed sealed 8 keys, 8 found, 8 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: seal a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 8 keys, 8 found, 8 writable, 0 configurable as that closed sealed 8 keys, 8 found, 8 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: seal a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 8 keys, 8 found, 8 writable, 0 configurable as that closed sealed 8 keys, 8 found, 8 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: seal a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 8 keys, 8 found, 8 writable, 0 configurable as that closed sealed 8 keys, 8 found, 8 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: seal a Proxy that traps ownKeys, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed 8 keys, 8 found, 8 writable, 0 configurable as that closed sealed 8 keys, 8 found, 8 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: seal a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed 8 keys, 8 found, 8 writable, 0 configurable as that closed sealed 8 keys, 8 found, 8 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: seal a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 8 keys, 8 found, 8 writable, 0 configurable as that closed sealed 8 keys, 8 found, 8 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: preventExtensions itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 8 keys, 8 found, 8 writable, 8 configurable as that closed 8 keys, 8 found, 8 writable, 8 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: preventExtensions a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 8 keys, 8 found, 8 writable, 8 configurable as that closed 8 keys, 8 found, 8 writable, 8 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: preventExtensions a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 itself closed 8 keys, 8 found, 8 writable, 8 configurable as that closed 8 keys, 8 found, 8 writable, 8 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: preventExtensions a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 8 keys, 8 found, 8 writable, 8 configurable as that closed 8 keys, 8 found, 8 writable, 8 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: preventExtensions a Proxy with every trap, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed 8 keys, 8 found, 8 writable, 8 configurable as that closed 8 keys, 8 found, 8 writable, 8 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: preventExtensions a Proxy with every trap, then used as that traps while closing: 1 of them for a key that is not listed: 0 itself closed 8 keys, 8 found, 8 writable, 8 configurable as that closed 8 keys, 8 found, 8 writable, 8 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: preventExtensions a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 3 of them for a key that is not listed: 0 itself closed 8 keys, 8 found, 8 writable, 8 configurable as that closed 8 keys, 8 found, 8 writable, 8 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: preventExtensions a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 @@ -3591,61 +3591,61 @@ traps while closing: 0 of them for a key that is not listed: 0 itself closed 8 keys, 8 found, 8 writable, 8 configurable as that closed 8 keys, 8 found, 8 writable, 8 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: preventExtensions a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 8 keys, 8 found, 8 writable, 8 configurable as that closed 8 keys, 8 found, 8 writable, 8 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: preventExtensions a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 8 keys, 8 found, 8 writable, 8 configurable as that closed 8 keys, 8 found, 8 writable, 8 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: preventExtensions a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 8 keys, 8 found, 8 writable, 8 configurable as that closed 8 keys, 8 found, 8 writable, 8 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: preventExtensions a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 8 keys, 8 found, 8 writable, 8 configurable as that closed 8 keys, 8 found, 8 writable, 8 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: preventExtensions a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 8 keys, 8 found, 8 writable, 8 configurable as that closed 8 keys, 8 found, 8 writable, 8 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: preventExtensions a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 8 keys, 8 found, 8 writable, 8 configurable as that closed 8 keys, 8 found, 8 writable, 8 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: preventExtensions a Proxy that traps ownKeys, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 8 keys, 8 found, 8 writable, 8 configurable as that closed 8 keys, 8 found, 8 writable, 8 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: preventExtensions a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed 8 keys, 8 found, 8 writable, 8 configurable as that closed 8 keys, 8 found, 8 writable, 8 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a readable stream: preventExtensions a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 8 keys, 8 found, 8 writable, 8 configurable as that closed 8 keys, 8 found, 8 writable, 8 configurable defined anew true added false removed true absent removed true absent found false --- stream end | nobody caught TypeError in endReadableNT +-- stream end | stream close == a stream that passes through: nothing itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself open 9 keys, 9 found, 9 writable, 9 configurable @@ -3657,43 +3657,43 @@ traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable as that closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: freeze a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable as that closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: freeze a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable as that closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: freeze a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable as that closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: freeze a Proxy with every trap, then used itself traps while closing: 20 of them for a key that is not listed: 0 itself closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable as that closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: freeze a Proxy with every trap, then used as that traps while closing: 20 of them for a key that is not listed: 0 itself closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable as that closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: freeze a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 88 of them for a key that is not listed: 0 itself closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable as that closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: freeze a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 @@ -3713,103 +3713,103 @@ traps while closing: 9 of them for a key that is not listed: 0 itself closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable as that closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: freeze a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable as that closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: freeze a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable as that closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: freeze a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 9 of them for a key that is not listed: 0 itself closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable as that closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: freeze a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable as that closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: freeze a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable as that closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: freeze a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable as that closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: freeze a Proxy that traps ownKeys, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable as that closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: freeze a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable as that closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: freeze a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable as that closed sealed frozen 9 keys, 9 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: seal itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 9 keys, 9 found, 9 writable, 0 configurable as that closed sealed 9 keys, 9 found, 9 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: seal a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 9 keys, 9 found, 9 writable, 0 configurable as that closed sealed 9 keys, 9 found, 9 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: seal a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 9 keys, 9 found, 9 writable, 0 configurable as that closed sealed 9 keys, 9 found, 9 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: seal a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 9 keys, 9 found, 9 writable, 0 configurable as that closed sealed 9 keys, 9 found, 9 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: seal a Proxy with every trap, then used itself traps while closing: 11 of them for a key that is not listed: 0 itself closed sealed 9 keys, 9 found, 9 writable, 0 configurable as that closed sealed 9 keys, 9 found, 9 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: seal a Proxy with every trap, then used as that traps while closing: 11 of them for a key that is not listed: 0 itself closed sealed 9 keys, 9 found, 9 writable, 0 configurable as that closed sealed 9 keys, 9 found, 9 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: seal a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 52 of them for a key that is not listed: 0 itself closed sealed 9 keys, 9 found, 9 writable, 0 configurable as that closed sealed 9 keys, 9 found, 9 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: seal a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 @@ -3829,103 +3829,103 @@ traps while closing: 9 of them for a key that is not listed: 0 itself closed sealed 9 keys, 9 found, 9 writable, 0 configurable as that closed sealed 9 keys, 9 found, 9 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: seal a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 9 keys, 9 found, 9 writable, 0 configurable as that closed sealed 9 keys, 9 found, 9 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: seal a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 9 keys, 9 found, 9 writable, 0 configurable as that closed sealed 9 keys, 9 found, 9 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: seal a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 9 keys, 9 found, 9 writable, 0 configurable as that closed sealed 9 keys, 9 found, 9 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: seal a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 9 keys, 9 found, 9 writable, 0 configurable as that closed sealed 9 keys, 9 found, 9 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: seal a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 9 keys, 9 found, 9 writable, 0 configurable as that closed sealed 9 keys, 9 found, 9 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: seal a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 9 keys, 9 found, 9 writable, 0 configurable as that closed sealed 9 keys, 9 found, 9 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: seal a Proxy that traps ownKeys, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed 9 keys, 9 found, 9 writable, 0 configurable as that closed sealed 9 keys, 9 found, 9 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: seal a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed 9 keys, 9 found, 9 writable, 0 configurable as that closed sealed 9 keys, 9 found, 9 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: seal a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 9 keys, 9 found, 9 writable, 0 configurable as that closed sealed 9 keys, 9 found, 9 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: preventExtensions itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 9 keys, 9 found, 9 writable, 9 configurable as that closed 9 keys, 9 found, 9 writable, 9 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: preventExtensions a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 9 keys, 9 found, 9 writable, 9 configurable as that closed 9 keys, 9 found, 9 writable, 9 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: preventExtensions a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 itself closed 9 keys, 9 found, 9 writable, 9 configurable as that closed 9 keys, 9 found, 9 writable, 9 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: preventExtensions a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 9 keys, 9 found, 9 writable, 9 configurable as that closed 9 keys, 9 found, 9 writable, 9 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: preventExtensions a Proxy with every trap, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed 9 keys, 9 found, 9 writable, 9 configurable as that closed 9 keys, 9 found, 9 writable, 9 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: preventExtensions a Proxy with every trap, then used as that traps while closing: 1 of them for a key that is not listed: 0 itself closed 9 keys, 9 found, 9 writable, 9 configurable as that closed 9 keys, 9 found, 9 writable, 9 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: preventExtensions a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 3 of them for a key that is not listed: 0 itself closed 9 keys, 9 found, 9 writable, 9 configurable as that closed 9 keys, 9 found, 9 writable, 9 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: preventExtensions a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 @@ -3945,61 +3945,61 @@ traps while closing: 0 of them for a key that is not listed: 0 itself closed 9 keys, 9 found, 9 writable, 9 configurable as that closed 9 keys, 9 found, 9 writable, 9 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: preventExtensions a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 9 keys, 9 found, 9 writable, 9 configurable as that closed 9 keys, 9 found, 9 writable, 9 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: preventExtensions a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 9 keys, 9 found, 9 writable, 9 configurable as that closed 9 keys, 9 found, 9 writable, 9 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: preventExtensions a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 9 keys, 9 found, 9 writable, 9 configurable as that closed 9 keys, 9 found, 9 writable, 9 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: preventExtensions a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 9 keys, 9 found, 9 writable, 9 configurable as that closed 9 keys, 9 found, 9 writable, 9 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: preventExtensions a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 9 keys, 9 found, 9 writable, 9 configurable as that closed 9 keys, 9 found, 9 writable, 9 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: preventExtensions a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 9 keys, 9 found, 9 writable, 9 configurable as that closed 9 keys, 9 found, 9 writable, 9 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: preventExtensions a Proxy that traps ownKeys, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 9 keys, 9 found, 9 writable, 9 configurable as that closed 9 keys, 9 found, 9 writable, 9 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: preventExtensions a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed 9 keys, 9 found, 9 writable, 9 configurable as that closed 9 keys, 9 found, 9 writable, 9 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a stream that passes through: preventExtensions a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 9 keys, 9 found, 9 writable, 9 configurable as that closed 9 keys, 9 found, 9 writable, 9 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: nothing itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself open 10 keys, 10 found, 10 writable, 10 configurable @@ -4011,43 +4011,43 @@ traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable as that closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: freeze a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable as that closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: freeze a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable as that closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: freeze a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable as that closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: freeze a Proxy with every trap, then used itself traps while closing: 22 of them for a key that is not listed: 0 itself closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable as that closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: freeze a Proxy with every trap, then used as that traps while closing: 22 of them for a key that is not listed: 0 itself closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable as that closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: freeze a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 97 of them for a key that is not listed: 0 itself closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable as that closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: freeze a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 @@ -4067,103 +4067,103 @@ traps while closing: 10 of them for a key that is not listed: 0 itself closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable as that closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: freeze a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable as that closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: freeze a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable as that closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: freeze a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 10 of them for a key that is not listed: 0 itself closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable as that closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: freeze a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable as that closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: freeze a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable as that closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: freeze a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable as that closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: freeze a Proxy that traps ownKeys, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable as that closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: freeze a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable as that closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: freeze a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable as that closed sealed frozen 10 keys, 10 found, 0 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: seal itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 10 keys, 10 found, 10 writable, 0 configurable as that closed sealed 10 keys, 10 found, 10 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: seal a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 10 keys, 10 found, 10 writable, 0 configurable as that closed sealed 10 keys, 10 found, 10 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: seal a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 10 keys, 10 found, 10 writable, 0 configurable as that closed sealed 10 keys, 10 found, 10 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: seal a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 10 keys, 10 found, 10 writable, 0 configurable as that closed sealed 10 keys, 10 found, 10 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: seal a Proxy with every trap, then used itself traps while closing: 12 of them for a key that is not listed: 0 itself closed sealed 10 keys, 10 found, 10 writable, 0 configurable as that closed sealed 10 keys, 10 found, 10 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: seal a Proxy with every trap, then used as that traps while closing: 12 of them for a key that is not listed: 0 itself closed sealed 10 keys, 10 found, 10 writable, 0 configurable as that closed sealed 10 keys, 10 found, 10 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: seal a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 57 of them for a key that is not listed: 0 itself closed sealed 10 keys, 10 found, 10 writable, 0 configurable as that closed sealed 10 keys, 10 found, 10 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: seal a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 @@ -4183,103 +4183,103 @@ traps while closing: 10 of them for a key that is not listed: 0 itself closed sealed 10 keys, 10 found, 10 writable, 0 configurable as that closed sealed 10 keys, 10 found, 10 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: seal a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 10 keys, 10 found, 10 writable, 0 configurable as that closed sealed 10 keys, 10 found, 10 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: seal a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 10 keys, 10 found, 10 writable, 0 configurable as that closed sealed 10 keys, 10 found, 10 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: seal a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 10 keys, 10 found, 10 writable, 0 configurable as that closed sealed 10 keys, 10 found, 10 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: seal a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 10 keys, 10 found, 10 writable, 0 configurable as that closed sealed 10 keys, 10 found, 10 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: seal a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 10 keys, 10 found, 10 writable, 0 configurable as that closed sealed 10 keys, 10 found, 10 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: seal a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 10 keys, 10 found, 10 writable, 0 configurable as that closed sealed 10 keys, 10 found, 10 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: seal a Proxy that traps ownKeys, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed 10 keys, 10 found, 10 writable, 0 configurable as that closed sealed 10 keys, 10 found, 10 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: seal a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed sealed 10 keys, 10 found, 10 writable, 0 configurable as that closed sealed 10 keys, 10 found, 10 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: seal a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed sealed 10 keys, 10 found, 10 writable, 0 configurable as that closed sealed 10 keys, 10 found, 10 writable, 0 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: preventExtensions itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 10 keys, 10 found, 10 writable, 10 configurable as that closed 10 keys, 10 found, 10 writable, 10 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: preventExtensions a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 10 keys, 10 found, 10 writable, 10 configurable as that closed 10 keys, 10 found, 10 writable, 10 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: preventExtensions a Proxy, then used as that traps while closing: 0 of them for a key that is not listed: 0 itself closed 10 keys, 10 found, 10 writable, 10 configurable as that closed 10 keys, 10 found, 10 writable, 10 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: preventExtensions a Proxy of a Proxy, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 10 keys, 10 found, 10 writable, 10 configurable as that closed 10 keys, 10 found, 10 writable, 10 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: preventExtensions a Proxy with every trap, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed 10 keys, 10 found, 10 writable, 10 configurable as that closed 10 keys, 10 found, 10 writable, 10 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: preventExtensions a Proxy with every trap, then used as that traps while closing: 1 of them for a key that is not listed: 0 itself closed 10 keys, 10 found, 10 writable, 10 configurable as that closed 10 keys, 10 found, 10 writable, 10 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: preventExtensions a Proxy with every trap of a Proxy with every trap, then used itself traps while closing: 3 of them for a key that is not listed: 0 itself closed 10 keys, 10 found, 10 writable, 10 configurable as that closed 10 keys, 10 found, 10 writable, 10 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: preventExtensions a revoked Proxy, then used itself closing threw TypeError traps while closing: 0 of them for a key that is not listed: 0 @@ -4299,61 +4299,61 @@ traps while closing: 0 of them for a key that is not listed: 0 itself closed 10 keys, 10 found, 10 writable, 10 configurable as that closed 10 keys, 10 found, 10 writable, 10 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: preventExtensions a Proxy that traps deleteProperty, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 10 keys, 10 found, 10 writable, 10 configurable as that closed 10 keys, 10 found, 10 writable, 10 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: preventExtensions a Proxy that traps get, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 10 keys, 10 found, 10 writable, 10 configurable as that closed 10 keys, 10 found, 10 writable, 10 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: preventExtensions a Proxy that traps getOwnPropertyDescriptor, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 10 keys, 10 found, 10 writable, 10 configurable as that closed 10 keys, 10 found, 10 writable, 10 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: preventExtensions a Proxy that traps getPrototypeOf, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 10 keys, 10 found, 10 writable, 10 configurable as that closed 10 keys, 10 found, 10 writable, 10 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: preventExtensions a Proxy that traps has, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 10 keys, 10 found, 10 writable, 10 configurable as that closed 10 keys, 10 found, 10 writable, 10 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: preventExtensions a Proxy that traps isExtensible, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 10 keys, 10 found, 10 writable, 10 configurable as that closed 10 keys, 10 found, 10 writable, 10 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: preventExtensions a Proxy that traps ownKeys, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 10 keys, 10 found, 10 writable, 10 configurable as that closed 10 keys, 10 found, 10 writable, 10 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: preventExtensions a Proxy that traps preventExtensions, then used itself traps while closing: 1 of them for a key that is not listed: 0 itself closed 10 keys, 10 found, 10 writable, 10 configurable as that closed 10 keys, 10 found, 10 writable, 10 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a duplex stream: preventExtensions a Proxy that traps set, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself closed 10 keys, 10 found, 10 writable, 10 configurable as that closed 10 keys, 10 found, 10 writable, 10 configurable defined anew true added false removed true absent removed true absent found false --- stream finish | stream end | nobody caught TypeError in endReadableNT +-- stream finish | stream end | stream close == a writable stream: nothing itself, then used itself traps while closing: 0 of them for a key that is not listed: 0 itself open 7 keys, 7 found, 7 writable, 7 configurable diff --git a/test/aot/parallel/the-own-names-of-what-a-server-makes/a-response-a-connection-and-a-stand-in-handle-list-the-same-names.mjs b/test/aot/parallel/the-own-names-of-what-a-server-makes/a-response-a-connection-and-a-stand-in-handle-list-the-same-names.mjs index 109fb47aa903..d5678836f078 100644 --- a/test/aot/parallel/the-own-names-of-what-a-server-makes/a-response-a-connection-and-a-stand-in-handle-list-the-same-names.mjs +++ b/test/aot/parallel/the-own-names-of-what-a-server-makes/a-response-a-connection-and-a-stand-in-handle-list-the-same-names.mjs @@ -1,4 +1,4 @@ -// Whoever lists or prints what a server makes sees the same own names as without compilation, in the same order: a response at six moments of an answer, among them behind an empty chunk under a Content-Length; a connection that two requests were pipelined on; the handle that stands in for the native one on a connection handed over by server.emit("connection", socket). +// Whoever lists or prints what a server makes sees the same own names as without compilation, in the same order: a response at six moments of an answer, among them behind an empty chunk under a Content-Length; a connection that two requests were pipelined on; a response on a connection handed over by server.emit("connection", socket), which has no native handle. import http from "node:http"; import net from "node:net"; const lines = []; @@ -29,9 +29,10 @@ const server = http.createServer((req, res) => { if (req.url === "/b") setImmediate(() => note("the connection behind both", req.socket)); }, 10); } else { - const handle = res[Object.getOwnPropertySymbols(res).find(symbol => symbol.description === "handle")]; - note("the stand-in handle", handle); - lines.push(`above it stands Object.prototype: ${Object.getPrototypeOf(handle) === Object.prototype}`); + const handle = Object.getOwnPropertySymbols(res).find(symbol => symbol.description === "handle"); + note("a response on a connection handed over", res); + note("the connection handed over", req.socket); + lines.push(`its handle: ${res[handle]}`); res.end("ok"); } }); @@ -49,7 +50,7 @@ await new Promise(resolve => { const front = net.createServer(socket => server.emit("connection", socket)); await new Promise(resolve => front.listen(0, "127.0.0.1", resolve)); lines.push( - `over the stand-in: ${await new Promise(resolve => + `over the connection handed over: ${await new Promise(resolve => http.get({ host: "127.0.0.1", port: front.address().port, path: "/handed", agent: false }, got => { let text = ""; got.on("data", chunk => (text += chunk)).on("end", () => resolve(`${got.statusCode} ${text}`)); diff --git a/test/aot/parallel/the-questions-of-the-library-say-yes/a-quiet-server-beside-a-class-below-whose-prototype-gets-a-getter.mjs b/test/aot/parallel/the-questions-of-the-library-say-yes/a-quiet-server-beside-a-class-below-whose-prototype-gets-a-getter.mjs index 49ed3fc0c797..8cb1f54bf492 100644 --- a/test/aot/parallel/the-questions-of-the-library-say-yes/a-quiet-server-beside-a-class-below-whose-prototype-gets-a-getter.mjs +++ b/test/aot/parallel/the-questions-of-the-library-say-yes/a-quiet-server-beside-a-class-below-whose-prototype-gets-a-getter.mjs @@ -2,7 +2,8 @@ // The twin of a-quiet-server-of-node-http.mjs: the same server, the same two hundred requests. Beside it stands a second server whose requests are of a class below the library's; it has answered one request, // and then the program lays a getter on the prototype of that class. That ends a guard of that class, and of no other: each class that a door takes has its own count. // As long as one count answered for all the classes of a door, the plain server's requests heard no from then on, and ran a third slower: right in every answer, and seen by no test. -// COUNTER: LibraryFastPathChecksPassed >= 11500 +// The body of a POST goes through push(), and its end is a microtask behind the last chunk, as in Node: no short road of its own. +// COUNTER: LibraryFastPathChecksPassed >= 10600 // STATISTIC: MemberLookupChecksLeftGeneric <= 0 import http from "node:http"; import net from "node:net"; diff --git a/test/aot/parallel/the-questions-of-the-library-say-yes/a-quiet-server-of-node-http.mjs b/test/aot/parallel/the-questions-of-the-library-say-yes/a-quiet-server-of-node-http.mjs index 5cd7ec1ad40d..0ab0cd7c5db4 100644 --- a/test/aot/parallel/the-questions-of-the-library-say-yes/a-quiet-server-of-node-http.mjs +++ b/test/aot/parallel/the-questions-of-the-library-say-yes/a-quiet-server-of-node-http.mjs @@ -2,7 +2,8 @@ // The short roads of node:http stand behind questions that the library asks of its own objects: $isPlainInstanceOf, $isPlainHeirOf, $findsDeclaredMember, $findsNoMember. // A question that is compiled and says no is no fault: the long road answers the same, a third slower, and no test sees it. Under the word that checks the walk each yes is held to the object, and counted. // A hundred GETs and a hundred POSTs on one kept connection of a server that nobody has touched: the floor stands close under what they count. Whoever takes a yes away finds this test; whoever adds a question raises the floor. -// COUNTER: LibraryFastPathChecksPassed >= 10900 +// The body of a POST goes through push(), and its end is a microtask behind the last chunk, as in Node: no short road of its own. +// COUNTER: LibraryFastPathChecksPassed >= 10000 // STATISTIC: MemberLookupChecksLeftGeneric <= 0 import http from "node:http"; import net from "node:net"; diff --git a/test/aot/parallel/the-short-roads-of-the-http-server/resume-and-remove-listener-are-both-replaced-in-front-of-the-load.cjs b/test/aot/parallel/the-short-roads-of-the-http-server/resume-and-remove-listener-are-both-replaced-in-front-of-the-load.cjs index 8e24e61b600c..6c8dd9d47f1f 100644 --- a/test/aot/parallel/the-short-roads-of-the-http-server/resume-and-remove-listener-are-both-replaced-in-front-of-the-load.cjs +++ b/test/aot/parallel/the-short-roads-of-the-http-server/resume-and-remove-listener-are-both-replaced-in-front-of-the-load.cjs @@ -33,5 +33,5 @@ function run(done) { } run(() => {}); /* Output: -answers 4 resume 4 removeListener 4 +answers 4 resume 5 removeListener 4 */ diff --git a/test/aot/parallel/the-short-roads-of-the-http-server/resume-of-readable-is-replaced-in-front-of-the-load.cjs b/test/aot/parallel/the-short-roads-of-the-http-server/resume-of-readable-is-replaced-in-front-of-the-load.cjs index c108d07e3d8e..a4073b69ff0a 100644 --- a/test/aot/parallel/the-short-roads-of-the-http-server/resume-of-readable-is-replaced-in-front-of-the-load.cjs +++ b/test/aot/parallel/the-short-roads-of-the-http-server/resume-of-readable-is-replaced-in-front-of-the-load.cjs @@ -26,5 +26,5 @@ server.listen(0, () => { }); }); /* Output: -answers 4 resume 4 +answers 4 resume 5 */ diff --git a/test/aot/parallel/the-short-roads-of-the-http-server/resume-of-the-socket-is-replaced-in-front-of-listen.mjs b/test/aot/parallel/the-short-roads-of-the-http-server/resume-of-the-socket-is-replaced-in-front-of-listen.mjs index 1b5e0ed1d736..f0bc88c63b51 100644 --- a/test/aot/parallel/the-short-roads-of-the-http-server/resume-of-the-socket-is-replaced-in-front-of-listen.mjs +++ b/test/aot/parallel/the-short-roads-of-the-http-server/resume-of-the-socket-is-replaced-in-front-of-listen.mjs @@ -27,5 +27,5 @@ server.listen(0, () => { }); }); /* Output: -connections 1 answers 4 resume 4 +connections 1 answers 4 resume 5 */ diff --git a/test/aot/parallel/the-short-roads-of-the-http-server/declared-the-writable-state-of-the-socket-is-replaced.mjs b/test/aot/parallel/the-short-roads-of-the-http-server/the-writable-state-of-the-socket-is-replaced.mjs similarity index 69% rename from test/aot/parallel/the-short-roads-of-the-http-server/declared-the-writable-state-of-the-socket-is-replaced.mjs rename to test/aot/parallel/the-short-roads-of-the-http-server/the-writable-state-of-the-socket-is-replaced.mjs index 7b02681afceb..c2cb61ec1cc1 100644 --- a/test/aot/parallel/the-short-roads-of-the-http-server/declared-the-writable-state-of-the-socket-is-replaced.mjs +++ b/test/aot/parallel/the-short-roads-of-the-http-server/the-writable-state-of-the-socket-is-replaced.mjs @@ -1,5 +1,4 @@ -// Differs from `bun run` by design. socket._writableState is replaced by a copy with a mark of 1. `bun ` and `bun build --compile` print -// /first true false | /second false true | /third false true. The short road of write() goes by the state that the socket had: to ask at each request whether it is still that one costs 54 to 140 instructions. +// socket._writableState is replaced by a copy with a mark of 1: write() answers by the copy, as without compilation. import http from "node:http"; import net from "node:net"; const log = []; @@ -25,6 +24,3 @@ server.listen(0, "127.0.0.1", () => { client.destroy(); server.close(); console.log(log.join(" | ")); }); }); -/* Output: -/first true false | /second true true | /third true true -*/ diff --git a/test/aot/parallel/the-words-of-an-error-inside-the-library/what-the-library-calls-is-no-function.mjs b/test/aot/parallel/the-words-of-an-error-inside-the-library/what-the-library-calls-is-no-function.mjs index 20cbf6a5a91d..15614e477e78 100644 --- a/test/aot/parallel/the-words-of-an-error-inside-the-library/what-the-library-calls-is-no-function.mjs +++ b/test/aot/parallel/the-words-of-an-error-inside-the-library/what-the-library-calls-is-no-function.mjs @@ -1,18 +1,12 @@ -// compatibility.mdx, "Error messages": the message names the value. `bun run` names the expression in Bun's own source: "OutgoingMessagePrototype.setHeader.@call is not a function. (In ..". +// compatibility.mdx, "Error messages": the message names the value. `bun run` names the expression in Bun's own source: "OutgoingMessagePrototype.cork.@call is not a function. (In ..". import http from "node:http"; -Object.defineProperty(http.OutgoingMessage.prototype, "setHeader", { configurable: true, writable: true, value: null }); -const server = http.createServer((request, response) => { - try { - response.setHeader("Content-Length", "2"); - } catch (error) { - console.log(error.name, error.message); - } - response.end("ok"); -}); -server.listen(0, async () => { - await fetch("http://localhost:" + server.address().port).then(answer => answer.text()); - server.close(); -}); +Object.defineProperty(http.OutgoingMessage.prototype, "cork", { configurable: true, writable: true, value: null }); +const response = new http.ServerResponse(new http.IncomingMessage()); +try { + response.cork(); +} catch (error) { + console.log(error.name, error.message); +} /* Output: -TypeError null is not a function (near '...OutgoingMessagePrototype.setHeader...') +TypeError null is not a function (near '...OutgoingMessagePrototype.cork...') */ diff --git a/test/aot/parallel/what-a-program-puts-in-place-is-honored/the-pipelined-responses-of-a-connection-emptied.mjs b/test/aot/parallel/what-a-program-puts-in-place-is-honored/the-pipelined-responses-of-a-connection-emptied.mjs index 35e56bf7c142..0e8412daf273 100644 --- a/test/aot/parallel/what-a-program-puts-in-place-is-honored/the-pipelined-responses-of-a-connection-emptied.mjs +++ b/test/aot/parallel/what-a-program-puts-in-place-is-honored/the-pipelined-responses-of-a-connection-emptied.mjs @@ -9,13 +9,13 @@ const server = http.createServer((req, res) => { if (req.url !== "/one") return res.end("two"); setTimeout(() => { const socket = req.socket; - const symbol = Object.getOwnPropertySymbols(socket).find(each => each.description === "kPipelinedResponses"); + const symbol = Object.getOwnPropertySymbols(socket).find(each => each.description === "kConnectionState"); log.push("the name is there: " + (symbol !== undefined)); if (symbol !== undefined) { const held = socket[symbol]; - log.push("it holds " + (Array.isArray(held) ? "a list of " + held.length : String(held))); + log.push("it holds " + (Array.isArray(held?.outgoing) ? "a list of " + held.outgoing.length : String(held))); if (way === "forgotten") socket[symbol] = undefined; - if (way === "emptied") socket[symbol] = []; + if (way === "emptied") held.outgoing = []; if (way === "watched") Object.defineProperty(socket, symbol, { configurable: true, diff --git a/test/aot/parallel/what-a-program-puts-in-place-is-honored/the-pipelined-responses-of-a-connection-forgotten.mjs b/test/aot/parallel/what-a-program-puts-in-place-is-honored/the-pipelined-responses-of-a-connection-forgotten.mjs index 376ecc0697d5..cff34ce416ea 100644 --- a/test/aot/parallel/what-a-program-puts-in-place-is-honored/the-pipelined-responses-of-a-connection-forgotten.mjs +++ b/test/aot/parallel/what-a-program-puts-in-place-is-honored/the-pipelined-responses-of-a-connection-forgotten.mjs @@ -9,13 +9,13 @@ const server = http.createServer((req, res) => { if (req.url !== "/one") return res.end("two"); setTimeout(() => { const socket = req.socket; - const symbol = Object.getOwnPropertySymbols(socket).find(each => each.description === "kPipelinedResponses"); + const symbol = Object.getOwnPropertySymbols(socket).find(each => each.description === "kConnectionState"); log.push("the name is there: " + (symbol !== undefined)); if (symbol !== undefined) { const held = socket[symbol]; - log.push("it holds " + (Array.isArray(held) ? "a list of " + held.length : String(held))); + log.push("it holds " + (Array.isArray(held?.outgoing) ? "a list of " + held.outgoing.length : String(held))); if (way === "forgotten") socket[symbol] = undefined; - if (way === "emptied") socket[symbol] = []; + if (way === "emptied") held.outgoing = []; if (way === "watched") Object.defineProperty(socket, symbol, { configurable: true, diff --git a/test/aot/parallel/what-a-program-puts-in-place-is-honored/the-pipelined-responses-of-a-connection-left-alone.mjs b/test/aot/parallel/what-a-program-puts-in-place-is-honored/the-pipelined-responses-of-a-connection-left-alone.mjs index b671e9b613c2..667eeac86fe3 100644 --- a/test/aot/parallel/what-a-program-puts-in-place-is-honored/the-pipelined-responses-of-a-connection-left-alone.mjs +++ b/test/aot/parallel/what-a-program-puts-in-place-is-honored/the-pipelined-responses-of-a-connection-left-alone.mjs @@ -1,4 +1,4 @@ -// Two requests are pipelined on one connection. The first listener finds the private name of the pipelined responses among the own symbols of the socket and leaves it alone: the control of the three files beside this one. +// Two requests are pipelined on one connection. The first listener finds the private name of what holds the pipelined responses among the own symbols of the socket and leaves it alone: the control of the three files beside this one. import http from "node:http"; import net from "node:net"; const way = process.env.WAY ?? "untouched"; @@ -9,13 +9,13 @@ const server = http.createServer((req, res) => { if (req.url !== "/one") return res.end("two"); setTimeout(() => { const socket = req.socket; - const symbol = Object.getOwnPropertySymbols(socket).find(each => each.description === "kPipelinedResponses"); + const symbol = Object.getOwnPropertySymbols(socket).find(each => each.description === "kConnectionState"); log.push("the name is there: " + (symbol !== undefined)); if (symbol !== undefined) { const held = socket[symbol]; - log.push("it holds " + (Array.isArray(held) ? "a list of " + held.length : String(held))); + log.push("it holds " + (Array.isArray(held?.outgoing) ? "a list of " + held.outgoing.length : String(held))); if (way === "forgotten") socket[symbol] = undefined; - if (way === "emptied") socket[symbol] = []; + if (way === "emptied") held.outgoing = []; if (way === "watched") Object.defineProperty(socket, symbol, { configurable: true, diff --git a/test/aot/parallel/what-a-program-puts-in-place-is-honored/the-pipelined-responses-of-a-connection-watched.mjs b/test/aot/parallel/what-a-program-puts-in-place-is-honored/the-pipelined-responses-of-a-connection-watched.mjs index 2680d9455ca6..32f23dd4e406 100644 --- a/test/aot/parallel/what-a-program-puts-in-place-is-honored/the-pipelined-responses-of-a-connection-watched.mjs +++ b/test/aot/parallel/what-a-program-puts-in-place-is-honored/the-pipelined-responses-of-a-connection-watched.mjs @@ -9,13 +9,13 @@ const server = http.createServer((req, res) => { if (req.url !== "/one") return res.end("two"); setTimeout(() => { const socket = req.socket; - const symbol = Object.getOwnPropertySymbols(socket).find(each => each.description === "kPipelinedResponses"); + const symbol = Object.getOwnPropertySymbols(socket).find(each => each.description === "kConnectionState"); log.push("the name is there: " + (symbol !== undefined)); if (symbol !== undefined) { const held = socket[symbol]; - log.push("it holds " + (Array.isArray(held) ? "a list of " + held.length : String(held))); + log.push("it holds " + (Array.isArray(held?.outgoing) ? "a list of " + held.outgoing.length : String(held))); if (way === "forgotten") socket[symbol] = undefined; - if (way === "emptied") socket[symbol] = []; + if (way === "emptied") held.outgoing = []; if (way === "watched") Object.defineProperty(socket, symbol, { configurable: true, diff --git a/test/aot/the-library-compiled-from-the-texts-that-bun-run-evaluates.test.ts b/test/aot/the-library-compiled-from-the-texts-that-bun-run-evaluates.test.ts index dd1a04fa41eb..d0a378898f6d 100644 --- a/test/aot/the-library-compiled-from-the-texts-that-bun-run-evaluates.test.ts +++ b/test/aot/the-library-compiled-from-the-texts-that-bun-run-evaluates.test.ts @@ -37,7 +37,11 @@ async function printedInThreeWays(program: string) { test("a difference that the second text of node:http declares is gone under the word", async () => { // If that difference is ever fixed, this test needs another program that tells the texts apart: without one it does not show that the word is read. const { plain, second, first } = await printedInThreeWays( - join(parallel, "the-short-roads-of-the-http-server", "declared-the-writable-state-of-the-socket-is-replaced.mjs"), + join( + parallel, + "what-lies-under-the-headers-of-a-response", + "declared-what-a-stand-in-for-the-handle-does-to-the-list-that-it-is-handed.cjs", + ), ); expect(plain.exitCode).toBe(0); expect(first).toEqual(plain); diff --git a/test/aot/the-socket-of-an-http-server-names-the-socket-of-net-as-its-base.test.ts b/test/aot/the-socket-of-an-http-server-has-the-layout-of-the-socket-of-net.test.ts similarity index 65% rename from test/aot/the-socket-of-an-http-server-names-the-socket-of-net-as-its-base.test.ts rename to test/aot/the-socket-of-an-http-server-has-the-layout-of-the-socket-of-net.test.ts index 8f0e9e291c88..b9b614670121 100644 --- a/test/aot/the-socket-of-an-http-server-names-the-socket-of-net-as-its-base.test.ts +++ b/test/aot/the-socket-of-an-http-server-has-the-layout-of-the-socket-of-net.test.ts @@ -1,7 +1,6 @@ -// The class of the sockets that node:http's server hands out is written over `Socket` of node:net, a constructor function in another of Bun's -// modules. It names that function's layout as its base in the program that the front end writes, so its objects hold what EventEmitter, Readable -// and Writable lay on them where every other stream holds it. What a program gets that has stored another class in net.Socket is held by -// parallel/what-stands-above-the-socket-of-an-http-server; without the base those programs would hold nothing. +// The sockets that node:http's server hands out are made by `Socket` of node:net, a constructor function in another of Bun's modules, and node:http +// lays no class above it: their objects hold what EventEmitter, Readable and Writable lay on them where every other stream holds it. What a program +// gets that has stored another class in net.Socket is held by parallel/what-stands-above-the-socket-of-an-http-server. import { expect } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; import { join } from "node:path"; @@ -18,7 +17,7 @@ server.listen(0, () => server.close()); const declared = without("testaot") ? test.skip : test; -declared("the layout of net's Socket is the base of the class of a server's sockets", async () => { +declared("a server's sockets have the layout of net's Socket", async () => { using folder = tempDir("aot-the-socket-of-an-http-server", { "main.cjs": program }); const file = join(String(folder), "program.aot"); const flags = [withProgramOutput(file), buildFlags.withoutExecutable]; @@ -38,7 +37,5 @@ declared("the layout of net's Socket is the base of the class of a server's sock moduleOf(name).some(module => line.includes(` module=${module} `)), ); expect(socketsOf("node:net").length).toBe(1); - expect(socketsOf("node:_http_server").map(line => / base=(L\d+)/.exec(line)?.[1] ?? "no base")).toEqual([ - socketsOf("node:net")[0].split(" ")[1], - ]); + expect(socketsOf("node:_http_server")).toEqual([]); }); diff --git a/test/bundler/bundler_compile.test.ts b/test/bundler/bundler_compile.test.ts index 55661d4372cf..7b7825e6b00b 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" + `, + }, + }); // A file imported with { type: "file" } is embedded byte for byte, whatever it begins with and whatever its extension. itBundled("compile/EmbeddedFileKeepsItsBytes", { compile: true, diff --git a/test/bundler/compile-trim-options.ts b/test/bundler/compile-trim-options.ts index 22b1b8f63a3e..506e551041f8 100644 --- a/test/bundler/compile-trim-options.ts +++ b/test/bundler/compile-trim-options.ts @@ -88,6 +88,11 @@ export const obeyed: Record = { "--dns-result-order=verbatim", "--dns-result-order=nonsense", ], + "--network-family-autoselection-attempt-timeout": [ + "--network-family-autoselection-attempt-timeout=500", + "--network-family-autoselection-attempt-timeout=nonsense", + ], + "--tls-keylog": ["--tls-keylog=keys.log"], "--redirect-warnings": ["--redirect-warnings=warnings.txt"], "--disable-warning": ["--disable-warning=x"], "--title": ["--title=x", "--title x", "--title 'two words'"], diff --git a/test/bundler/esbuild/loader.test.ts b/test/bundler/esbuild/loader.test.ts index 665e61a3ac99..5c4c21611e20 100644 --- a/test/bundler/esbuild/loader.test.ts +++ b/test/bundler/esbuild/loader.test.ts @@ -340,6 +340,15 @@ describe("bundler", () => { stdout: /^..\/image-.*\.png$/, }, }); + itBundled("loader/DataURLBase64URLSafeAlphabet", { + files: { + "/entry.js": /* js */ ` + import a from "data:text/javascript;base64,ZXhwb3J0IGRlZmF1bHQgIj4_Pn5-Pz4wIjs="; + console.log(a); + `, + }, + run: { stdout: ">?>~~?>0" }, + }); // itBundled("loader/FileRelativePathCSS", { // // GENERATED // files: { diff --git a/test/cli/hot/hot.test.ts b/test/cli/hot/hot.test.ts index f8c6887f5d9c..8434705003a0 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,201 @@ 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); }); + +// 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; + } +} + +// 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", + }); + + 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() }, + }).catch(error => { + if (error.code !== "ECONNREFUSED") throw error; + }); + // A TLS listener binds again on every run, so nothing listens for an instant. That run reports itself next. + if (!socket) { + await nextRun(); + continue; + } + const replies = lines(stream.readable); + const nextReply = async () => (await replies.next()).value; + const greeting = await nextReply(); + // Binding again also drops a connection the old listening socket 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, +); + +it( + "should keep the previous run's Bun.listen() serving when the new one has a broken certificate", + async () => { + const source = (cert: string) => ` + const generation = (globalThis.generation = (globalThis.generation ?? 0) + 1); + if (!globalThis.port) { + const probe = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + globalThis.port = probe.port; + probe.stop(true); + } + let threw = false; + try { + Bun.listen({ + hostname: "127.0.0.1", + port: globalThis.port, + tls: ${JSON.stringify({ key: tls.key, cert })}, + socket: { open: socket => void socket.end("run " + generation), data() {} }, + }); + } catch { + threw = true; + } + console.log(JSON.stringify({ port: globalThis.port, threw })); + `; + using dir = tempDir("hot-listen-broken", { "index.ts": source(tls.cert) }); + await using runner = spawn({ + cmd: [bunExe(), "--hot", "run", "index.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + const runs = lines(runner.stdout); + const { port, threw } = JSON.parse((await runs.next()).value!); + expect(threw).toBe(false); + + const broken = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n"; + writeFileSync(join(String(dir), "index.ts"), source(broken)); + expect(JSON.parse((await runs.next()).value!).threw).toBe(true); + + const { promise: greeting, resolve, reject } = Promise.withResolvers(); + await Bun.connect({ + hostname: "127.0.0.1", + port, + tls: { ca: tls.cert }, + socket: { + data: (_socket, chunk) => resolve(chunk.toString()), + error: (_socket, error) => reject(error), + close: () => reject(new Error("closed before the greeting")), + }, + }); + expect(await greeting).toBe("run 1"); + }, + 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/cli/inspect/inspect.test.ts b/test/cli/inspect/inspect.test.ts index 0e3e7eb58fb1..c2a81d80610e 100644 --- a/test/cli/inspect/inspect.test.ts +++ b/test/cli/inspect/inspect.test.ts @@ -457,6 +457,38 @@ describe("unix domain socket without websocket", () => { } }); +test("the inspectee sends the rest of an answer after the frontend's FIN, then closes", async () => { + const size = 8 * 1024 * 1024; + const { promise: closed, resolve, reject } = Promise.withResolvers(); + let received = 0; + using listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + allowHalfOpen: true, + socket: { + open(socket) { + const params = { expression: `"x".repeat(${size})`, returnByValue: true }; + new SocketFramer(() => {}).send(socket, JSON.stringify({ id: 1, method: "Runtime.evaluate", params })); + }, + data(socket, bytes) { + // The answer is too big for one send, so the inspectee still holds most of it. + if (received === 0) socket.shutdown(); + received += bytes.length; + }, + close: () => resolve(), + error: (_socket, error) => reject(error), + }, + }); + await using _inspectee = spawn({ + cmd: [bunExe(), join(import.meta.dir, "inspectee.js")], + env: { ...bunEnv, BUN_INSPECT: `tcp://127.0.0.1:${listener.port}` }, + stdout: "ignore", + stderr: "inherit", + }); + await closed; + expect(received).toBeGreaterThan(size); +}); + /// TODO: this test is flaky because the inspect may not send all messages before the process exit /// we need to implement a way/option so we wait every message from the inspector before exiting test.todo("junit reporter", async () => { 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/cli/test/isolation.test.ts b/test/cli/test/isolation.test.ts index 154bfab4154f..a2569eec400c 100644 --- a/test/cli/test/isolation.test.ts +++ b/test/cli/test/isolation.test.ts @@ -115,6 +115,49 @@ describe.concurrent("bun test --isolate", () => { expect(exitCode).toBe(0); }); + test("with --isolate, a file's tls.DEFAULT_CIPHERS and default CA list are undone before the next file", async () => { + using dir = tempDir("isolate-tls-defaults", { + "ca.pem": tls.cert, + "a-defaults.test.ts": ` + import { test } from "bun:test"; + import tls from "node:tls"; + test("replace the TLS defaults", () => { + tls.DEFAULT_CIPHERS = "ECDHE-RSA-AES256-GCM-SHA384"; + tls.setDefaultCACertificates([]); + }); + `, + "b-fetch.test.ts": ` + import { test, expect } from "bun:test"; + import { once } from "node:events"; + import tls from "node:tls"; + test("fetch uses the stock defaults", async () => { + // The server prefers a cipher the previous file's list leaves out, and only NODE_EXTRA_CA_CERTS trusts it. + const ciphers = "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384"; + const server = tls.createServer({ ...JSON.parse(process.env.SERVER_CERT), maxVersion: "TLSv1.2", ciphers }, socket => { + const { name } = socket.getCipher(); + socket.end("HTTP/1.1 200 OK\\r\\nContent-Length: " + name.length + "\\r\\nConnection: close\\r\\n\\r\\n" + name); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + try { + const response = await fetch("https://127.0.0.1:" + server.address().port); + expect(await response.text()).toBe("ECDHE-RSA-AES128-GCM-SHA256"); + expect(tls.DEFAULT_CIPHERS).toContain("ECDHE-RSA-AES128-GCM-SHA256"); + } finally { + server.close(); + } + }); + `, + }); + const { stderr, exitCode } = await runTests( + String(dir), + ["--isolate"], + ["./a-defaults.test.ts", "./b-fetch.test.ts"], + { ...bunEnv, SERVER_CERT: JSON.stringify(tls), NODE_EXTRA_CA_CERTS: join(String(dir), "ca.pem") }, + ); + expect(normalizeBunSnapshot(stderr, dir)).toContain("2 pass"); + expect(exitCode).toBe(0); + }); + // TZ, NODE_TLS_REJECT_UNAUTHORIZED, BUN_CONFIG_VERBOSE_FETCH and the proxy // keys have process.env setters with a native side effect outside the global // (the WTF time zone override, per-VM caches, the env map the next diff --git a/test/expectations.txt b/test/expectations.txt index 65e005d51fab..88510a399ff5 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -23,11 +23,15 @@ # 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 +# 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 + +# An http.Server in a cluster worker asks the primary to bind only on POSIX. On +# Windows each worker still binds for itself, so the second bind of the same +# port succeeds where this test expects EADDRINUSE. The file is that one case. +[ WINDOWS ] test/js/node/test/parallel/test-cluster-bind-twice.js [ FAIL ] # http.Server#listen() in a worker doesn't go through the primary on Windows # Cluster workers sharing a listen port behave differently on Linux, where # SO_REUSEPORT load-balances across the workers' own listeners instead of the diff --git a/test/expected-durations.json b/test/expected-durations.json index 6325c05f0950..284f397f06ac 100644 --- a/test/expected-durations.json +++ b/test/expected-durations.json @@ -3072,7 +3072,7 @@ "default": 60000, "asan": 240000 }, - "aot/the-socket-of-an-http-server-names-the-socket-of-net-as-its-base.test.ts": { + "aot/the-socket-of-an-http-server-has-the-layout-of-the-socket-of-net.test.ts": { "default": 60000, "asan": 240000 }, @@ -9907,13 +9907,6 @@ "windows": 26300, "windows-aarch64": 27000 }, - "js/bun/http/server-url-invalid.test.ts": { - "default": 100, - "asan": 100, - "musl": 100, - "windows": 100, - "windows-aarch64": 100 - }, "js/bun/http/tls-bunfile-leak.test.ts": { "default": 100, "asan": 900, 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 9acce8dedb74..6f4ba4b1aaee 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -18,8 +18,6 @@ import { locksEnvironment } from "./aot/hardened"; import { ownTemporaryFolder } from "./aot/harness/temporary-folders"; import "./aot/harness/without-installing-packages"; -export const BREAKING_CHANGES_BUN_1_2 = false; - export const isMacOS = process.platform === "darwin"; export const isLinux = process.platform === "linux"; export const isFreeBSD = process.platform === "freebsd"; @@ -54,7 +52,6 @@ export const libcFamily: "glibc" | "musl" = export const isMusl = isLinux && libcFamily === "musl"; export const isGlibc = isLinux && libcFamily === "glibc"; export const isBuildKite = process.env.BUILDKITE === "true"; -export const isVerbose = process.env.DEBUG === "1"; // Use these to mark a test as flaky or broken. // This will help us keep track of these tests. diff --git a/test/integration/bun-types/bun-types.test.ts b/test/integration/bun-types/bun-types.test.ts index 47e6ab29c5fc..9961a0bfaeae 100644 --- a/test/integration/bun-types/bun-types.test.ts +++ b/test/integration/bun-types/bun-types.test.ts @@ -648,6 +648,8 @@ describe("@types/bun integration test", () => { ).toEqual([ // `undefined` for a Blob without a store or with bytes. What is published is a BunFile. "Blob.bind.ts: file().stat() is Promise | undefined, not Promise", + // Only bun:internal-for-testing hands it out. + "FakeTimers.bind.ts: fakeLibraryTimers is not published", // An S3File is a Blob with more members, which C++ adds and no schema describes. "S3Client.bind.ts: new S3Client().file() has no readable, bucket, presign", "S3Client.bind.ts: new S3Client().file().stat() is Promise | undefined, not Promise", 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/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/internal/arms-of-modules-for-compiled-programs.test.ts b/test/internal/arms-of-modules-for-compiled-programs.test.ts index 829a5155fc04..c2435c1d8a10 100644 --- a/test/internal/arms-of-modules-for-compiled-programs.test.ts +++ b/test/internal/arms-of-modules-for-compiled-programs.test.ts @@ -326,13 +326,12 @@ const owed: Record = { "ahead-of-time/internal/streams/destroy.ts": 1, "ahead-of-time/internal/streams/end-of-stream.ts": 11, "internal/streams/from.ts": 2, - "ahead-of-time/internal/streams/readable.ts": 40, + "ahead-of-time/internal/streams/readable.ts": 39, "ahead-of-time/internal/streams/state.ts": 3, "ahead-of-time/internal/streams/transform.ts": 1, "ahead-of-time/internal/streams/utils.ts": 1, "node/async_hooks.ts": 6, "ahead-of-time/node/events.ts": 9, - "ahead-of-time/node/http2.ts": 2, }; test("every arm for compiled programs names a program that reaches it", () => { diff --git a/test/internal/build-trimmable-config.test.ts b/test/internal/build-trimmable-config.test.ts index fce70daf17fe..d5d57abf62eb 100644 --- a/test/internal/build-trimmable-config.test.ts +++ b/test/internal/build-trimmable-config.test.ts @@ -96,7 +96,7 @@ describe.skipIf(isWindows)("trimmable build config", () => { }); test("with LTO, C is compiled so that each c-function of src/trim/features stays a function", () => { - const keepsFunction = "-force-attribute=us_nq_loop_drain:noinline"; + const keepsFunction = "-force-attribute=us_quic_loop_process:noinline"; const flagsOf = (partial: PartialConfig) => computeFlags(resolve({ ...linux, ...partial })); expect(flagsOf({ lto: true }).cflags).toContain(keepsFunction); expect(flagsOf({ lto: true }).cxxflags).not.toContain(keepsFunction); diff --git a/test/internal/source-lints/jsresult-swallow.inventory.json b/test/internal/source-lints/jsresult-swallow.inventory.json index 8fc270fc2987..9eaf18fa69b0 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": 1 }, "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 3cccb2a4b065..589c77b0746b 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/dns/dns-interleave.test.ts b/test/js/bun/dns/dns-interleave.test.ts index da288b466c56..c36e028be3be 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[] = [], env: Record = {}) { const fixture = /* js */ ` const { dnsCacheSeed } = require("bun:internal-for-testing"); if (typeof dnsCacheSeed !== "function") { @@ -30,13 +30,14 @@ 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, HTTPS_PROXY: undefined, http_proxy: undefined, https_proxy: undefined, + ...env, }, stderr: "pipe", stdout: "pipe", @@ -53,6 +54,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 +163,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 +204,91 @@ 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, + ); + + test.skipIf(!isLinux || isMusl)( + "a timeout set while every address still hangs fires when it's due", + async () => { + const { out, stderr, exitCode, signal } = await run( + /* js */ ` + ${blackholePrelude} + const { sweepSocketTimers } = require("bun:internal-for-testing"); + + const free = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + const port = free.port; + free.stop(true); + blackhole("127.0.0.2", port); + blackhole("127.0.0.3", port); + const host = "he-timeout-" + port + ".test"; + dnsCacheSeed(host, ["127.0.0.2", "127.0.0.3"]); + + const failed = Promise.withResolvers(); + let early = true; + // Keeps the sweeps going, so that the timeout is set a moment after one. + using listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + sweepSocketTimers(1); + const ws = new WebSocket("ws://" + host + ":" + port); + ws.onerror = event => failed.resolve({ early, message: event.message.split(" failed: ")[1] }); + // The next loop iteration dials both addresses. + await new Promise(resolve => setImmediate(resolve)); + // A sweep runs every 4 seconds, and the 250th comes a moment too early. + sweepSocketTimers(1000 / 4); + early = false; + sweepSocketTimers(1); + console.log(JSON.stringify(await failed.promise)); + for (const fd of fds) libc.symbols.close(fd); + process.exit(0); + `, + 20_000, + [], + { BUN_CONFIG_WS_HANDSHAKE_TIMEOUT: "1000" }, + ); + expect({ out, stderr, exitCode, signal }).toEqual({ + out: { early: false, message: "Timeout" }, + stderr: expect.any(String), + exitCode: 0, + signal: null, + }); + }, + 20_000, + ); }); // Loopback: the connect-path resolver asks getaddrinfo with AI_ADDRCONFIG, and @@ -401,3 +491,63 @@ 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, + }); + }); + + test("dns.setDefaultResultOrder() in a Worker only changes that Worker's dns.lookup() default", async () => { + const { out, stderr, exitCode } = await run(/* js */ ` + const { once } = require("node:events"); + const { Worker } = require("node:worker_threads"); + dnsCacheSeed("cached.test", ["::1", "127.0.0.1"]); + const before = Bun.dns.getCacheStats().size; + const worker = new Worker( + \` + const dns = require("node:dns"); + dns.setDefaultResultOrder("ipv4first"); + require("node:worker_threads").parentPort.postMessage(dns.getDefaultResultOrder()); + \`, + { eval: true }, + ); + const [inWorker] = await once(worker, "message"); + await worker.terminate(); + 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({ inWorker, onMain: require("node:dns").getDefaultResultOrder(), before, after, order })); + `); + expect({ out, stderr, exitCode }).toEqual({ + out: { inWorker: "ipv4first", onMain: "verbatim", before: 1, after: 1, order: [6, 4, 6, 4] }, + 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/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..60bd57bf453f 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,51 @@ 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`], + // Not a string, so its path is "[object Bun]". + [Bun, "unix://%5Bobject%20Bun%5D"], + ] as [string, string][]) { + 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: String(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 +956,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 90fac8fb23bb..074dfba024b7 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,47 @@ 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-form-data-refused": async () => { + const res = new Response(partialSlice()); + // text/plain isn't a form type, so this rejects and leaves the body alone. + await res.formData().catch(() => {}); + 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 +259,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 +302,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 +324,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 +332,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 +415,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 +506,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 +531,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 +580,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 +625,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 +642,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 +651,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 +669,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 +689,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 +698,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 +717,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 +737,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 +751,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 +891,38 @@ 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"), + "formData() refused": await serve("/slice-handler-form-data-refused"), + "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, + "formData() refused": 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 +973,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 +1085,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 +1099,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 +1131,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 +1202,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 +1360,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 +1627,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 +1720,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 +1741,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 +1906,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 +2117,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 +2247,213 @@ 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 `] : []), + ]; + // Windows reports a path below a regular file as ENOENT. + const codes = ["ENOENT", ...(isWindows ? ["ENOENT"] : ["ENOTDIR", "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 +2463,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, read } from "bun:ffi"; const port = Number(process.env.PORT); @@ -1748,9 +2512,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 @@ -1759,6 +2533,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" }; } @@ -1766,70 +2543,82 @@ 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)); // JSC suspends threads with a signal, and a recv with SO_RCVTIMEO is never restarted after one. if (n === -1 && read.i32(libc.__errno_location()) === 4 /* EINTR */) continue; 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); @@ -1842,39 +2631,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 => { @@ -1882,28 +2672,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..2e7425c83e9b 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, gunzipSync, gzipSync, zstdCompressSync } from "node:zlib"; // https://github.com/oven-sh/bun/issues/9180 test("weird headers", async () => { @@ -229,7 +233,8 @@ describe("response Connection: close closes the socket", () => { ); }); - test("keep-alive still the default", async () => { + // "not-close" is one connection option (RFC 9110 section 7.6.1), and it isn't "close". + test.each([undefined, "not-close"])("keep-alive still the default with Connection: %s", async connection => { // Negative: without Connection: close, a second request on the same socket // must be serviced. let handled = 0; @@ -239,7 +244,7 @@ describe("response Connection: close closes the socket", () => { idleTimeout: 0, fetch() { handled++; - return new Response("bye"); + return new Response("bye", connection ? { headers: { connection } } : undefined); }, }); @@ -265,3 +270,535 @@ 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. +// One raw request per entry on its own connection. Resolves with the response heads, lowercased. +async function rawHeads(port: number, requests: string[]) { + return Promise.all( + requests.map(async request => { + const socket = net.connect(port, "127.0.0.1"); + try { + let raw = ""; + const { promise, resolve, reject } = Promise.withResolvers(); + socket.on("error", reject); + socket.on("close", () => resolve()); + socket.on("data", chunk => { + raw += chunk.toString("latin1"); + if (raw.includes("\r\n\r\n")) resolve(); + }); + socket.write(request); + await promise; + return raw.split("\r\n\r\n")[0].toLowerCase().split("\r\n"); + } finally { + socket.destroy(); + } + }), + ); +} + +describe("the server frames the response, whatever the handler's headers say", () => { + const headers = { "Content-Length": "3", "Transfer-Encoding": "gzip, chunked", "X-Kept": "yes" }; + const framing = (head: string[]) => head.filter(line => /^(content-length|transfer-encoding|x-kept):/.test(line)); + + test("for a handler, a static route and a file route, and leaves the headers of a route's Response alone", async () => { + using dir = tempDir("serve-framing-headers", { "hello.txt": "hello world" }); + const staticRoute = new Response("hello world", { headers }); + using server = Bun.serve({ + port: 0, + development: false, + routes: { + "/static": staticRoute, + "/file": new Response(Bun.file(join(String(dir), "hello.txt")), { headers }), + }, + fetch: () => new Response("hello world", { headers }), + }); + const heads = await rawHeads( + server.port!, + ["/handler", "/static", "/file"].map(path => `GET ${path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`), + ); + expect(heads.map(framing)).toEqual([ + ["x-kept: yes", "content-length: 11"], + ["x-kept: yes", "content-length: 11"], + ["x-kept: yes", "content-length: 11"], + ]); + expect(staticRoute.headers.get("content-length")).toBe("3"); + }); + + test("HEAD says what GET would: chunked for a stream, and for a bodiless Response that names any transfer coding", async () => { + using server = Bun.serve({ + port: 0, + development: false, + fetch(req) { + if (new URL(req.url).pathname === "/stream") return new Response(new ReadableStream({})); + return new Response(null, { headers: { "Transfer-Encoding": "gzip, chunked" } }); + }, + }); + const heads = await rawHeads(server.port!, [ + "HEAD /stream HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", + "HEAD /bodiless HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", + "HEAD /stream HTTP/1.0\r\nHost: x\r\n\r\n", + ]); + expect(heads.map(framing)).toEqual([["transfer-encoding: chunked"], ["transfer-encoding: chunked"], []]); + }); + + test("sends one Date, the route's own, from a file route", async () => { + using dir = tempDir("serve-file-route-date", { "hello.txt": "hello world" }); + using server = Bun.serve({ + port: 0, + development: false, + routes: { + "/file": new Response(Bun.file(join(String(dir), "hello.txt")), { + headers: { Date: "Thu, 01 Jan 1970 00:00:00 GMT" }, + }), + }, + }); + const [head] = await rawHeads(server.port!, ["GET /file HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"]); + expect(head.filter(line => line.startsWith("date:"))).toEqual(["date: thu, 01 jan 1970 00:00:00 gmt"]); + }); +}); + +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 }); + }); + + // 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 as string | null).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: "" }); + }); + + // With `late`, the upstream holds most of the body back until fetch() has resolved. + let fetched = Promise.withResolvers(); + function serveGzip(late: boolean) { + const headers = { "content-encoding": "gzip", "content-type": "text/plain", "x-upstream": "1" }; + if (!late) return Bun.serve({ port: 0, fetch: () => new Response(gzipped, { headers }) }); + return Bun.serve({ + port: 0, + fetch() { + fetched = Promise.withResolvers(); + return new Response( + async function* () { + yield gzipped.subarray(0, 10); + await fetched.promise; + yield gzipped.subarray(10); + }, + { headers }, + ); + }, + }); + } + async function fetchUpstream(upstream: Bun.Server, init?: BunFetchRequestInit) { + const res = await fetch(`http://127.0.0.1:${upstream.port}/`, init); + fetched.resolve(); + return res; + } + + async function proxied(late: boolean, shapes: Record Response | Promise>) { + using upstream = serveGzip(late); + using proxy = Bun.serve({ + port: 0, + fetch: async req => + shapes[decodeURIComponent(new URL(req.url).pathname.slice(1))]( + await fetchUpstream(upstream, { decompress: !req.url.endsWith("?raw") }), + ), + }); + const seen: Record = {}; + for (const name of Object.keys(shapes)) { + for (const raw of ["", "?raw"]) { + const res = await fetch(`http://127.0.0.1:${proxy.port}/${encodeURIComponent(name)}${raw}`, { + decompress: false, + }); + const body = Buffer.from(await res.arrayBuffer()); + seen[name + raw] = { + encoding: res.headers.get("content-encoding"), + upstream: res.headers.get("x-upstream"), + body: body.equals(gzipped) ? "gzip" : body.toString() === payload ? "plain" : body.toString("latin1"), + }; + } + } + return seen; + } + + const expected = (shapes: object, decoded: object) => + Object.fromEntries( + Object.keys(shapes).flatMap(name => [ + [name, decoded], + // fetch() left these bytes alone, so the header still describes them. + [name + "?raw", { encoding: "gzip", upstream: "1", body: "gzip" }], + ]), + ); + + describe.each([ + ["the body arrives with the head", false], + ["the body arrives after the head", true], + ])("%s", (_, late) => { + test("is dropped from a Response that wraps the decoded body", async () => { + const shapes: Record Response> = { + "new Response(res.body, res)": res => new Response(res.body, res), + "{ headers: res.headers }": res => new Response(res.body, { headers: res.headers }), + "{ status, statusText, headers }": res => + new Response(res.body, { status: res.status, statusText: res.statusText, headers: res.headers }), + "{ headers: new Headers(res.headers) }": res => new Response(res.body, { headers: new Headers(res.headers) }), + "{ headers: Object.fromEntries(res.headers) }": res => + new Response(res.body, { headers: Object.fromEntries(res.headers) }), + "wrapped twice": res => { + const once = new Response(res.body, res); + return new Response(once.body, once); + }, + "clone of a wrapper": res => new Response(res.body, res).clone(), + "body of a clone": res => new Response(res.clone().body, res), + "through a Request": res => + new Response(new Request("http://example.com/", { method: "POST", body: res.body }).body, res), + }; + expect(await proxied(late, shapes)).toEqual(expected(shapes, { encoding: null, upstream: "1", body: "plain" })); + }); + + test("is dropped from both branches of tee()", async () => { + const shapes: Record Response> = { + "first": res => new Response(res.body!.tee()[0], res), + "second": res => new Response(res.body!.tee()[1], res), + "tee of a branch": res => new Response(res.body!.tee()[0].tee()[1], res), + }; + expect(await proxied(late, shapes)).toEqual(expected(shapes, { encoding: null, upstream: "1", body: "plain" })); + }); + + test("is dropped from a Response that wraps the decoded Blob", async () => { + const shapes: Record Promise> = { + "blob()": async res => new Response(await res.blob(), res), + "blob().stream()": async res => new Response((await res.blob()).stream(), res), + "blob().slice()": async res => new Response((await res.blob()).slice(), res), + }; + expect(await proxied(late, shapes)).toEqual(expected(shapes, { encoding: null, upstream: "1", body: "plain" })); + }); + + // What comes out of a transform is the program's own bytes, and only the program knows their encoding. + test("is kept on the output of the program's own transform", async () => { + using upstream = serveGzip(late); + const shapes: Record Response> = { + "CompressionStream, upstream headers": res => + new Response(res.body!.pipeThrough(new CompressionStream("gzip")), res), + "CompressionStream, own header": res => + new Response(res.body!.pipeThrough(new CompressionStream("gzip")), { + headers: { "content-encoding": "gzip" }, + }), + }; + using proxy = Bun.serve({ + port: 0, + fetch: async req => + shapes[decodeURIComponent(new URL(req.url).pathname.slice(1))](await fetchUpstream(upstream)), + }); + for (const name of Object.keys(shapes)) { + const res = await fetch(`http://127.0.0.1:${proxy.port}/${encodeURIComponent(name)}`, { decompress: false }); + expect({ + name, + encoding: res.headers.get("content-encoding"), + body: gunzipSync(await res.bytes()).toString(), + }).toEqual({ name, encoding: "gzip", body: payload }); + } + }); + }); + + test("is kept on bytes the program compressed itself", async () => { + using upstream = serveGzip(false); + const own = { headers: { "content-encoding": "gzip" } }; + const shapes: Record Response | Promise> = { + "bytes": () => new Response(gzipped, own), + "Blob": () => new Response(new Blob([gzipped]), own), + "stream": () => new Response(new Blob([gzipped]).stream(), own), + "wrapped": () => { + const res = new Response(gzipped, own); + return new Response(res.body, res); + }, + "clone": () => new Response(gzipped, own).clone(), + "fetched, decoded and compressed again": async () => { + const res = await fetch(`http://127.0.0.1:${upstream.port}/`); + return new Response(gzipSync(await res.bytes()), res); + }, + }; + using server = Bun.serve({ + port: 0, + fetch: req => shapes[decodeURIComponent(new URL(req.url).pathname.slice(1))](), + }); + for (const name of Object.keys(shapes)) { + const res = await fetch(`http://127.0.0.1:${server.port}/${encodeURIComponent(name)}`, { decompress: false }); + expect({ + name, + encoding: res.headers.get("content-encoding"), + body: gunzipSync(await res.bytes()).toString(), + }).toEqual({ name, encoding: "gzip", body: payload }); + } + }); + + test("is dropped from a static route that wraps the decoded body", async () => { + using upstream = serveGzip(false); + const res = await fetch(`http://127.0.0.1:${upstream.port}/`); + // A static route only accepts a fully buffered body. + await res.clone().arrayBuffer(); + using proxy = Bun.serve({ port: 0, routes: { "/": new Response(res.body, res) } }); + expect(await readWire(proxy)).toEqual({ encoding: null, length: String(payload.length), body: payload }); + }); +}); 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-build-holds-server.test.ts b/test/js/bun/http/bun-serve-html-build-holds-server.test.ts index f91ce26bbb14..9d59e37dd76b 100644 --- a/test/js/bun/http/bun-serve-html-build-holds-server.test.ts +++ b/test/js/bun/http/bun-serve-html-build-holds-server.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "node:path"; // An HTML route served without the DevServer bundles on its first request. The // plugin load and the build finish on later event-loop turns and call back into @@ -39,6 +40,7 @@ const fixture = { }; `, "run.ts": /* ts */ ` + import { Lifetimes } from ${JSON.stringify(join(import.meta.dir, "..", "module-graph", "module-graph-lifetimes.ts"))}; import html from "./index.html"; // Read by plugin.ts, which Bun.serve loads into this same global on the @@ -47,16 +49,10 @@ const fixture = { const release = Promise.withResolvers(); Object.assign(globalThis, { __mode: process.argv[2], __parked: parked.resolve, __release: release.promise }); - let collected = false; - const registry = new FinalizationRegistry(() => void (collected = true)); + // On Windows every callback runs under uv_run(), whose frame has an array of 4 KB that only the kernel writes, so a + // word that an earlier call left there can keep the server from being finalized. Lifetimes asks the heap instead. + const lifetimes = new Lifetimes(); const turn = () => new Promise(r => setTimeout(r, 0)); - async function collectServer() { - for (let i = 0; i < 50 && !collected; i++) { - Bun.gc(true); - await turn(); - } - return collected; - } // Its own frame, so that nothing on the stack still refers to the server // once it returns. @@ -67,7 +63,7 @@ const fixture = { routes: { "/": html }, fetch: () => new Response("fallback"), }); - registry.register(server, "server"); + lifetimes.track("server", server); const controller = new AbortController(); const request = fetch(server.url, { signal: controller.signal }).then( response => "status " + response.status, @@ -87,7 +83,7 @@ const fixture = { if (settled) { // Nothing holds the server for the parked route: let it be freed before // the route resumes, which is the use-after-free. - await collectServer(); + await lifetimes.stillAlive("server"); await turn(); } @@ -98,7 +94,7 @@ const fixture = { fetch: fetchResult, pendingRequestsWhileParked, stopBeforeRelease, - collectedAfterwards: await collectServer(), + heldAfterwardsBy: await lifetimes.stillAlive("server"), }), ); `, @@ -122,7 +118,7 @@ test.concurrent.each(["build", "plugin setup", "plugin setup rejection"])( fetch: "AbortError", pendingRequestsWhileParked: 1, stopBeforeRelease: "pending", - collectedAfterwards: true, + heldAfterwardsBy: [], }); if (mode === "plugin setup rejection") { expect(stderr).toContain("plugin setup rejected on purpose"); @@ -135,3 +131,44 @@ test.concurrent.each(["build", "plugin setup", "plugin setup rejection"])( // symbolizes the report against the debug binary before the child exits. 30_000, ); + +test("a request that waits for its HTML route's build isn't closed at idleTimeout", async () => { + using dir = tempDir("serve-html-build-idle-timeout", { + ...fixture, + "run.ts": /* ts */ ` + import { sweepSocketTimers } from "bun:internal-for-testing"; + import html from "./index.html"; + + const parked = Promise.withResolvers(); + const release = Promise.withResolvers(); + Object.assign(globalThis, { __mode: "build", __parked: parked.resolve, __release: release.promise }); + + using server = Bun.serve({ + port: 0, + development: false, + idleTimeout: 1, + routes: { "/": html }, + fetch: () => new Response("fallback"), + }); + const request = fetch(server.url).then( + response => "status " + response.status, + error => error.code, + ); + await parked.promise; + await new Promise(setImmediate); + sweepSocketTimers(4); + release.resolve(); + console.log(await request); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "run.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({ stdout: stdout.trim(), stderr }).toEqual({ stdout: "status 200", stderr: "" }); + expect(exitCode).toBe(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..9b5077cdfb9a 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,266 @@ 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("doesn't start the dev server for the route of a framework router", async () => { + const { stderr, ...rest } = await run(` + using server = Bun.serve({ port: 0, development: true, routes: api, fetch: () => new Response("old") }); + const pages = { dir: import.meta.dir, style: "nextjs-pages" }; + server.reload({ routes: { ...api, "/pages/*": pages }, fetch: () => new Response("new") }); + console.log(JSON.stringify(await (await fetch(new URL("/other", server.url))).text())); + `); + expect(rest, stderr).toEqual({ result: "new", 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 +1651,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 +2347,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..dcbbaa34f92f 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,287 @@ 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", + ); + }); +}); + +describe("the same routes over HTTP/1.1, HTTP/2 and HTTP/3", () => { + const named = (name: string) => () => new Response(name); + const paths = [ + "/a/static", + "/a/fn", + "/a/own", + "/a/other", + "/b/fn", + "/b/7", + "/c/x/y", + "/c/x/z", + "/c/q", + "/d/fn", + "/none", + ]; + const methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "PROPFIND"]; + + test("pick the same handler", async () => { + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls, + http2: true, + http3: true, + routes: { + "/a/*": { GET: named("/a/* GET") }, + "/a/static": new Response("/a/static"), + "/a/fn": named("/a/fn"), + "/a/own": { GET: named("/a/own GET") }, + "/b/:id": { GET: named("/b/:id GET"), POST: named("/b/:id POST") }, + "/b/fn": named("/b/fn"), + "/c/*": named("/c/*"), + "/c/x/*": { GET: named("/c/x/* GET") }, + "/c/x/y": named("/c/x/y"), + "/d/*": { POST: named("/d/* POST") }, + "/d/fn": named("/d/fn"), + }, + fetch: () => new Response("fetch"), + }); + const over = async (protocol: string) => { + const answers: Record = {}; + for (const path of paths) { + for (const method of methods) { + const response = await fetch(`https://127.0.0.1:${server.port}${path}`, { + method, + protocol, + tls: { rejectUnauthorized: false }, + } as RequestInit); + const body = await response.text(); + answers[`${method} ${path}`] = method === "HEAD" ? `${response.headers.get("content-length")} bytes` : body; + } + } + return answers; + }; + const http1 = await over("http1.1"); + expect(http1).toMatchObject({ + "GET /a/static": "/a/static", + "GET /a/fn": "/a/fn", + "GET /a/own": "/a/own GET", + "GET /a/other": "/a/* GET", + "POST /a/other": "fetch", + "GET /b/fn": "/b/fn", + "POST /b/fn": "/b/fn", + "POST /b/7": "/b/:id POST", + "GET /c/x/y": "/c/x/y", + "GET /c/x/z": "/c/x/* GET", + "PUT /c/x/z": "/c/*", + "POST /d/fn": "/d/fn", + "PROPFIND /a/fn": "/a/fn", + "PROPFIND /none": "fetch", + }); + expect({ http2: await over("http2"), http3: await over("http3") }).toEqual({ http2: http1, http3: http1 }); + }); +}); diff --git a/test/js/bun/http/bun-serve-ssl.test.ts b/test/js/bun/http/bun-serve-ssl.test.ts index 9402ed2f0f97..b6c2e5b467ec 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, @@ -725,3 +758,161 @@ 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("a number that is no protocol code leaves the default bounds", async () => { + const versions = ["TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3"] as const; + // The versions that a client pinned to one version at a time gets. + const accepted = async (bounds: object) => { + using server = serve(bounds); + const got: string[] = []; + for (const version of versions) { + const { promise, resolve } = Promise.withResolvers(); + const client = tls + .connect({ + port: server.port, + host: "127.0.0.1", + ca: tlsCert.cert, + servername: "localhost", + minVersion: version, + maxVersion: version, + ciphers: "ALL", + }) + .on("secureConnect", () => resolve(true)) + .on("error", () => resolve(false)); + if (await promise) got.push(version); + client.destroy(); + } + return got; + }; + // The clients do speak TLS 1.0 and 1.1. + expect(await accepted({ minVersion: "TLSv1" })).toEqual([...versions]); + for (const code of [5, 0x0305]) { + expect(await accepted({ minVersion: code })).toEqual(["TLSv1.2", "TLSv1.3"]); + expect(await accepted({ maxVersion: code })).toEqual(["TLSv1.2", "TLSv1.3"]); + } + }); + + 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', + }), + ); + }); +}); + +// Bun.build() waits for an async plugin setup() by running the event loop, here from inside a handler. +test("stop(true) survives a handler that closes other connections and runs the event loop", async () => { + using dir = tempDir("serve-stop-nested-tick", { + "stop.test.js": /* js */ ` + import { expect, test } from "bun:test"; + import tls from "node:tls"; + const { key, cert } = JSON.parse(process.env.TEST_TLS); + + test("stop(true)", async () => { + const count = 6; + const controllers = []; + const stopped = Promise.withResolvers(); + let aborted = false; + let ranLoop = false; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + tls: { key, cert }, + idleTimeout: 0, + fetch(request) { + request.signal.addEventListener("abort", () => { + if (aborted) return; + aborted = true; + // "Connection: close": the end of a response closes its connection, once the peer has answered. + for (const controller of controllers) controller.end(); + let waited = false; + Bun.build({ + entrypoints: ["./none.js"], + plugins: [{ name: "nested tick", setup: () => Bun.sleep(400).then(() => void (waited = true)) }], + }).catch(() => {}); + ranLoop = waited; + }); + return new Response( + new ReadableStream({ + type: "direct", + async pull(controller) { + controllers.push(controller); + controller.write("unfinished"); + await controller.flush(); + await new Promise(() => {}); + }, + }), + ); + }, + }); + let answered = 0; + for (let i = 0; i < count; i++) { + const client = tls.connect({ port: server.port, host: "127.0.0.1", ca: cert, servername: "localhost" }); + client.on("error", () => {}); + client.write("GET / HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n"); + client.once("data", () => { + // From a timer, so that no tick of the socket loop is on the stack. + if (++answered === count) setTimeout(() => stopped.resolve(server.stop(true)), 1); + }); + } + await stopped.promise; + expect(ranLoop).toBe(true); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "stop.test.js"], + env: { ...bunEnv, TEST_TLS: JSON.stringify({ key: tlsCert.key, cert: tlsCert.cert }) }, + cwd: String(dir), + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain(" 1 pass"); + expect(exitCode).toBe(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..1ca707e29d54 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, @@ -851,6 +897,50 @@ describe.concurrent("server.stop() drain promise counts open connections", () => }); }); + test("stop() and the process don't wait for a client that keeps its side open after the last response", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const net = require("net"); + const inflight = Promise.withResolvers(); + const release = Promise.withResolvers(); + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch() { + inflight.resolve(); + await release.promise; + return new Response("ok"); + }, + }); + // With allowHalfOpen the client sees the server's FIN and doesn't answer it. + const client = net.connect({ port: server.port, host: "127.0.0.1", allowHalfOpen: true }); + const ended = new Promise(resolve => client.on("end", resolve)); + client.on("error", () => {}); + client.resume(); + client.write("GET / HTTP/1.1\\r\\nHost: x\\r\\n\\r\\n"); + await inflight.promise; + let resolved = false; + const stopped = server.stop().then(() => { resolved = true; }); + release.resolve(); + // The server lets the connection go when it sends its FIN, a loop turn before the client reads it. + await ended; + console.log(JSON.stringify({ resolved, clientDestroyed: client.destroyed })); + await stopped; + client.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 }).toEqual({ stdout: '{"resolved":true,"clientDestroyed":false}\n', stderr: "" }); + expect(exitCode).toBe(0); + }); + test("in-flight HEAD request completes across stop(), then its connection is closed", async () => { expect(await runDrainFixture("inflightHead")).toEqual({ stderr: "", @@ -1324,8 +1414,8 @@ describe.concurrent("server.stop() drain promise counts open connections", () => test("websocket-only server: a second stop() returns the still-pending promise", async () => { // After upgrade() the filter fires -1, so a websocket-only server has - // active_connection_count == 0 and only the has_active_web_sockets() term - // in get_all_closed_promise's early-return keeps a repeat stop() call from + // active_connection_count == 0 and only the active_websocket_count term + // of the server's phase keeps a repeat stop() call from // handing back a fresh resolved promise while the first one is pending. await using proc = Bun.spawn({ cmd: [ @@ -1624,7 +1714,7 @@ test("should be able to await server.stop(true) with keep alive", async () => { // so a (former) panic in the dispatch trampoline surfaces as a non-zero exit // instead of taking down the runner. // -// `deinit_if_we_can` defers the wrapper downgrade while the connection is +// `apply_phase` defers the wrapper downgrade while the connection is // still open, so the held request always dispatches and completes against a // live wrapper. What happens to the pipelined request depends on the server: // a Bun.serve graceful stop marks the busy connection close-when-idle, so the @@ -2056,7 +2146,7 @@ test("server wrapper survives GC while a websocket is connected after stop()", a // stays alive (active_websockets > 0), but stop() previously downgraded // js_value immediately, so GC could finalize the JS wrapper — and with it // m_routeList — while the connection was still in use. With the downgrade - // deferred into deinit_if_we_can's idle predicate, the wrapper must outlive + // deferred into apply_phase's idle predicate, the wrapper must outlive // the websocket and become collectable only after the last close. await using proc = Bun.spawn({ cmd: [ @@ -2135,6 +2225,9 @@ test("server wrapper survives GC while a websocket is connected after stop()", a await drain(baseline); const afterCloseGC = serverCount(); + if (afterCloseGC !== baseline) { + console.error(await require(${JSON.stringify(path.join(import.meta.dir, "what-keeps-a-server.ts"))}).whatKeepsAServer()); + } console.log(JSON.stringify({ baseline, afterStopGC, afterCloseGC })); process.exit(0); @@ -2152,7 +2245,7 @@ test("server wrapper survives GC while a websocket is connected after stop()", a // collect the wrapper. baseline already includes the prototype(s), so the // live instance shows as baseline+1. expect(afterStopGC).toBeGreaterThan(baseline); - // Last websocket closing triggers deinit_if_we_can → downgrade → wrapper + // Last websocket closing triggers apply_phase → downgrade → wrapper // becomes collectable again (no leak). expect(afterCloseGC).toBe(baseline); }, 15_000); @@ -2791,9 +2884,10 @@ describe.concurrent("HEAD requests #15355", () => { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); // "1, 2" is not a valid integer so Content-Length parses as 0; what we - // care about is that parsing it does not read freed memory. + // care about is that parsing it does not read freed memory. A Transfer-Encoding + // says that GET would stream, and "chunked" is the only coding GET sends. expect({ stdout: stdout.trim(), stderr: stderr.trim() }).toEqual({ - stdout: JSON.stringify({ te: "gzip, chunked", cl: "0" }), + stdout: JSON.stringify({ te: "chunked", cl: "0" }), stderr: "", }); expect(exitCode).toBe(0); @@ -2857,6 +2951,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 = [ { @@ -3579,7 +3713,7 @@ describe.concurrent("handler GC tracing (heapStats wrapper-count)", () => { client.close(); await closed.promise; client = null; - // The last ws closing triggers on_websocket_closed → deinit_if_we_can, + // The last ws closing triggers on_websocket_closed → apply_phase, // which downgrades the wrapper without an explicit stop(true) — that's // the path under test, so no force-finish here. const afterClose = await gcUntilCountAtMost(baseline); @@ -3869,7 +4003,7 @@ describe.concurrent("handler GC tracing (heapStats wrapper-count)", () => { expect({ body, msgs }).toEqual({ body: "ok-fetch", msgs: ["ws-open", "m:hi"] }); }, 30_000); - // Sibling of the above for server.reload(): on_reload_from_zig moves the + // Sibling of the above for server.reload(): apply_reload moves the // websocket handler shadows into the heap-boxed self.config before // write_ws_handler_slots roots them, and each wrap_handler_slot allocates // via with_async_context_if_needed. Pre-PR on_create's server.protect() 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..7d7645332161 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,17 +1,17 @@ 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 -// HTTPContext.deinit), so unregisterAbortTracker() never runs and -// socket_async_http_abort_tracker is left pointing at freed sockets. The next +// HTTPContext.deinit), so the requests on them never hear of it and +// are left pointing at freed sockets. The next // drainQueuedShutdowns then UAF'd. Fix: skip eviction when the context has // 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. +// evictOldestSslContext. Aborting all requests then reaches every socket. // // In debug+ASAN builds the existing assertUnpoisoned check at // HTTPThread.processEvents catches the freed socket deterministically, so one @@ -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..c7ffd91b87c9 100644 --- a/test/js/bun/http/fetch-file-upload.test.ts +++ b/test/js/bun/http/fetch-file-upload.test.ts @@ -1,5 +1,20 @@ import { describe, expect, test } from "bun:test"; -import { isBroken, isWindows, tempDir, withoutAggressiveGC } from "harness"; +import { + bunEnv, + bunExe, + getFDCount, + isBroken, + isLinux, + isPosix, + isWindows, + tempDir, + tls, + withoutAggressiveGC, +} from "harness"; +import { once } from "node:events"; +import { closeSync, existsSync, openSync, readFileSync, truncateSync } from "node:fs"; +import net from "node:net"; +import { createServer as createTlsServer } from "node:tls"; import { tmpdir } from "os"; import { join } from "path"; @@ -222,6 +237,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 +369,236 @@ 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 }); + }); +}); + +// A unix socket, because its send buffer is small and has a known size. +test.skipIf(!isLinux)( + "a Bun.file() body waits for the socket when the request head has filled the send buffer", + async () => { + using dir = tempDir("fetch-file-full-buffer", {}); + const content = Buffer.alloc(100_000, "0123456789"); + await Bun.write(join(String(dir), "body"), content); + const sendBuffer = Number(readFileSync("/proc/sys/net/core/wmem_default", "utf8")); + using other = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("ok") }); + + for (const [i, extra] of [-2048, 0, 2048, 4096].entries()) { + const unix = join(String(dir), `${i}.sock`); + const { promise: accepted, resolve: onConnection } = Promise.withResolvers(); + const server = net.createServer({ pauseOnConnect: true }, onConnection); + await once(server.listen(unix), "listening"); + try { + // @ts-expect-error latin1Slice is missing in @types/node + const headers = { "x-pad": Buffer.alloc(sendBuffer + extra, "a").latin1Slice() }; + const upload = fetch("http://localhost/", { + unix, + method: "PUT", + body: Bun.file(join(String(dir), "body")), + headers, + }); + const socket = await accepted; + // The HTTP thread has written what it can of the upload by the time it has finished a request that was queued later. + await (await fetch(other.url)).text(); + + let received = Buffer.alloc(0); + socket.on("data", (data: Buffer) => { + received = Buffer.concat([received, data]); + const headEnd = received.indexOf("\r\n\r\n"); + if (headEnd < 0 || received.length < headEnd + 4 + content.length) return; + const whole = received.subarray(headEnd + 4).equals(content); + socket.end(`HTTP/1.1 200 OK\r\nContent-Length: ${String(whole).length}\r\nConnection: close\r\n\r\n${whole}`); + }); + socket.resume(); + expect(await (await upload).text()).toBe("true"); + } finally { + server.close(); + } + } + }, +); + +// Windows reads the whole file before it sends the head. +describe.skipIf(isWindows)("a file that becomes shorter during the upload", () => { + test.concurrent.each(["the first hop", "a redirected hop over TLS"])("rejects on %s", async hop => { + using dir = tempDir("fetch-file-shorter", {}); + const path = join(String(dir), "body"); + // More than the socket buffers of both ends can take. Sparse, so it costs nothing to make. + closeSync(openSync(path, "w")); + truncateSync(path, 64 * 1024 * 1024); + + // Stops reading at the head, cuts the file, and reads on. + const onConnection = (socket: net.Socket) => { + socket.on("error", () => {}); + socket.once("data", () => { + socket.pause(); + truncateSync(path, 0); + socket.resume(); + }); + }; + const target = hop === "the first hop" ? net.createServer(onConnection) : createTlsServer(tls, onConnection); + await once(target.listen(0, "127.0.0.1"), "listening"); + const { port } = target.address() as net.AddressInfo; + using redirector = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => new Response(null, { status: 307, headers: { Location: `https://127.0.0.1:${port}/` } }), + }); + try { + const url = hop === "the first hop" ? `http://127.0.0.1:${port}/` : redirector.url; + // The signal only ends a run in which the fetch would otherwise wait for its idle timeout. + const init = { method: "PUT", body: Bun.file(path), tls: { ca: tls.cert }, signal: AbortSignal.timeout(4000) }; + await expect(fetch(url, init)).rejects.toMatchObject({ code: "ERR_HTTP_CONTENT_LENGTH_MISMATCH" }); + } finally { + target.close(); + } + }); +}); + +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..0427259e98cb 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..e4a6342c7091 100644 --- a/test/js/bun/http/http-server-chunking.test.ts +++ b/test/js/bun/http/http-server-chunking.test.ts @@ -1,648 +1,418 @@ -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. + // The kernel promises no order between two connections, so a round may coalesce. Most must not. + const rounds = 10; + let 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, - ]); + let split = 0; + for (let round = 0; round < rounds; round++) { + reads = []; + for (const segment of ["first", "second", "third"]) { + socket.write(segment); + socket.flush(); + await clock.tick(); + } + // A late segment must not count for the next round. + while (reads.join("") !== "firstsecondthird") await clock.tick(); + if (reads.length === 3) split++; + } - expect(stdout).toContain("200 OK"); - expect(stdout).toContain("Got: Hello"); - expect(exitCode).toBe(0); + expect(split).toBeGreaterThan(rounds / 2); }); - 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 }], + }, + { + name: "a trailer line without a colon, split across reads", + segments: [chunkedRequest() + "5\r\nhello\r\n0\r\nX-T: 9\r\njust", "text\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/node-http-pipeline-callbacks.test.ts b/test/js/bun/http/node-http-pipeline-callbacks.test.ts index e62af14c29d9..5cf8b518d155 100644 --- a/test/js/bun/http/node-http-pipeline-callbacks.test.ts +++ b/test/js/bun/http/node-http-pipeline-callbacks.test.ts @@ -1,7 +1,13 @@ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; +import { tls as tlsCert } from "harness"; +import { once } from "node:events"; +import type { IncomingMessage, ServerResponse } from "node:http"; import { createServer } from "node:http"; +import { createSecureServer } from "node:http2"; import type { AddressInfo } from "node:net"; -import { connect } from "node:net"; +import { connect, createServer as createNetServer } from "node:net"; +import { duplexPair, Writable } from "node:stream"; +import { createServer as createTLSServer, connect as tlsConnect } from "node:tls"; test("aborting a connection settles every queued pipelined response callback once", async () => { const queued = Promise.withResolvers(); @@ -73,3 +79,191 @@ test("aborting a connection settles every queued pipelined response callback onc } } }); + +test("a request body that was put back on its socket before emit('connection') is delivered whole to a listener that reads it after an await", async () => { + const { promise, resolve } = Promise.withResolvers(); + const server = createServer(async (req, res) => { + await null; + let bytes = 0; + const slow = new Writable({ + highWaterMark: 1, + write(chunk, _encoding, callback) { + bytes += chunk.length; + setImmediate(callback); + }, + }); + req.pipe(slow).on("finish", () => resolve(bytes)); + res.end(); + }); + const front = createNetServer(socket => { + socket.once("data", first => { + socket.pause(); + socket.unshift(first); + server.emit("connection", socket); + socket.resume(); + }); + }); + front.listen(0, "127.0.0.1"); + await new Promise(resolve => front.once("listening", resolve)); + const client = connect((front.address() as AddressInfo).port, "127.0.0.1"); + try { + client.on("error", () => {}).resume(); + client.write( + "POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n" + + Array.from("abcd", fill => `400\r\n${fill.repeat(1024)}\r\n`).join("") + + "0\r\n\r\n", + ); + expect(await promise).toBe(4096); + } finally { + client.destroy(); + server.closeAllConnections(); + front.close(); + } +}); + +type Reader = (req: IncomingMessage, res: ServerResponse, take: (chunk: Buffer) => void, end: () => void) => void; +const readers: [string, Reader][] = [ + [ + "pipe() into a slow Writable", + (req, _res, take, end) => { + req.on("end", end).pipe( + new Writable({ + highWaterMark: 1, + write(chunk, _encoding, callback) { + take(chunk); + setImmediate(callback); + }, + }), + ); + }, + ], + [ + "on('data') and pause()", + (req, _res, take, end) => { + req.on("end", end).on("data", chunk => { + take(chunk); + req.pause(); + setImmediate(() => req.resume()); + }); + }, + ], + [ + "once('data') then pause(), and the rest once the response has closed", + (req, res, take, end) => { + req.once("data", chunk => { + take(chunk); + req.pause(); + }); + res.on("close", () => setImmediate(() => req.on("end", end).on("data", take).resume())); + }, + ], +]; +test.each(readers.flatMap(([how, read]) => [true, false].map(flowing => [how, flowing, read] as const)))( + "a request body on a stream that is not a socket is delivered whole to a listener that reads it at once with %s and answers (stream flowing: %p)", + async (_, flowing, read) => { + const { promise, resolve } = Promise.withResolvers<{ bytes: number; end: boolean }>(); + const server = createServer((req, res) => { + const seen = { bytes: 0, end: false }; + req.on("close", () => setImmediate(resolve, seen)); + read( + req, + res, + chunk => (seen.bytes += chunk.length), + () => (seen.end = true), + ); + res.end("ok"); + }); + const [clientSide, serverSide] = duplexPair(); + server.emit("connection", serverSide); + try { + clientSide.on("error", () => {}).resume(); + // The server's side flows from the next tick on. + if (flowing) await new Promise(resolve => setImmediate(resolve)); + clientSide.write( + "POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n" + + Array.from("abcd", fill => `400\r\n${fill.repeat(1024)}\r\n`).join("") + + "0\r\n\r\n", + ); + expect(await promise).toEqual({ bytes: 4096, end: true }); + } finally { + clientSide.destroy(); + server.closeAllConnections(); + } + }, +); + +describe.each(["emit('connection')", "emit('connection') with a TLS socket", "allowHTTP1", "listen()"] as const)( + "over %s", + kind => { + test.each([false, true])( + "a response that never gets its turn emits no 'finish', and its end() callback gets an error (a CONNECT request behind it: %p)", + async withConnect => { + const events: string[] = []; + const { promise: closed, resolve: onClosed } = Promise.withResolvers(); + const onRequest = (req: IncomingMessage, res: ServerResponse) => { + if (req.url === "/0") { + req.socket.on("close", () => setImmediate(onClosed)); + res.writeHead(204).end(); + return; + } + for (const event of ["finish", "close"]) res.on(event, () => events.push(`${req.url} ${event}`)); + if (req.url === "/1") req.socket.end(); + else + res.end("body", (error?: NodeJS.ErrnoException) => events.push(`${req.url} end() ${error?.code ?? "ok"}`)); + }; + const server = + kind === "allowHTTP1" + ? createSecureServer({ ...tlsCert, allowHTTP1: true }, onRequest as any) + : createServer(onRequest); + server.on("connect", (_req, socket) => { + events.push("connect"); + socket.on("error", () => {}); + }); + const front = + kind === "emit('connection')" + ? createNetServer(socket => server.emit("connection", socket)) + : kind === "emit('connection') with a TLS socket" + ? createTLSServer(tlsCert, socket => server.emit("connection", socket)) + : server; + await once(front.listen(0, "127.0.0.1"), "listening"); + const { port } = front.address() as AddressInfo; + const client = + kind === "emit('connection')" || kind === "listen()" + ? connect(port, "127.0.0.1") + : tlsConnect({ + port, + host: "127.0.0.1", + ca: tlsCert.cert, + servername: "localhost", + ALPNProtocols: ["http/1.1"], + }); + try { + client.on("error", () => {}).on("end", () => client.destroy()); + client + .resume() + .write( + "GET /0 HTTP/1.1\r\nHost: localhost\r\n\r\n" + + "POST /1 HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\n\r\nhello" + + "POST /2 HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\n\r\nhello" + + (withConnect ? "CONNECT localhost:1 HTTP/1.1\r\nHost: localhost:1\r\n\r\n" : ""), + ); + await closed; + // uWS parses nothing behind the request whose handler ended the socket. + if (kind === "listen()") expect(events).toEqual(["/1 close"]); + else { + expect(events).toEqual([ + ...(withConnect ? ["connect"] : []), + "/1 close", + "/2 end() ERR_STREAM_DESTROYED", + "/2 close", + ]); + } + } finally { + client.destroy(); + front.close(); + server.close(); + } + }, + ); + }, +); 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..d5cf4ccbc9ab 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 `tunnelGrowth` 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, tunnelGrowth, tunnelsInFlight, 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. + expect(tunnelGrowth).toBeLessThanOrEqual(2); + if (want.outcome.completed > 0) expect(tunnelsInFlight).toBeGreaterThanOrEqual(1); + // Every tunnel has the same `tls` options, so they share the SSL_CTX that the warm-up made. + expect(sslCtxInFlight).toBe(0); + + // 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..ada69536b55a 100644 --- a/test/js/bun/http/proxy-stress-memory-fixture.ts +++ b/test/js/bun/http/proxy-stress-memory-fixture.ts @@ -1,40 +1,56 @@ /** - * 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 + * tunnelGrowth live tunnel count at the end minus the count after the + * warm-up. A leaked tunnel adds one per request. + * tunnelsInFlight highest live tunnel count sampled with requests in flight + * minus the lowest sampled with none. Above 0 proves that + * `tunnelGrowth` can see a leaked tunnel. + * sslCtxInFlight the same as `tunnelsInFlight` for SSL_CTX objects. Tunnels + * with the same `tls` options share one, so it's 0. + * 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 { proxyInternals, 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 fetchProxyTunnelLiveCount = proxyInternals.tunnelLiveCount; 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 +65,19 @@ 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. +let tunnelStart = -1; +let tunnelIdleMin = Infinity; +let tunnelBusyMax = -Infinity; +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 +91,26 @@ 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 has its tunnel by now. + upstream!.once("data", () => { + if (tunnelStart === -1) return; + tunnelBusyMax = Math.max(tunnelBusyMax, fetchProxyTunnelLiveCount()); + sslCtxBusyMax = Math.max(sslCtxBusyMax, sslCtxLiveCount()); + }); + // The "data" handler above relays client → upstream. upstream!.pipe(client); }); upstream.on("error", () => client.destroy()); @@ -88,110 +123,100 @@ 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(); + tunnelStart = fetchProxyTunnelLiveCount(); } + if (tunnelStart !== -1) { + tunnelIdleMin = Math.min(tunnelIdleMin, fetchProxyTunnelLiveCount()); + 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 (fetchProxyTunnelLiveCount() > tunnelStart && Date.now() < deadline) { + await Bun.sleep(5); } +const tunnelEnd = fetchProxyTunnelLiveCount(); +tunnelIdleMin = Math.min(tunnelIdleMin, tunnelEnd); + +console.log( + JSON.stringify({ + completed, + failed, + errors, + connects, + tunnelGrowth: tunnelEnd - tunnelStart, + tunnelsInFlight: tunnelBusyMax === -Infinity ? 0 : tunnelBusyMax - tunnelIdleMin, + 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 fe9ed30a494d..bc1bb44efad2 100644 --- a/test/js/bun/http/req-url-leak.test.ts +++ b/test/js/bun/http/req-url-leak.test.ts @@ -1,57 +1,40 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, withoutAsanQuarantine } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, withoutAsanQuarantine } 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: withoutAsanQuarantine(bunEnv), 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..8f60559438fa 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 malformed list in the Transfer-Encoding header (%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 = ""; @@ -1002,6 +1035,52 @@ describe("SPILL.TERM - invalid chunk terminators", () => { }); }); + test.each([ + ["a line without a colon", "justtext\r\n"], + ["a request line", "GET /admin HTTP/1.1\r\nHost: localhost\r\n"], + ["a space in a field name", "X T: 1\r\n"], + ["a space before the colon", "X-T : 1\r\n"], + ["an empty field name", ": v\r\n"], + ["a control byte in a value", "X-T: a\x01b\r\n"], + ["a NUL in a value", "X-T: a\0b\r\n"], + ["a DEL in a value", "X-T: a\x7fb\r\n"], + ["a folded line", "X-T: 1\r\n folded\r\n"], + ["Transfer-Encoding", "Transfer-Encoding: chunked\r\n"], + ["Content-Length", "Content-Length: 5\r\n"], + ["a malformed line behind 200 fields", "X-T: 1\r\n".repeat(200) + "justtext\r\n"], + ["Content-Length behind 200 fields", "X-T: 1\r\n".repeat(200) + "Content-Length: 5\r\n"], + ])("rejects a trailer section with %s", async (_name, trailers) => { + const urls: string[] = []; + await using server = Bun.serve({ + port: 0, + async fetch(req) { + urls.push(new URL(req.url).pathname); + await req.text(); + return new Response("OK"); + }, + }); + + const client = net.connect(server.port!, "127.0.0.1"); + const responseData = await new Promise((resolve, reject) => { + let data = ""; + client.on("error", reject); + client.on("data", chunk => { + data += chunk.toString(); + }); + client.on("close", () => resolve(data)); + client.write( + "POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n" + + trailers + + "\r\nGET /after HTTP/1.1\r\nHost: localhost\r\n\r\n", + ); + }); + + expect({ statuses: responseData.match(/HTTP\/1\.1 \d+/g), urls }).toEqual({ + statuses: ["HTTP/1.1 400"], + urls: ["/"], + }); + }); + test("rejects zero-chunk terminator split across TCP segments with invalid byte", async () => { // The terminator validation must persist across TCP boundaries. Send the // first (valid) '\r', yield to the event loop so the server's recv() runs @@ -1046,14 +1125,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, @@ -1352,8 +1425,9 @@ describe("chunk size strict hex digit validation", () => { test(`rejects '${ch}' in chunk size (not a hex digit)`, async () => { await using server = Bun.serve({ port: 0, + // Reads the body: the request says "Connection: close", and nothing is parsed behind a complete response to it. async fetch(req) { - return new Response("OK"); + return new Response(await req.text()); }, }); @@ -1368,8 +1442,9 @@ describe("chunk size strict hex digit validation", () => { test(`rejects '${ch}' (ASCII ${ch.charCodeAt(0)}) in chunk size`, async () => { await using server = Bun.serve({ port: 0, + // Reads the body: the request says "Connection: close", and nothing is parsed behind a complete response to it. async fetch(req) { - return new Response("OK"); + return new Response(await req.text()); }, }); @@ -1383,8 +1458,9 @@ describe("chunk size strict hex digit validation", () => { test(`rejects '${ch}' in chunk size`, async () => { await using server = Bun.serve({ port: 0, + // Reads the body: the request says "Connection: close", and nothing is parsed behind a complete response to it. async fetch(req) { - return new Response("OK"); + return new Response(await req.text()); }, }); @@ -1783,6 +1859,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 44c55c32d6a0..d2b1b77d8e07 100644 --- a/test/js/bun/http/serve-direct-readable-stream.test.ts +++ b/test/js/bun/http/serve-direct-readable-stream.test.ts @@ -875,14 +875,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({ @@ -906,9 +912,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 => { @@ -916,11 +924,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 as Buffer).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"); @@ -931,14 +942,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(); } @@ -2329,12 +2345,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 5fb8ba171218..b3f53ba052e1 100644 --- a/test/js/bun/http/serve-directory-routes.test.ts +++ b/test/js/bun/http/serve-directory-routes.test.ts @@ -558,6 +558,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..9b1a2947e1dd 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 a few fixed names. import { afterAll, beforeAll, expect, test } from "bun:test"; import { bunEnv, bunExe, isLinux, tempDir } from "harness"; import net from "node:net"; @@ -8,6 +9,17 @@ import { join } from "node:path"; const cc = Bun.which("cc") || Bun.which("gcc") || Bun.which("clang"); +// For `eai-.test` the shim's getaddrinfo() returns EAI_. The values are the error's code and its errno, which is Node's (libuv's UV_EAI_). +const EAI_CASES = { + noname: ["ENOTFOUND", -3008], + nodata: ["ENOTFOUND", -3007], + again: ["EAI_AGAIN", -3001], + fail: ["EBADRESP", -3004], + memory: ["ENOMEM", -3006], + family: ["EBADFAMILY", -3005], + addrfamily: ["EBADFAMILY", -3000], +} as const; + // FAIL_EPOLL_ADD=listener: listening TCP sockets (SO_ACCEPTCONN). // FAIL_EPOLL_ADD=accepted: connected SOCK_STREAM. FAIL_EPOLL_ADD=udp: SOCK_DGRAM. // Non-socket fds (timerfd, eventfd) always pass through. @@ -15,11 +27,20 @@ const SHIM_C = /* c */ ` #define _GNU_SOURCE #include #include +#include #include #include #include #include +// Not every libc's has these two. +#ifndef EAI_NODATA +#define EAI_NODATA -5 +#endif +#ifndef EAI_ADDRFAMILY +#define EAI_ADDRFAMILY -9 +#endif + static int (*real_epoll_ctl)(int, int, int, struct epoll_event *); static int mode = -1; // 0 = listener, 1 = accepted, 2 = udp @@ -46,6 +67,42 @@ 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"); +${Object.keys(EAI_CASES) + .map(key => ` if (node && strcmp(node, "eai-${key}.test") == 0) return EAI_${key.toUpperCase()};`) + .join("\n")} + return real(node, service, hints, res); +} +`; + +const DNS_FIXTURE = /* js */ ` +const shape = ({ code, errno, syscall, hostname, message }) => ({ code, errno, syscall, hostname, message }); +const out = {}; +for (const hostname of ${JSON.stringify(Object.keys(EAI_CASES).map(key => `eai-${key}.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 +204,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 +266,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), @@ -304,11 +362,11 @@ test.concurrent.skipIf(!isLinux || !cc)( expect({ stderr, line }).toEqual({ stderr: expect.any(String), line: expect.stringContaining("{") }); const result = JSON.parse(line); // Synchronous NULL from us_socket_group_connect → do_connect() Err → - // handle_connect_error remaps the errno and rejects with syscall "connect". + // handle_connect_error rejects with the errno and syscall "connect". expect(result).toEqual({ settled: "rejected", - code: "ECONNREFUSED", - errno: -111, + code: "ENOSPC", + errno: -28, syscall: "connect", message: expect.any(String), }); @@ -332,3 +390,26 @@ 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 out: Record = {}; + for (const [key, [code, errno]] of Object.entries(EAI_CASES)) { + const hostname = `eai-${key}.test`; + for (const api of ["Bun.serve", "Bun.listen", "http", "net"]) { + out[`${api} ${hostname}`] = { + code, + errno, + syscall: "getaddrinfo", + hostname, + message: `getaddrinfo ${code} ${hostname}`, + }; + } + } + expect({ out: JSON.parse(stdout || "null"), stderr }).toEqual({ out, 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..246ae61f711a 100644 --- a/test/js/bun/http/serve-http2-fixture.ts +++ b/test/js/bun/http/serve-http2-fixture.ts @@ -4,6 +4,7 @@ // // bun serve-http2-fixture.ts --big-file [--tls] [--no-http1] [--http3] [--idle-timeout ] import { serve } from "bun"; +import { sweepSocketTimers } from "bun:internal-for-testing"; import { tls as tlsCert } from "harness"; import { parseArgs } from "node:util"; @@ -22,6 +23,18 @@ const bigFile = args["big-file"]!; const big = Buffer.alloc(5 * 1024 * 1024, "abcdefghijklmnop"); let lateRead: PromiseWithResolvers | undefined; const stopCalled = Promise.withResolvers(); +// A handler with ?until= waits for /release?gate=. +const gates = new Map>(); +function gate(name: string) { + let gate = gates.get(name); + if (!gate) gates.set(name, (gate = Promise.withResolvers())); + return gate; +} +// One socket timeout sweep, as if 4 seconds had passed. Never from inside a socket's own dispatch. +async function sweep() { + await new Promise(setImmediate); + sweepSocketTimers(1); +} const makeRoutes = () => ({ "/api/:id": (req: Bun.BunRequest<"/api/:id">) => @@ -232,13 +245,11 @@ async function handler(req: Request, server: Bun.Server): Promise): Promise): 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* () { @@ -301,17 +316,23 @@ async function handler(req: Request, server: Bun.Server): 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..70bd984fa30a 100644 --- a/test/js/bun/http/serve-http2-lifecycle.test.ts +++ b/test/js/bun/http/serve-http2-lifecycle.test.ts @@ -1,10 +1,13 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; -import http2 from "node:http2"; +import { once } from "node:events"; +import net from "node:net"; +import { Duplex } from "node:stream"; import tls from "node:tls"; import { F, - H2Result, + Fixture, + PREFACE, RawH2, T, baseHeaders, @@ -23,6 +26,20 @@ const fetchH3 = (port: number, path: string, init: RequestInit = {}) => tls: { rejectUnauthorized: false }, } as RequestInit); +// Over a connection of its own. With an `idleTimeout` of 4 or more, each request keeps it from timing out. +const control = async (fx: { port: number }, path: string) => + void (await (await fetch(`http://127.0.0.1:${fx.port}${path}`)).text()); +/** One socket timeout sweep in the server, as if 4 seconds had passed. A timeout is never early, so `idleTimeout` 4 outlives one sweep and ends at the second. */ +const sweep = (fx: { port: number }) => control(fx, "/sweep"); +const release = (fx: { port: number }, gate: string) => control(fx, "/release?gate=" + gate); +/** Sweeps until `session` has closed, `limit` times at most. Returns whether it got there. */ +async function sweepUntilClosed(fx: { port: number }, session: Awaited>, limit: number) { + for (let i = 0; i < limit && !session.closed; i++) await sweep(fx); + // The answer to a sweep comes over a connection of its own, so a close can still be on its way. It is here ahead of the answer to a ping. + if (!session.closed) await new Promise(r => session.ping(() => r())); + return session.closed; +} + describe("Bun.serve http2 + http3 on one port", () => { test("fetch, static and file routes over both; alt-svc advertised on h2; reload; stop with a stream open on each", async () => { await using fx = await startFixture({ tls: true, http3: true }); @@ -82,17 +99,19 @@ describe("Bun.serve http2 lifecycle", () => { }); test("idleTimeout closes a silent connection; server.timeout(req, 0) exempts it", async () => { - // usockets ticks timeouts in 4 s steps, so this test needs real seconds. - await using fx = await startFixture({ tls: false, idleTimeout: 2 }); + await using fx = await startFixture({ tls: false, idleTimeout: 4 }); const idle = await connectH2(fx.port, false); - const idleClosed = new Promise(r => idle.once("close", () => r())); const busy = await connectH2(fx.port, false); - const kept = request(busy, { ":path": "/keepalive?ms=9000" }); - await idleClosed; + const kept = request(busy, { ":path": "/keepalive?until=done" }); + // Answered behind it on the same connection, so the exemption is in place. + await request(busy, { ":path": "/hello" }); + expect(await sweepUntilClosed(fx, idle, 8)).toBe(true); + for (let i = 0; i < 4; i++) await sweep(fx); + await release(fx, "done"); expect((await kept).body.toString()).toBe("kept"); expect(fx.stderr()).not.toContain("KEEPALIVE-ABORTED"); busy.close(); - }, 30000); + }); test("graceful stop closes a connection whose last stream ends outside a socket event", async () => { await using fx = await startFixture({ tls: false }); @@ -126,31 +145,153 @@ describe("Bun.serve http2 lifecycle", () => { raw.close(); }); - 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 }); - const session = await connectH2(fx.port, false); - const res = await new Promise((resolve, reject) => { - const r = session.request({ ":path": "/slow-read?ms=700", ":method": "POST" }, { endStream: false }); - const chunks: Buffer[] = []; - let headers: http2.IncomingHttpHeaders = {}; - r.on("response", h => (headers = h)); - r.on("data", (c: Buffer) => chunks.push(c)); - r.on("end", () => resolve({ status: Number(headers[":status"]), headers, body: Buffer.concat(chunks) })); - r.on("error", reject); - // 12 × 512 KB; at 700 ms per delivered chunk the stream sits paused - // (window closed) for well over idleTimeout in total. - let i = 0; - const next = () => (i++ < 12 ? r.write(Buffer.alloc(512 * 1024), next) : r.end()); - next(); + // 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); + }); }); - expect(res.body.toString()).toBe(String(12 * 512 * 1024)); - session.close(); - }, 40000); - // usockets ticks timeouts every 4 s and idleTimeout rounds to ticks, so 8 s - // (two ticks) with keepalive traffic every 2 s is the smallest setting that - // separates "refreshed the timer" from "did not". + 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 () => { + await using fx = await startFixture({ tls: false, idleTimeout: 4 }); + const raw = await RawH2.connect(fx.port, false); + const settings = (await raw.waitFor(f => f.type === T.SETTINGS && (f.flags & F.ACK) === 0)).payload; + let streamWindow = 65535; + for (let i = 0; i < settings.length; i += 6) { + if (settings.readUInt16BE(i) === 4) streamWindow = settings.readUInt32BE(i + 2); + } + const total = 4 * 1024 * 1024; + const chunk = Buffer.alloc(16384); + let sent = 0; + let pings = 0; + const granted = (streamId: number) => + raw.frames + .filter(f => f.type === T.WINDOW_UPDATE && f.streamId === streamId) + .reduce((sum, f) => sum + f.payload.readUInt32BE(0), 0); + const hasRoom = () => Math.min(streamWindow + granted(1), 65535 + granted(0)) - sent >= chunk.length; + // Sends what flow control allows, until all of it is sent or the server has stopped widening the window. + const pump = async () => { + while (sent < total) { + for (; sent < total && hasRoom(); sent += chunk.length) raw.write(frame(T.DATA, 0, 1, chunk)); + // Answered behind every WINDOW_UPDATE for what was sent so far. + raw.write(frame(T.PING, 0, 0, Buffer.alloc(8, ++pings))); + await raw.waitFor(f => f.type === T.PING && (f.flags & F.ACK) !== 0 && f.payload[0] === pings); + if (!hasRoom()) return; + } + }; + raw.headers(1, baseHeaders("/slow-read?until=go", "POST"), F.END_HEADERS); + await pump(); + expect(sent).toBeLessThan(total); + // 16 s in which nothing moves, because the server itself closed the window. + for (let i = 0; i < 4; i++) await sweep(fx); + await release(fx, "go"); + while (sent < total) { + await raw.waitFor(hasRoom); + await pump(); + } + raw.write(frame(T.DATA, F.END_STREAM, 1)); + expect((await raw.body(1)).toString()).toBe(String(total)); + raw.close(); + }); + for (const [name, keepalive] of [ ["PING", (raw: RawH2) => raw.write(frame(T.PING, 0, 0, Buffer.from("keepaliv")))], // An open POST sending empty non-final DATA frames. @@ -165,42 +306,40 @@ describe("Bun.serve http2 lifecycle", () => { for (let i = 0, id = 1; i < 8; i++, id += 2) stalled.headers(id, baseHeaders("/big")); stalled.write(frame(T.HEADERS, F.END_HEADERS, 101, hpackLiteral(baseHeaders("/echo", "POST")))); await stalled.waitFor(f => f.type === T.HEADERS && f.streamId === 15); - // Control: a connection making real progress every 2 s on the same server outlives it. + // Control: a connection making real progress ahead of every sweep on the same server outlives it. const live = await RawH2.connect(fx.port, false); - let liveId = 1; - const t0 = Date.now(); - const tick = setInterval(() => { - if (!stalled.closed) keepalive(stalled); - if (!live.closed) { + try { + for (let liveId = 1; liveId < 16 && !stalled.closed; liveId += 2) { + keepalive(stalled); live.headers(liveId, baseHeaders("/hello")); - liveId += 2; + await live.body(liveId); + await sweep(fx); } - }, 2000); - try { - await stalled.waitForClose(); - expect(Date.now() - t0).toBeLessThan(20000); + expect(stalled.closed).toBe(true); expect(stalled.frames.some(f => f.type === T.GOAWAY)).toBe(true); expect(live.closed).toBe(false); } finally { - clearInterval(tick); stalled.close(); live.close(); } - }, 40000); + }); } test("server.timeout(req, n) on h2 is per connection: the most permissive open request wins", async () => { await using fx = await startFixture({ tls: false, idleTimeout: 4 }); const session = await connectH2(fx.port, false); - // 30 s is set first, then 1 s; both sleep 6 s. Max-wins keeps the - // connection; last-wins, min-wins or a no-op would all close it at the 4 s tick. - const a = request(session, { ":path": "/t?s=30&ms=6000" }); - await new Promise(r => setTimeout(r, 100)); - const b = request(session, { ":path": "/t?s=1&ms=6000" }); + // 30 s is set first, then 1 s; both answer after 16 s. Max-wins keeps the + // connection; last-wins, min-wins or a no-op would all close it within 8 s. + const a = request(session, { ":path": "/t?s=30&until=done" }); + const b = request(session, { ":path": "/t?s=1&until=done" }); + // Answered behind them on the same connection, so both have set their timeout. + await request(session, { ":path": "/hello" }); + for (let i = 0; i < 4; i++) await sweep(fx); + await release(fx, "done"); const [ra, rb] = await Promise.all([a, b]); expect([ra.body.toString(), rb.body.toString()]).toEqual(["t30", "t1"]); session.close(); - }, 40000); + }); // Same two shapes serve.test.ts has for HTTP/1: stop(true) synchronously // inside the handler, and stop(true) between two awaits; each over a GET and @@ -242,20 +381,21 @@ describe("Bun.serve http2 lifecycle", () => { test("server.timeout(req, n) on h2: once the more permissive request ends, the remaining one's budget applies", async () => { await using fx = await startFixture({ tls: false }); // idleTimeout 30 const session = await connectH2(fx.port, false); - const closed = new Promise(r => session.once("close", () => r())); - // A keeps the 30 s default and answers after 1.5 s; B, opened while A is in - // flight, asks for 1 s and would answer after 60 s. While both are open the - // 30 s wins. Once A has retired, B's 1 s is the connection's budget: the - // server idles it out at the next 4 s tick, not 30 s later. - const a = request(session, { ":path": "/slow?ms=1500" }); - const pending = request(session, { ":path": "/t?s=1&ms=60000" }).catch(e => e); + // A keeps the 30 s default and answers after 12 s; B, opened while A is in + // flight, asks for 1 s and never answers. While both are open the 30 s + // wins. Once A has retired, B's 1 s is the connection's budget: the + // server idles it out at the next sweep, not 30 s (eight sweeps) later. + const a = request(session, { ":path": "/slow?until=a" }); + const pending = request(session, { ":path": "/t?s=1&until=never" }).catch(e => e); + // Answered behind them on the same connection, so B has set its timeout. + await request(session, { ":path": "/hello" }); + for (let i = 0; i < 3; i++) await sweep(fx); + await release(fx, "a"); expect((await a).body.toString()).toBe("slow"); - const t0 = Date.now(); - await closed; - expect(Date.now() - t0).toBeLessThan(15000); + expect(await sweepUntilClosed(fx, session, 6)).toBe(true); const result = await pending; expect(result instanceof Error ? NaN : result.status).toBeNaN(); - }, 30000); + }); test("--max-http-header-size applies to h2 like HTTP/1.1", async () => { await using fx = await startFixture({ tls: false, execArgv: ["--max-http-header-size=4096"] }); @@ -285,15 +425,17 @@ describe("Bun.serve http2 lifecycle", () => { }); test("idle timer is re-armed after a request that was exempted from it", async () => { - await using fx = await startFixture({ tls: false, idleTimeout: 2 }); + await using fx = await startFixture({ tls: false, idleTimeout: 4 }); const session = await connectH2(fx.port, false); - const closed = new Promise(r => session.once("close", () => r(Date.now()))); // /keepalive sets server.timeout(req, 0) for its duration; once it's done the // connection's idle timeout must apply again. - expect((await request(session, { ":path": "/keepalive?ms=5000" })).body.toString()).toBe("kept"); - const tDone = Date.now(); - expect((await closed) - tDone).toBeLessThan(15000); - }, 40000); + const kept = request(session, { ":path": "/keepalive?until=done" }); + await request(session, { ":path": "/hello" }); + for (let i = 0; i < 4; i++) await sweep(fx); + await release(fx, "done"); + expect((await kept).body.toString()).toBe("kept"); + expect(await sweepUntilClosed(fx, session, 8)).toBe(true); + }); test("h2 is not negotiated below TLS 1.2; a TLS 1.2 client offering only ECDHE-RSA-AES128-GCM-SHA256 works", async () => { await using fx = await startFixture({ tls: true }); diff --git a/test/js/bun/http/serve-http2.test.ts b/test/js/bun/http/serve-http2.test.ts index 0a9613f62fff..565ad20a02d4 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 { setSocketOptions, sweepSocketTimers } 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, 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); @@ -419,6 +420,18 @@ for (const secure of [true, false]) { raw.close(); }); + // A 100 Continue would start an upload that the 413 behind it refuses. HTTP/1.1 and HTTP/3 do the same. + test("answers 413 without an interim response when content-length exceeds maxRequestBodySize", async () => { + const raw = await RawH2.connect(fx.port, secure); + await raw.waitFor(f => f.type === T.SETTINGS); + // The default limit is 128 MiB. + const headers = [...baseHeaders("/echo", "POST"), ["expect", "100-continue"], ["content-length", "200000000"]]; + raw.headers(1, headers as [string, string][], F.END_HEADERS); + const first = await raw.waitFor(f => f.type === T.HEADERS && f.streamId === 1); + expect(decodeStatus(first.payload)).toBe(413); + raw.close(); + }); + test("server.requestIP works", async () => { const res = await request(session, { ":path": "/ip" }); const ip = JSON.parse(res.body.toString()); @@ -970,4 +983,163 @@ 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 { + sweepSocketTimers(2); + 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: 1, + 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(); + sweepSocketTimers(1); + } + expect(onAbort).not.toHaveBeenCalled(); + + for (let i = 0; i < 8 && onAbort.mock.calls.length === 0; i++) sweepSocketTimers(1); + expect(onAbort).toHaveBeenCalledTimes(1); + } finally { + raw.close(); + } + }); +}); + +test.concurrent.each([ + ["HTTP/2", "HTTP/1.1"], + ["HTTP/1.1", "HTTP/2"], +])("development mode warns about the idle timeout once, whether %s or %s times out first", async (...order) => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + import { sweepSocketTimers } from "bun:internal-for-testing"; + import http2 from "node:http2"; + import net from "node:net"; + let started = Promise.withResolvers(); + let aborted = Promise.withResolvers(); + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + development: true, + http2: true, + fetch(req) { + req.signal.addEventListener("abort", () => aborted.resolve()); + started.resolve(); + return new Promise(() => {}); + }, + }); + for (const protocol of ${JSON.stringify(order)}) { + let client; + if (protocol === "HTTP/2") { + client = http2.connect("http://127.0.0.1:" + server.port); + client.request({ ":path": "/" }).on("error", () => {}).end(); + } else { + client = net.connect(server.port, "127.0.0.1"); + client.write("GET / HTTP/1.1\\r\\nHost: localhost\\r\\n\\r\\n"); + } + client.on("error", () => {}); + await started.promise; + // uWS arms the request's timer after the handler returns. The default 10 seconds are 4 sweeps. + await new Promise(setImmediate); + sweepSocketTimers(4); + await aborted.promise; + console.log(protocol, "timed out"); + client.destroy(); + started = Promise.withResolvers(); + aborted = Promise.withResolvers(); + } + await 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).toBe("warn: Bun.serve() timed out a request after 10 seconds. Pass `idleTimeout` to configure.\n"); + expect(stdout).toBe(order.map(protocol => protocol + " timed out\n").join("")); + expect(exitCode).toBe(0); }); diff --git a/test/js/bun/http/serve-http3.test.ts b/test/js/bun/http/serve-http3.test.ts index 185f4650e325..d35381fa4a23 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"); }); }); @@ -1882,9 +1973,9 @@ describe("Bun.serve HTTP/3 request validation", () => { results.after = await h3Exchange(server.port!, requestHeaders("/")); expect(results).toEqual({ - [JSON.stringify("a\r\nb: c")]: "closed ERR_QUIC_APPLICATION_ERROR 270", - [JSON.stringify("a\nb")]: "closed ERR_QUIC_APPLICATION_ERROR 270", - [JSON.stringify("a\rb")]: "closed ERR_QUIC_APPLICATION_ERROR 270", + [JSON.stringify("a\r\nb: c")]: "400 ", + [JSON.stringify("a\nb")]: "400 ", + [JSON.stringify("a\rb")]: "400 ", wellFormed: "200 1", after: "200 1", }); @@ -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,122 @@ 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", + ); + }); + + // RFC 9110 section 9.1: the method is case-sensitive. HTTP/1.1 and HTTP/2 answer 501 too. + test("answers 501 to a known method that isn't uppercase", async () => { + const { server, calls } = serve(); + await using _ = server; + const rows: Record> = {}; + for (const method of ["get", "Get", "patch", "m-search"]) { + rows[method] = requestHeaders("/echo", { ":method": method }); + } + expect({ results: await exchangeAll(server.port!, rows), calls }).toEqual({ + results: Object.fromEntries(Object.keys(rows).map(row => [row, "501 "])), + calls: [], + }); + }); + + // RFC 9114 sections 4.2 and 4.3. HTTP/2 resets the stream for each of these. + test("answers 400 to a malformed field section", async () => { + const { server, calls } = serve(); + await using _ = server; + const { ":scheme": _scheme, ...noScheme } = requestHeaders("/echo"); + const rows: Record> = { + "no :scheme": noScheme, + "unknown pseudo-header": requestHeaders("/echo", { ":protocol": "websocket" }), + "empty :scheme": requestHeaders("/echo", { ":scheme": "" }), + ":authority with userinfo": requestHeaders("/echo", { ":authority": "user@localhost" }), + "host that contradicts :authority": requestHeaders("/echo", { host: "other.invalid" }), + "control byte in a value": requestHeaders("/echo", { "x-probe": "a\x01b" }), + }; + 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("/echo", { host: "localhost", "x-probe": "a\tb" }))).toBe( + "200 GET 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, @@ -2103,13 +2309,7 @@ describe("Bun.serve HTTP/3 request validation", () => { }, }); - const authorities = [ - "example.com/other", - "user@example.com", - "example.com#frag", - "example.com\\other:8080", - "[::1]:3000?q", - ]; + const authorities = ["example.com/other", "example.com#frag", "example.com\\other:8080", "[::1]:3000?q"]; const results: Record = {}; for (const authority of authorities) { results[authority] = await h3Exchange(server.port!, { ...requestHeaders("/index"), ":authority": authority }); @@ -2121,7 +2321,6 @@ describe("Bun.serve HTTP/3 request validation", () => { expect(results).toEqual({ "example.com/other": "200 /index", - "user@example.com": "200 /index", "example.com#frag": "200 /index", "example.com\\other:8080": "200 /index", "[::1]:3000?q": "200 /index", @@ -2129,6 +2328,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,61 +2528,199 @@ 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); }); -}); - -// The HTTP/3 twin of the HTTP/1 cases in websocket-server.test.ts: ws.close() -// runs close() before it returns, and a request handler that calls it must still -// run to completion before the nextTick and promise callbacks it queued. The -// socket being closed lives on a plain HTTP/1 server, since HTTP/3 carries no -// WebSockets; any handler can close it. -describe("Bun.serve HTTP/3 request handlers run to completion before the callbacks they queued", () => { - async function openHeldSocket() { - const order: string[] = []; - const opened = Promise.withResolvers>(); - const closed = Promise.withResolvers(); - const wsServer = Bun.serve({ - port: 0, - fetch: (req, srv) => (srv.upgrade(req) ? undefined : new Response("upgrade() failed", { status: 500 })), - websocket: { - open: ws => opened.resolve(ws), - message() {}, - close() { - order.push("close()"); - }, - }, - }); - const client = new WebSocket(wsServer.url.href.replace(/^http/, "ws")); - client.onerror = () => closed.resolve(); - client.onclose = () => closed.resolve(); - const held = await opened.promise; - return { - order, - closed: closed.promise, - handler() { - process.nextTick(() => order.push("nextTick")); - Promise.resolve().then(() => order.push("microtask")); - held.close(); - order.push("rest of handler"); - return new Response("ok"); - }, - [Symbol.dispose]: () => wsServer.stop(true), - }; - } - test("fetch() and a route handler closing an open ServerWebSocket", async () => { - using viaFetch = await openHeldSocket(); - using viaRoute = await openHeldSocket(); + // A 100 Continue would start an upload that the 413 behind it refuses. HTTP/1.1 and HTTP/2 do the same. + test("but not when content-length exceeds maxRequestBodySize", async () => { await using server = Bun.serve({ port: 0, tls, http3: true, - routes: { "/route": viaRoute.handler }, - fetch: viaFetch.handler, + http1: false, + maxRequestBodySize: 100, + fetch: async req => new Response("content:" + (await req.text())), }); - - const responses = { - fetch: await h3Exchange(server.port!, requestHeaders("/")), - route: await h3Exchange(server.port!, requestHeaders("/route")), + 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); + await client.opened; + const seen: string[] = []; + const final = Promise.withResolvers(); + const stream = await client.createBidirectionalStream({ + oninfo(received) { + seen.push("info " + received[":status"]); + }, + onheaders(received) { + seen.push("headers " + received[":status"]); + final.resolve(); + }, + }); + stream.closed.then(final.resolve, final.resolve); + stream.sendHeaders(requestHeaders("/", { ":method": "POST", expect: "100-continue", "content-length": "101" })); + await final.promise; + if (!client.destroyed) client.close().catch(() => {}); + expect(seen).toEqual(["headers 413"]); + }); + + // 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() +// runs close() before it returns, and a request handler that calls it must still +// run to completion before the nextTick and promise callbacks it queued. The +// socket being closed lives on a plain HTTP/1 server, since HTTP/3 carries no +// WebSockets; any handler can close it. +describe("Bun.serve HTTP/3 request handlers run to completion before the callbacks they queued", () => { + async function openHeldSocket() { + const order: string[] = []; + const opened = Promise.withResolvers>(); + const closed = Promise.withResolvers(); + const wsServer = Bun.serve({ + port: 0, + fetch: (req, srv) => (srv.upgrade(req) ? undefined : new Response("upgrade() failed", { status: 500 })), + websocket: { + open: ws => opened.resolve(ws), + message() {}, + close() { + order.push("close()"); + }, + }, + }); + const client = new WebSocket(wsServer.url.href.replace(/^http/, "ws")); + client.onerror = () => closed.resolve(); + client.onclose = () => closed.resolve(); + const held = await opened.promise; + return { + order, + closed: closed.promise, + handler() { + process.nextTick(() => order.push("nextTick")); + Promise.resolve().then(() => order.push("microtask")); + held.close(); + order.push("rest of handler"); + return new Response("ok"); + }, + [Symbol.dispose]: () => wsServer.stop(true), + }; + } + + test("fetch() and a route handler closing an open ServerWebSocket", async () => { + using viaFetch = await openHeldSocket(); + using viaRoute = await openHeldSocket(); + await using server = Bun.serve({ + port: 0, + tls, + http3: true, + routes: { "/route": viaRoute.handler }, + fetch: viaFetch.handler, + }); + + const responses = { + fetch: await h3Exchange(server.port!, requestHeaders("/")), + route: await h3Exchange(server.port!, requestHeaders("/route")), }; await Promise.all([viaFetch.closed, viaRoute.closed]); @@ -2459,3 +2857,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..b2185784cf61 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 { bunEnv, bunExe, isLinux, isWindows, tempDir, tmpdirSync } from "harness"; +import { closeSync, existsSync, openSync, 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: "", } : { @@ -161,6 +164,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: 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 as any).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 @@ -180,10 +221,11 @@ describe.each([ } catch (e) { error = e; } - const { name, code, syscall, hostname: errHostname, message } = error ?? {}; - expect({ name, code, syscall, hostname: errHostname, message }).toEqual({ + const { name, code, errno, syscall, hostname: errHostname, message } = error ?? {}; + expect({ name, code, errno, syscall, hostname: errHostname, message }).toEqual({ name: "Error", code: "ENOTFOUND", + errno: -3008, syscall: "getaddrinfo", hostname, message: `getaddrinfo ENOTFOUND ${hostname}`, @@ -264,3 +306,117 @@ 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); + }); + + const facts = (listen: () => { stop(closeActiveConnections: boolean): unknown }) => { + try { + listen().stop(true); + return "listening"; + } catch ({ code, errno, syscall, address, port, path }: any) { + return { code, errno: getSystemErrorName(errno), syscall, address, port, path }; + } + }; + + test.skipIf(isWindows || !hasIPv4)("with the properties Bun.listen() gives the same failure", () => { + using dir = tempDir("serve-listen-error", {}); + using occupant = serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() }); + const missing = join(String(dir), "missing", "a.sock"); + const tooLong = join(String(dir), Buffer.alloc(200, "a").toString() + ".sock"); + const rows = [ + [ + { hostname: "127.0.0.1", port: occupant.port }, + { code: "EADDRINUSE", address: "127.0.0.1", port: occupant.port }, + ], + [{ unix: missing }, { code: "ENOENT", address: missing, path: missing }], + [{ unix: tooLong }, { code: "ENAMETOOLONG", address: tooLong, path: tooLong }], + ] as const; + for (const [address, { code, ...rest }] of rows) { + const expected = { port: undefined, path: undefined, ...rest, code, errno: code, syscall: "listen" }; + expect(facts(() => serve({ ...address, fetch: () => new Response() }))).toEqual(expected); + expect(facts(() => Bun.listen({ ...address, socket: { data() {} } } as Bun.UnixSocketOptions))).toEqual(expected); + } + }); + + test.skipIf(isWindows)("Bun.listen({ fd }) reports the code listen() failed with", () => { + using dir = tempDir("serve-listen-fd", { "file.txt": "" }); + const fd = openSync(join(String(dir), "file.txt"), "r"); + const listen = () => Bun.listen({ fd, socket: { data() {} } } as any); + const expected = { syscall: "listen", address: undefined, port: undefined, path: undefined }; + expect(facts(listen)).toEqual({ ...expected, code: "ENOTSOCK", errno: "ENOTSOCK" }); + closeSync(fd); + expect(facts(listen)).toEqual({ ...expected, code: "EBADF", errno: "EBADF" }); + }); +}); 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 7a59637ed855..020daaf9691c 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, withoutAsanQuarantine } 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 ResponseStream 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: withoutAsanQuarantine(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-tls-prehandshake-reap-fixture.ts b/test/js/bun/http/serve-tls-prehandshake-reap-fixture.ts index 9857865e2f8d..b284a715d452 100644 --- a/test/js/bun/http/serve-tls-prehandshake-reap-fixture.ts +++ b/test/js/bun/http/serve-tls-prehandshake-reap-fixture.ts @@ -1,12 +1,13 @@ // 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. +// had not closed after the sweeps that bring the idle timeout. +import { getEventLoopStats, sweepSocketTimers } from "bun:internal-for-testing"; import net from "node:net"; const N = Number(process.env.N || 300); @@ -18,11 +19,11 @@ const server = Bun.serve({ tls: { cert: process.env.TLS_CERT!, key: process.env.TLS_KEY! }, fetch: () => new Response("ok"), }); +const holdersBefore = getEventLoopStats().sweepTimerCount; let opened = 0; let settled = 0; const held = new Set(); -const allSettled = Promise.withResolvers(); const socks: net.Socket[] = []; for (let i = 0; i < N; i++) { const s = net.connect(server.port as number, "127.0.0.1", () => { @@ -33,18 +34,27 @@ for (let i = 0; i < N; i++) { s.on("close", () => { held.delete(s); settled++; - if (settled === N) allSettled.resolve(); }); socks.push(s); } -// The idle timeout is 10s and the sweep granularity is 4s, so a correctly -// behaving server closes every connection by ~16s; the deadline leaves margin -// for debug builds. The race here is won or lost at a single sweep tick, so a -// server that has not reaped them all by the deadline never will. -await Promise.race([allSettled.promise, Bun.sleep(DEADLINE_MS)]); +// Nothing reports an accept, and on Linux the kernel holds the burst back for about a +// second. Every socket of this loop holds the sweep's timer, so the holders that the +// clients don't account for are the connections the server has accepted. +const accepted = () => getEventLoopStats().sweepTimerCount - holdersBefore - (N - settled); +const deadline = performance.now() + DEADLINE_MS; +while (accepted() <= 0 && performance.now() < deadline) await Bun.sleep(5); +// Each turn of the loop takes sockets out of the queue again, so this looks on every +// turn and runs the sweeps in the turn of the last accept. +while (accepted() < N - settled && performance.now() < deadline) await new Promise(resolve => setImmediate(resolve)); -console.log(JSON.stringify({ opened, held: held.size })); +// The idle timeout is 10s, which is due at the third or fourth sweep. A parked +// connection's timeout is held back for 30 more. +sweepSocketTimers(8); +const left = accepted(); +while (left <= 0 && settled < N && performance.now() < deadline) await Bun.sleep(5); + +console.log(JSON.stringify({ opened, held: Math.max(left, held.size) })); for (const s of socks) s.destroy(); server.stop(true); diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 520d29b817ed..cb6a87186abd 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -28,6 +28,7 @@ import { connect } from "net"; import { join, resolve } from "path"; // import { renderToReadableStream } from "react-dom/server"; // import app_jsx from "./app.jsx"; +import { setSocketOptions, sweepSocketTimers } from "bun:internal-for-testing"; import { heapStats } from "bun:jsc"; import { spawn } from "child_process"; import { on, once } from "node:events"; @@ -190,12 +191,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(); - await 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 +267,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 +387,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 +403,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 +653,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 +662,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 +677,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(); } @@ -1647,7 +1689,7 @@ it("reload() of a node:http-backed server keeps the 'connection' event firing", }); // Under --hot the module re-evaluates and listen() runs again against the same -// native server from the hot map, re-invoking Server__setOnConnection. The JS +// native server from the hot map, setting its onConnection callback again. The JS // onServerConnection callback dedupes on socketHandle.duplex, so this asserts // the user-visible contract (exactly one 'connection' per accept) holds across // hot reloads, not the native filter-vector size. @@ -1701,29 +1743,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 +2368,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 +2557,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 = /