Skip to content

perf(fetch): avoid redundant request state in the Request constructor - #5901

Closed
zirkelc wants to merge 2 commits into
nodejs:mainfrom
zirkelc:perf-request-constructor
Closed

zirkelc wants to merge 2 commits into
nodejs:mainfrom
zirkelc:perf-request-constructor

Conversation

@zirkelc

@zirkelc zirkelc commented Sep 25, 2026 •

Copy link
Copy Markdown

Context: performance campaign. This is one of 4 PRs from a systematic performance campaign on the per-request JavaScript work in lib/web (headers, Request/Response, WebIDL, cookies, WebSocket frames), run as a research loop: 12 isolated experiments, 6 kept, 6 discarded. Scope is CPU work in one process: no case opens a socket, so nothing here says anything about network, TLS or llhttp time. Every candidate change was

  • benchmarked with an A/B harness that loads two git revisions of index.js + lib/ into one process and times 12 workloads in strict alternation: A and B run back to back inside each iteration, and the delta is the median of their per-iteration ratios, so both sides sample the same machine state. Module instances carry a load-order bias of a few percent, so the harness runs both load orders in separate child processes and combines them with a geometric mean.
  • measured on 12 deterministic workloads mirroring benchmarks/ (headers iteration, request creation, parse-headers, cookies, WebSocket frames, core request instantiation, plus a whole fetch through an explicit MockAgent passed as dispatcher). The existing mitata benchmarks load one revision per process, so an A/B with them compares two standalone runs, and run-to-run drift is larger than most effects here. The workloads call the public API, not isolated snippets. The untouched benchmarks/ suite served as an external cross-check.
  • gated by a calibrated noise floor (1.5% suite total, 1.8% geometric mean, measured with identical code on both sides), and a per-case floor for changes aimed at one path; every keep required a second confirming run.
  • verified behaviour-preserving by a characterisation guard (hashes of header entries, Request/Response fields, parsed headers, cookies, WebSocket frame round trips, core request fields, and the exact TypeError messages), a differential run of both revisions in separate processes over 30 000 generated header records and cookie strings plus hand-picked edge cases, and the gates test:unit, test:fetch (incl. webidl, busboy), test:cookies, test:websocket and lint.

The numbers below are fresh runs of this branch alone against 328ab843. "Full" = whole suite, 25 paired iterations; "focused" = the same harness limited to the targeted cases, 200 paired iterations; "standalone" = one revision per process, 8 alternating process pairs, minimum of 100 runs per process, next to an identical-code control run the same way. Headline numbers are standalone, because that is what a maintainer reproduces; the paired runs decide experiments and are shown for completeness. An identical-source control measured -1.35% total. Negative = faster.

This relates to...

Per-request cost of new Request() and of fetch(), which constructs one per call. Follows #5701 (skip empty RequestInit work), which touched the same constructor.

Rationale

Two steps in the constructor build state that is thrown away at once.

String input builds the inner request twice. For new Request(string), step 4 creates a full request (makeRequest: a ~40-field object plus a HeadersList and its Map), and step 12 immediately copies it into a new one, copying the header list again. Nothing can observe the first request, and every field step 12 reads from it is a default. makeRequest already fills every missing field with its default (init.x ?? default), so step 4 now passes only { urlList: [parsedURL] } into step 12. One line.

A Request input with an init but no init.headers round-trips the header list. For new Request(request, { method: 'PUT' }) (retries, middleware), step 32 copies the header list, empties it and appends the copy back, which leaves it unchanged. The steps now run only when init.headers exists. The branch that handled a HeadersList in step 32.4 only ever received that copy, because the HeadersInit converter turns a Headers object into a list of its entries, so it is removed (-6 lines).

Changes

  • lib/web/fetch/request.js: the two changes above.
  • test/fetch/request.js: two tests that pin what the second change relies on: a derived request keeps a copy of the headers including multiple set-cookie values and stays independent of its source; a Headers object in init.headers keeps all of its entries. Both pass on main too.

Features

N/A

Bug Fixes

N/A

Breaking Changes and Deprecations

N/A

Verification (this branch vs 328ab843)

Headline numbers are standalone: one revision per process, 8 alternating process pairs, minimum of 100 runs per process, with an identical-code control (base against base) run the same way. That is what you will measure when you build each side separately.

case standalone median (8 pairs) pair range identical-code control speed-up
request-url: new Request(url) -12.6% and -17.7% (two runs) -24.3% .. -2.5% and -21.3% .. -7.6% -6.5% .. +7.2% and -3.2% .. +3.4% 1.14x to 1.22x
request-clone: clone() + new Request(req, { method }) not resolvable standalone -19.2% .. +18.4% see below
request-init: POST with headers and JSON body not resolvable standalone -33.8% .. +32.2% across three controls see below

request-clone and request-init vary more between processes of the same revision than the effect: their identical-code controls span about ±19% and ±33%, and two standalone runs of request-clone (-14.2%, -8.2%) both fall inside its control. So a standalone run cannot resolve them. The paired harness can: focused runs (the suite limited to these cases, 200 paired iterations) measured request-clone -15.0% / -16.4% and request-init -8.5% / -9.8%, against focused identical-code controls within ±4.2%. Those numbers come from two revisions in one process; they are marked as such here and not used as headlines.

For completeness, the paired runs this branch was verified with (they decide experiments, they do not set the claim):

case full run 1 full run 2 focused 1 focused 2
request-url -20.73% -19.65% -24.39% -25.37%
request-clone -11.17% -12.39% -15.01% -16.40%
request-init -6.54% -4.99% -8.46% -9.77%
suite TOTAL -3.07% -2.24%
suite GEOMEAN -3.50% -3.10%

parse-headers showed +3.72% in full run 2 and -0.67% in run 1. The change does not touch that code, and standalone it is equal (4.22/4.29/4.19 ms base, 4.23/4.23/4.21 ms branch): an in-process artefact of loading two revisions into one process.

External cross-check: benchmarks/fetch/request-creation.mjs (mitata, one revision per process, three fresh processes each) measured new Request(input) at 286-292 ns on main and 229-259 ns on this branch (-11..-21%), consistent with the standalone runs above.

Retained memory per instance is unchanged (request-url 1496 B, request-init 4211 B): the removed objects were garbage.

Observable surface

  • None intended. The guard (all Request fields for string, init and Request inputs) and a differential run over derived requests (header order, original name case, multiple set-cookie, independence after appends on either side, no-cors mode, a Headers object as init.headers) are identical.
  • A derived request's header list now keeps the source's cached sorted view (sortedMap) until either list changes, instead of dropping it in step 32.3. That was already the case for new Request(request) without an init.

Invariants this relies on

  • makeRequest defaults every field it is not given (init.x ?? default, and a fresh HeadersList when headersList is missing). A new request field without a default would read undefined for string input.
  • The HeadersInit converter turns a Headers object into an entry list (the existing workaround from fetch: don't re-lowercase HeadersList #3159). If a Headers object ever reached fill(), it would be handled as a record. The new test for Headers in init.headers fails in that case.
  • The cached sorted view of a header list is replaced, never mutated in place.

Open questions for maintainers (not in this PR)

  • Many requests that follow one signal cost O(n²): each adds an abort listener, and Node's EventTarget checks for duplicates by walking the list. 4 000 / 16 000 / 64 000 new Request(url, { signal }) on one live signal took 32 ms / 559 ms / 13.3 s (step ratios 17x and 24x instead of 4x). A dependent signal (AbortSignal.any, as the current DOM spec's "create a dependent abort signal" does and as the TODO at step 28 suggests) would make it O(1), but a dependent signal aborts after the source's own listeners, which changes the order relative to user listeners added later.
  • Symbol values in init.headers produce TypeError: undefined: undefined.headers["a"] is a symbol, ..., because the RequestInit/ResponseInit converters run without a prefix and argument name.

WPT (npm run test:wpt: /fetch, /mimesniff, /xhr, /websockets, /eventsource) was run locally on 328ab843 and on this branch: per test and per case, pass/fail is identical across all 1 282 test files.

Note on CI: Test with Node.js 22 compiled --without-intl already fails on main at 328ab843, before this change.

Reproducing the numbers

The harness (A/B runner, guard, differential, focused and standalone runners) lives in perf/ on the campaign branch: zirkelc/undici@afde9423/perf (the plan and the experiment log with all discarded experiments are in the same directory). It needs Node 24 (it runs .mts directly) and no install beyond npm ci.

git fetch https://github.com/zirkelc/undici.git afde94231e5e3ac6ee5a874e20f2b8b1eae800df
git checkout afde94231e5e3ac6ee5a874e20f2b8b1eae800df -- perf && git reset -q -- perf   # harness as untracked files
git fetch origin pull/5901/head:pr-5901
node perf/ab.mts 328ab843 328ab843            # noise control, ~20 s
node perf/ab.mts 328ab843 pr-5901              # full suite
PERF_ONLY=request-url,request-clone,request-init node perf/ab.mts 328ab843 pr-5901 --iters 200   # focused
# standalone: one revision per process, alternating, 8 pairs
for i in 1 2 3 4 5 6 7 8; do node --expose-gc perf/solo.mts 328ab843 request-url --iters 100; node --expose-gc perf/solo.mts pr-5901 request-url --iters 100; done

A row marked ? has an interquartile band that crosses zero in that run; judge by two runs that agree, and by the standalone numbers.

perf/cases.mts, the workloads these numbers come from
/**
 * Cases for the undici campaign.
 *
 * Scope: the per-request JavaScript work that every `fetch` pays on top of the network, in one
 * process. No case opens a socket: the network is replaced by an explicit `MockAgent` passed as
 * `dispatcher`, never through the global dispatcher (that lives on `globalThis` and would be shared
 * by both revisions in the process).
 *
 * Workloads mirror `benchmarks/` (headers iteration, request creation, parse-headers, cookies,
 * websocket frames) but call the public API, so inlining and hidden-class effects of the composed
 * path are part of the measurement. All data is seeded or literal.
 */
import { type PerfCase, rng } from "./harness.mts";

const ORIGIN = "https://example.com";

/** Realistic response headers, the same sets as benchmarks/core/parse-headers.mjs. */
const HEADER_SETS: Array<Record<string, string>> = [
  {
    "Content-Type": "application/json",
    Date: "Wed, 01 Nov 2023 00:00:00 GMT",
    "Powered-By": "NodeJS",
    "Content-Encoding": "gzip",
    "Set-Cookie": "__Secure-ID=123; Secure; Domain=example.com",
    "Content-Length": "150",
    Vary: "Accept-Encoding, Accept, X-Requested-With",
  },
  { "Content-Type": "text/html; charset=UTF-8", "Content-Length": "1234", Date: "Wed, 06 Dec 2023 12:47:57 GMT", Server: "Bing" },
  {
    "Content-Type": "image/jpeg",
    "Content-Length": "56789",
    Date: "Wed, 06 Dec 2023 12:48:12 GMT",
    Server: "Bing",
    ETag: '"a1b2c3d4e5f6g7h8i9j0"',
  },
  {
    Cookie: "session_id=1234567890abcdef",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
    Host: "www.bing.com",
    Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "en-US,en;q=0.5",
    "Accept-Encoding": "gzip, deflate, br",
  },
  {
    Location: "https://www.bing.com/search?q=bing",
    Status: "302 Found",
    Date: "Wed, 06 Dec 2023 12:48:27 GMT",
    Server: "Bing",
    "Content-Type": "text/html; charset=UTF-8",
    "Content-Length": "0",
  },
  {
    "Content-Type": "application/json; charset=UTF-8",
    "Content-Length": "2345",
    Date: "Wed, 06 Dec 2023 12:48:42 GMT",
    Server: "Bing",
    Status: "200 OK",
    "Cache-Control": "no-cache, no-store, must-revalidate",
  },
];

const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
function word(rand: () => number, length: number): string {
  let out = "";
  for (let i = 0; i < length; i++) out += CHARS[Math.floor(rand() * CHARS.length)];
  return out;
}

/** Captures the observable parts of a thrown value, including the exact message (WPT asserts it). */
function attempt(fn: () => unknown): unknown {
  try {
    const value = fn();
    return { ok: true, value: value === undefined ? null : String(value) };
  } catch (error: any) {
    return { ok: false, name: error?.name, message: error?.message, code: error?.code ?? null };
  }
}

function headerEntries(headers: any): Array<[string, string]> {
  return [...headers];
}

function requestFields(r: any): unknown {
  return {
    method: r.method,
    url: r.url,
    headers: headerEntries(r.headers),
    destination: r.destination,
    referrer: r.referrer,
    referrerPolicy: r.referrerPolicy,
    mode: r.mode,
    credentials: r.credentials,
    cache: r.cache,
    redirect: r.redirect,
    integrity: r.integrity,
    keepalive: r.keepalive,
    isReloadNavigation: r.isReloadNavigation,
    isHistoryNavigation: r.isHistoryNavigation,
    duplex: r.duplex,
    bodyUsed: r.bodyUsed,
    hasBody: r.body !== null,
    aborted: r.signal.aborted,
  };
}

function responseFields(r: any): unknown {
  return {
    type: r.type,
    url: r.url,
    redirected: r.redirected,
    status: r.status,
    ok: r.ok,
    statusText: r.statusText,
    headers: headerEntries(r.headers),
    bodyUsed: r.bodyUsed,
    hasBody: r.body !== null,
  };
}

/**
 * `PERF_ONLY=name[,name]` limits every harness script to those cases, so a per-case keep can be
 * confirmed with many more paired iterations in the time one full run takes.
 */
export function buildCases(lib: any): Array<PerfCase> {
  const only = process.env.PERF_ONLY?.split(",");
  const cases = allCases(lib);
  return only ? cases.filter((c) => only.includes(c.name)) : cases;
}

function allCases(lib: any): Array<PerfCase> {
  const { Headers, Request, Response, MockAgent, fetch, getCookies, getSetCookies, setCookie, coreUtil, wsFrame, coreRequest } = lib;
  const cases: Array<PerfCase> = [];

  /** Headers from a record, then the reads a request pipeline does. */
  {
    const build = (init: Record<string, string>) => {
      const h = new Headers(init);
      h.get("content-type");
      h.get("content-length");
      h.has("set-cookie");
      return h;
    };
    cases.push({
      name: "headers-record",
      run: () => {
        for (let i = 0; i < 3_000; i++) build(HEADER_SETS[i % HEADER_SETS.length]);
      },
      collect: () => HEADER_SETS.map((s) => headerEntries(build(s))),
      alloc: () => new Headers(HEADER_SETS[0]),
    });
  }

  /** append() with mixed-case names, then sorted iteration (mirrors benchmarks/fetch/headers.mjs). */
  {
    const rand = rng(7);
    const sets = [4, 8, 16, 32, 64].map((n) =>
      Array.from({ length: n }, (_, i) => [i % 3 === 0 ? word(rand, 12).toUpperCase() : word(rand, 12), word(rand, 20)] as [string, string])
    );
    const build = (pairs: Array<[string, string]>) => {
      const h = new Headers();
      for (const [k, v] of pairs) h.append(k, v);
      h.append("Set-Cookie", "a=1");
      h.append("set-cookie", "b=2");
      return h;
    };
    cases.push({
      name: "headers-append-iterate",
      run: () => {
        for (let i = 0; i < 100; i++) {
          for (const pairs of sets) {
            const h = build(pairs);
            for (const _ of h);
            h.getSetCookie();
          }
        }
      },
      collect: () => sets.map((p) => {
        const h = build(p);
        return [headerEntries(h), h.getSetCookie()];
      }),
    });
  }

  /** Failure paths of the header model: webidl and validation errors with exact messages. */
  {
    const bad: Array<() => unknown> = [
      () => new Headers([["a"]] as any),
      () => new Headers([["a", "b", "c"]] as any),
      () => new Headers({ "bad name": "x" }),
      () => new Headers({ ok: "bad\nvalue" }),
      () => new Headers(42 as any),
      () => new Headers().append("", "x"),
      () => new Headers().get("in valid"),
    ];
    cases.push({
      name: "headers-invalid",
      run: () => {
        for (let i = 0; i < 1_400; i++) attempt(bad[i % bad.length]);
      },
      collect: () => bad.map(attempt),
    });
  }

  /** new Request(url): the minimal constructor path (mirrors benchmarks/fetch/request-creation.mjs). */
  {
    const urls = Array.from({ length: 16 }, (_, i) => `${ORIGIN}/api/v1/items/${i}?page=${i}&q=search`);
    cases.push({
      name: "request-url",
      run: () => {
        for (let i = 0; i < 4_000; i++) new Request(urls[i & 15]);
      },
      collect: () => urls.slice(0, 4).map((u) => requestFields(new Request(u))),
      alloc: () => new Request(urls[0]),
    });
  }

  /** new Request(url, init) with method, headers and a JSON body: the typical POST. */
  {
    const body = JSON.stringify({ id: 1, name: "item", tags: ["a", "b", "c"] });
    const init = (i: number) => ({
      method: "POST",
      headers: { "content-type": "application/json", authorization: `Bearer token-${i & 7}`, "x-request-id": `req-${i & 7}` },
      body,
    });
    cases.push({
      name: "request-init",
      run: () => {
        for (let i = 0; i < 1_200; i++) new Request(`${ORIGIN}/api/items`, init(i));
      },
      collect: () => [0, 1].map((i) => requestFields(new Request(`${ORIGIN}/api/items`, init(i)))),
      alloc: () => new Request(`${ORIGIN}/api/items`, init(0)),
    });
  }

  /**
   * Request clone and Request-from-Request, as middleware and retries do. Each derived request adds
   * an abort listener to its source's signal, so the base is rebuilt every 10 derivations: the
   * listener count per signal stays realistic and the cost of one run does not grow with the number
   * of runs before it. Many requests on one shared signal is a scaling shape (see buildScan).
   */
  {
    const init = { headers: HEADER_SETS[3] };
    const url = `${ORIGIN}/api/items?x=1`;
    cases.push({
      name: "request-clone",
      run: () => {
        for (let i = 0; i < 90; i++) {
          const base = new Request(url, init);
          for (let k = 0; k < 10; k++) {
            base.clone();
            new Request(base, { method: "PUT" });
          }
        }
      },
      collect: () => {
        const base = new Request(url, init);
        return [requestFields(base.clone()), requestFields(new Request(base, { method: "PUT" }))];
      },
    });
  }

  /** Response construction: new Response(body, init) and Response.json(). */
  {
    const payload = { id: 1, items: [1, 2, 3], ok: true };
    cases.push({
      name: "response-new",
      run: () => {
        for (let i = 0; i < 800; i++) {
          new Response("hello world", { status: 201, statusText: "Created", headers: HEADER_SETS[i % HEADER_SETS.length] });
          Response.json(payload);
        }
      },
      collect: () => [
        responseFields(new Response("hello world", { status: 201, statusText: "Created", headers: HEADER_SETS[0] })),
        responseFields(Response.json(payload)),
        responseFields(Response.error()),
        responseFields(Response.redirect(`${ORIGIN}/x`, 302)),
        attempt(() => new Response(null, { status: 99 })),
        attempt(() => new Response("x", { status: 204 })),
        attempt(() => new Response(null, { statusText: "bad\n" })),
      ],
      alloc: () => new Response("hello world", { status: 200, headers: HEADER_SETS[1] }),
    });
  }

  /** Raw header parsing from the wire (mirrors benchmarks/core/parse-headers.mjs, both casings). */
  {
    const regular = HEADER_SETS.map((x) => Object.entries(x).flat().map((c) => Buffer.from(c)));
    const upper = HEADER_SETS.map((x) => Object.entries(x).flat().map((c) => Buffer.from(c.toUpperCase())));
    const all = [...regular, ...upper];
    cases.push({
      name: "parse-headers",
      run: () => {
        for (let r = 0; r < 500; r++) {
          for (let i = 0; i < all.length; i++) {
            coreUtil.parseHeaders(all[i]);
            coreUtil.parseRawHeaders(all[i]);
          }
        }
      },
      collect: () => all.map((h) => [coreUtil.parseHeaders(h), coreUtil.parseRawHeaders(h)]),
    });
  }

  /** Cookie parsing and serialisation on Headers. */
  {
    const rand = rng(11);
    const cookieHeader = Array.from({ length: 12 }, () => `${word(rand, 8)}=${word(rand, 24)}`).join("; ");
    const setCookies = [
      "id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly",
      "__Host-session=xyz; Path=/; Secure; SameSite=Strict",
      "lang=en-US; Max-Age=3600; Domain=example.com; Path=/docs",
      "theme=dark; SameSite=Lax",
      "bad",
    ];
    const cookie = { name: "token", value: "abc123", path: "/", secure: true, httpOnly: true, sameSite: "Lax", maxAge: 3600, domain: "example.com" };
    const reqHeaders = () => new Headers({ cookie: cookieHeader });
    const resHeaders = () => {
      const h = new Headers();
      for (const s of setCookies) h.append("set-cookie", s);
      return h;
    };
    cases.push({
      name: "cookies",
      run: () => {
        for (let i = 0; i < 500; i++) {
          getCookies(reqHeaders());
          getSetCookies(resHeaders());
          setCookie(new Headers(), cookie);
        }
      },
      collect: () => {
        const h = new Headers();
        setCookie(h, cookie);
        return [getCookies(reqHeaders()), getSetCookies(resHeaders()), headerEntries(h)];
      },
    });
  }

  /** WebSocket frame encoding for text payloads of three length classes. */
  {
    const rand = rng(5);
    const payloads = [16, 200, 70_000].map((n) => Buffer.from(word(rand, n)));
    /**
     * The mask is random, so the sample keeps the deterministic parts: the header bytes without the
     * mask, and whether unmasking the body with the frame's own mask gives back the payload.
     */
    const unmask = (head: Buffer, body: Buffer, offset: number) => {
      const key = head.subarray(offset - 4, offset);
      return Buffer.from(body.map((b, i) => b ^ key[i & 3]));
    };
    const offsetOf = (head: Buffer) => ((head[1] & 0x7f) === 127 ? 14 : (head[1] & 0x7f) === 126 ? 8 : 6);
    const sampleFrame = (p: Buffer) => {
      const frame: Buffer = new wsFrame.WebsocketFrameSend(p).createFrame(0x1);
      const offset = offsetOf(frame);
      return [frame.length, [...frame.subarray(0, offset - 4)], unmask(frame, frame.subarray(offset), offset).equals(p)];
    };
    const sampleFast = (p: Buffer) => {
      const [head, body] = wsFrame.WebsocketFrameSend.createFastTextFrame(Buffer.from(p));
      const offset = offsetOf(head);
      return [head.length, body.length, [...head.subarray(0, offset - 4)], unmask(head, body, offset).equals(p)];
    };
    cases.push({
      name: "ws-frame",
      run: () => {
        for (let i = 0; i < 8_000; i++) {
          const p = payloads[i % 2];
          new wsFrame.WebsocketFrameSend(p).createFrame(0x1);
          /** Masks its input in place; the XOR work is the same whatever the bytes are. */
          wsFrame.WebsocketFrameSend.createFastTextFrame(p);
        }
        for (let i = 0; i < 10; i++) new wsFrame.WebsocketFrameSend(payloads[2]).createFrame(0x2);
      },
      collect: () => payloads.map((p) => [sampleFrame(p), sampleFast(p)]),
    });
  }

  /**
   * Dispatcher-level request construction (lib/core/request.js), built once per dispatch by Client,
   * Pool and Agent: header record, header array and a string body (mirrors
   * benchmarks/core/request-instantiation.mjs, with realistic headers). Added in experiment 8; its
   * guard expectation was recorded against the campaign base revision.
   */
  {
    const CoreRequest = coreRequest.default ?? coreRequest;
    const handler = new lib.DecoratorHandler({});
    const origin = "https://example.com";
    const record = { ...HEADER_SETS[3], "content-type": "application/json", "x-request-id": "abc" };
    const array = Object.entries(HEADER_SETS[5]).flat();
    const body = JSON.stringify({ id: 1, name: "item" });
    const make = (i: number) =>
      i % 3 === 0
        ? new CoreRequest(origin, { path: "/api/items?page=1", method: "GET", headers: record, body: null }, handler)
        : i % 3 === 1
          ? new CoreRequest(origin, { path: "/api/items", method: "POST", headers: array, body }, handler)
          : new CoreRequest(origin, { path: "/api/items", method: "PUT", headers: record, query: { a: 1, b: "x y" }, body }, handler);
    const fields = (r: any) => [r.method, r.path, r.origin, r.protocol, r.host, r.contentLength, r.contentType, r.headers, r.idempotent, r.blocking, r.servername, r.upgrade, r.reset, r.body === null ? null : String(r.body)];
    const bad: Array<() => unknown> = [
      () => new CoreRequest(origin, { path: "api", method: "GET" }, handler),
      () => new CoreRequest(origin, { path: "/a b", method: "GET" }, handler),
      () => new CoreRequest(origin, { path: "/", method: "G E T" }, handler),
      () => new CoreRequest(origin, { path: "/", method: "GET", headers: { "bad key": "x" } }, handler),
      () => new CoreRequest(origin, { path: "/", method: "GET", headers: { a: "x\n" } }, handler),
      () => new CoreRequest(origin, { path: "/", method: "GET", headers: ["a"] }, handler),
      () => new CoreRequest(origin, { path: "/", method: "GET", headers: { "content-length": "x" } }, handler),
      () => new CoreRequest(origin, { path: "/", method: "GET", headers: { "transfer-encoding": "chunked" } }, handler),
    ];
    cases.push({
      name: "core-request",
      run: () => {
        for (let i = 0; i < 3_000; i++) make(i);
      },
      collect: () => [[0, 1, 2].map((i) => fields(make(i))), bad.map(attempt)],
      alloc: () => make(0),
    });
  }

  /**
   * Whole fetch through an explicit MockAgent: Request construction, dispatch, mock reply, Response
   * and body consumption. Async, so each call runs a fixed batch of 200 requests; the promise and
   * microtask overhead is then the same constant on both sides.
   */
  {
    let agent: any;
    const json = JSON.stringify({ id: 1, name: "item", tags: ["a", "b", "c"] });
    const one = async (i: number) => {
      const res = await fetch(`${ORIGIN}/api/items/${i & 3}`, {
        dispatcher: agent,
        headers: { accept: "application/json", "x-request-id": `r${i & 3}` },
      });
      return [res, await res.text()] as const;
    };
    const post = async () => {
      const res = await fetch(`${ORIGIN}/api/items`, { dispatcher: agent, method: "POST", body: json, headers: { "content-type": "application/json" } });
      return [res, await res.json()] as const;
    };
    cases.push({
      name: "fetch-mock",
      setup: () => {
        agent = new MockAgent();
        agent.disableNetConnect();
        const pool = agent.get(ORIGIN);
        for (let k = 0; k < 4; k++) {
          pool.intercept({ path: `/api/items/${k}`, method: "GET" }).reply(200, json, { headers: { "content-type": "application/json", etag: `"v${k}"` } }).persist();
        }
        pool.intercept({ path: "/api/items", method: "POST" }).reply(201, json, { headers: { "content-type": "application/json" } }).persist();
      },
      teardown: () => {
        agent?.close();
        agent = undefined;
      },
      run: async () => {
        const batch: Array<Promise<unknown>> = [];
        for (let i = 0; i < 160; i++) batch.push(one(i));
        for (let i = 0; i < 40; i++) batch.push(post());
        await Promise.all(batch);
      },
      collect: async () => {
        const [r1, t1] = await one(1);
        const [r2, t2] = await post();
        return [responseFields(r1), t1, responseFields(r2), t2];
      },
    });
  }

  return cases;
}

/** Scaling shapes: each takes a size n and must cost about 4x at 4n if the path is linear. */
export function buildScan(lib: any): Array<{ name: string; input: (n: number) => unknown; run: (input: any) => void; n?: number }> {
  const { Headers, Request, getCookies, coreUtil } = lib;
  const rand = rng(3);
  return [
    {
      name: "requests-shared-signal",
      n: 250,
      input: (n) => n,
      run: (n: number) => {
        const ac = new AbortController();
        for (let i = 0; i < n; i++) new Request(`${ORIGIN}/x`, { signal: ac.signal });
      },
    },
    {
      name: "request-from-request",
      n: 250,
      input: (n) => n,
      run: (n: number) => {
        const base = new Request(`${ORIGIN}/x`);
        for (let i = 0; i < n; i++) new Request(base);
      },
    },
    {
      name: "headers-many-names",
      n: 250,
      input: (n) => Array.from({ length: n }, (_, i) => [`x-${word(rand, 6)}-${i}`, "v"]),
      run: (pairs: Array<[string, string]>) => {
        const h = new Headers();
        for (const [k, v] of pairs) h.append(k, v);
        for (const _ of h);
      },
    },
    {
      name: "headers-same-name",
      n: 250,
      input: (n) => n,
      run: (n: number) => {
        const h = new Headers();
        for (let i = 0; i < n; i++) h.append("accept", `type/${i}`);
        h.get("accept");
      },
    },
    {
      name: "set-cookie-many",
      n: 250,
      input: (n) => n,
      run: (n: number) => {
        const h = new Headers();
        for (let i = 0; i < n; i++) h.append("set-cookie", `c${i}=v${i}; Path=/`);
        for (const _ of h);
        h.getSetCookie();
      },
    },
    {
      name: "cookie-header-long",
      n: 250,
      input: (n) => new Headers({ cookie: Array.from({ length: n }, (_, i) => `k${i}=${word(rand, 16)}`).join("; ") }),
      run: (h: any) => {
        getCookies(h);
      },
    },
    {
      name: "raw-headers-many",
      n: 250,
      input: (n) => Array.from({ length: n }, (_, i) => [Buffer.from(`X-Header-${i}`), Buffer.from(word(rand, 20))]).flat(),
      run: (raw: Array<Buffer>) => {
        coreUtil.parseHeaders(raw);
        coreUtil.parseRawHeaders(raw);
      },
    },
  ];
}

/**
 * Differential inputs, for changes whose risky inputs the guard does not reach. Each input is a
 * string: either a named scenario from SCENARIOS, or a JSON record of header name to value.
 */
export function buildDifferential(lib: any) {
  const { Headers, Request, Response, getCookies, getSetCookies, setCookie } = lib;
  const PIECES = ["a", "Content-Type", "x-y", " ", "\t", "\n", "\r", "\0", "ā", "é", "=", ";", ",", '"', "set-cookie", "cookie", "__proto__", "", "é", "💩", "0", "Ā"];
  const SCENARIOS: Record<string, () => unknown> = {
    "symbol-key": () => new Headers({ [Symbol("s")]: "x" } as any),
    "symbol-value": () => new Headers({ a: Symbol("v") } as any),
    "symbol-key-after-string": () => new Headers({ a: "1", [Symbol("s")]: "x" } as any),
    "wide-key": () => new Headers({ "ā": "x" }),
    "wide-value": () => new Headers({ a: "ā" }),
    "number-key": () => new Headers({ 1: "a", b: "c", 0: "d" } as any),
    "getter-order": () => {
      const log: Array<string> = [];
      const o = {};
      for (const k of ["b", "a", "ā", "c"]) Object.defineProperty(o, k, { enumerable: true, get: () => (log.push(k), "v") });
      try {
        new Headers(o);
      } catch (e: any) {
        log.push(`${e.name}: ${e.message}`);
      }
      return log;
    },
    "non-enumerable": () => {
      const o = { a: "1" };
      Object.defineProperty(o, "hidden", { enumerable: false, value: "2" });
      return new Headers(o);
    },
    "proxy-record": () => new Headers(new Proxy({ a: "1", b: "2" }, {})),
    "object-value": () => new Headers({ a: { toString: () => "x" } } as any),
    "request-record": () => new Request(`${ORIGIN}/`, { headers: { a: "1", B: "2" } }),
    "response-record": () => new Response(null, { headers: { a: "1", B: "2" } }),
    "request-symbol-header": () => new Request(`${ORIGIN}/`, { headers: { a: Symbol("q") } as any }),
    "response-symbol-header": () => new Response(null, { headers: { [Symbol("k")]: "v" } as any }),
    "derived-request-aliasing": () => {
      const base = new Request(`${ORIGIN}/`, { headers: [["Set-Cookie", "a=1"], ["x-b", "2"], ["set-cookie", "c=3"], ["X-A", "1"]] });
      [...base.headers];
      const derived = new Request(base, { method: "PUT" });
      const before = [[...derived.headers], derived.headers.getSetCookie()];
      derived.headers.append("set-cookie", "d=4");
      derived.headers.append("x-0", "0");
      base.headers.append("x-z", "z");
      return [before, [...derived.headers], derived.headers.getSetCookie(), [...base.headers], base.headers.getSetCookie(), derived.method];
    },
    "derived-request-no-cors": () => {
      const base = new Request(`${ORIGIN}/`, { mode: "no-cors", headers: { a: "1" } });
      const derived = new Request(base, { cache: "no-store" });
      return [[...derived.headers], derived.mode, derived.cache];
    },
    "derived-request-new-headers": () => {
      const base = new Request(`${ORIGIN}/`, { headers: { a: "1", "set-cookie": "x=1" } });
      const derived = new Request(base, { headers: base.headers });
      const replaced = new Request(base, { headers: { b: "2" } });
      return [[...derived.headers], derived.headers.getSetCookie(), [...replaced.headers]];
    },
  };
  const describeValue = (value: any): unknown => {
    if (value instanceof Headers) return [...value];
    if (value instanceof Request) return [value.method, value.url, [...value.headers]];
    if (value instanceof Response) return [value.status, [...value.headers]];
    return value;
  };
  return {
    fixed: [
      ...Object.keys(SCENARIOS),
      "cookie:a=b; c=d",
      "cookie:a",
      "cookie:=x",
      "cookie:a==b=c; ;x",
      "cookie: a = b ;c=",
      "cookie:__proto__=1; constructor=2",
    ],
    random: (rand: () => number) => {
      const pick = () => {
        let s = "";
        const n = Math.floor(rand() * 4);
        for (let i = 0; i < n; i++) s += PIECES[Math.floor(rand() * PIECES.length)];
        return s;
      };
      if (rand() < 0.3) return `cookie:${Array.from({ length: 1 + Math.floor(rand() * 4) }, () => `${pick()}=${pick()}`).join(rand() < 0.5 ? "; " : ";")}`;
      const rec: Record<string, string> = {};
      const n = Math.floor(rand() * 5);
      for (let i = 0; i < n; i++) rec[pick() || "k"] = pick();
      return JSON.stringify(rec);
    },
    describe: (input: string) => {
      const run = () => {
        if (input in SCENARIOS) return describeValue(SCENARIOS[input]());
        if (input.startsWith("cookie:")) {
          const h = new Headers();
          h.append("cookie", input.slice(7).replace(/[\0\r\n]/g, ""));
          const out = new Headers();
          const parsed = getCookies(h);
          for (const [name, value] of Object.entries(parsed)) {
            try {
              setCookie(out, { name, value: value as string });
            } catch (e: any) {
              return [Object.entries(parsed), `${e.name}: ${e.message}`];
            }
          }
          return [Object.entries(parsed), Object.getPrototypeOf(parsed) === null, getSetCookies(out)];
        }
        return describeValue(new Headers(JSON.parse(input)));
      };
      try {
        return { ok: run() };
      } catch (e: any) {
        return { error: `${e.name}: ${e.message}` };
      }
    },
  };
}

Status


Companion PRs from the same campaign: #5902 (perf(websocket): mask frames without a mask array, four bytes per step), #5903 (perf(webidl): check ByteString code units with a native scan), #5904 (perf(cookies): split each cookie pair once in getCookies)

@tsctx

tsctx commented Sep 25, 2026

Copy link
Copy Markdown
Member

This process cannot be bypassed, and the proposed changes do not allow setting default values properly according to the specifications.

@tsctx tsctx closed this Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants