Files
scriptc/tests/fixtures/fetch/servers.mjs
T
Chris Tate d67f378a50 Release the web view of an abandoned fetch body while its transfer runs
A Response body's stream gets a web ReadableStream view when a member the
fetch stream lacks is read (for example `body.tee`). The view's source holds
the stream, and the stream released the view only when that source was its
last holder. While the transfer runs it holds the stream too, so a dropped
body with a view kept a reference count of 2: the unobserved-response
discard never ran, the paused socket was never resumed, and a compressed
response dropped before HTTP EOF kept the process alive.

The stream now releases the view when only the view's source and the live
transfer hold it, and then takes the unobserved-response discard, as a
body without a view does.

The static-abandon-view fixture reads `body.tee` and `body.locked` of a
4 MiB incompressible gzip response from the new /gzip-incompressible route
and drops it. It hung until the test's timeout before this change and now
exits as Node does.
2026-10-29 13:44:32 -05:00

487 lines
21 KiB
JavaScript

/* The fetch fixtures' local HTTP servers — the routes fetch.test.ts always
* ran in-process, extracted so the Linux lane can run the IDENTICAL routes
* inside its container (linux-differential.test.ts). Two entry forms, one
* behavior:
*
* - module: `startFetchServers()` binds the target, refused-port probe, and
* HTTP/HTTPS proxy legs locally and resolves their URLs plus counters
* (fetch.test.ts's in-process use).
* - standalone: `node servers.mjs` starts the same legs and prints
* `BASE <url>` / `REFUSED <url>` / `PROXY <url>` /
* `SECURE_PROXY <url>` on stderr (the PORT protocol's channel — never a
* compared stream), then serves until killed. The proxied-request COUNT
* is queryable over the wire in this form: a plain `GET /__count` to the
* HTTP proxy (a relative-path request no real proxy client sends) answers
* the current count.
*
* Routes: /text /json /post-echo /header-echo /header-empty /headers-source
* /headers-reuse /request-defaults /raw-headers /header-init-echo
* /redirect /redirect-stream-302 /redirect-stream-303 /redirect-credentials
* /redirect-fragment/path /redirect-backslash /redirect-invalid-utf8
* /redirect-same-scheme/dir/start
* /early-hints
* /switching-protocols /invalid-utf8 /slow /drip
* /chunked /backpressure /backpressure-state /gzip /gzip-concat
* /gzip-truncated /gzip-pressure /gzip-incompressible /deflate
* /status-meta /no-content /reset-content /reset-content-large
* /truncated-response /upload-failure /sse,
* 404 for the rest;
* the proxy relays absolute-URI requests and CONNECT tunnels, counting one
* per proxied request either way. */
import { createCipheriv } from "node:crypto";
import { readFileSync } from "node:fs";
import { createServer, request } from "node:http";
import { createServer as createHttpsServer } from "node:https";
import { connect } from "node:net";
import { fileURLToPath } from "node:url";
import { deflateSync, gzipSync } from "node:zlib";
export async function startFetchServers() {
const fragmentRedirects = new Set();
const backpressureStates = new Map();
let gzipPressurePayload;
let gzipIncompressiblePayload;
const server = createServer((req, res) => {
const url = req.url ?? "/";
if (url === "/upload-failure") {
// The client-side body source fails after the request begins. Keep
// the peer quiet so fetch must settle from that upload error, and
// absorb the expected reset when fetch tears the socket down.
req.on("error", () => {});
req.resume();
return;
}
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const body = Buffer.concat(chunks);
if (url.startsWith("/backpressure-state?")) {
const parsed = new URL(url, "http://fixture.invalid");
const key = parsed.searchParams.get("key") ?? "missing";
const state = backpressureStates.get(key);
res.writeHead(200, { "content-type": "text/plain" });
res.end(state?.done ? "unbounded" : state ? "bounded" : "missing");
backpressureStates.delete(key);
} else if (url.startsWith("/backpressure?")) {
const parsed = new URL(url, "http://fixture.invalid");
const key = parsed.searchParams.get("key") ?? "missing";
const state = { closed: false, done: false, sent: 0 };
backpressureStates.set(key, state);
res.writeHead(200, { "content-type": "application/octet-stream" });
res.on("close", () => {
state.closed = true;
});
const chunk = Buffer.alloc(64 * 1024, 0x61);
const total = 32 * 1024 * 1024;
const pump = () => {
while (!state.closed && state.sent < total) {
const size = Math.min(chunk.length, total - state.sent);
state.sent += size;
if (!res.write(size === chunk.length ? chunk : chunk.subarray(0, size))) {
res.once("drain", pump);
return;
}
}
if (!state.closed && state.sent === total) {
state.done = true;
res.end();
}
};
pump();
} else if (url === "/text") {
res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "x-kind": "greeting" });
res.end("héllo wörld 😀");
} else if (url === "/json") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ n: 42, s: "wide é", arr: [1, "two"] }));
} else if (url === "/post-echo") {
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
method: req.method,
contentType: req.headers["content-type"] ?? null,
contentLength: req.headers["content-length"] ?? null,
body: body.toString("utf8"),
}),
);
} else if (url === "/header-echo") {
res.writeHead(200, {
"content-type": "text/plain",
"x-multi": ["a", "b"],
"x-latin": "é",
"set-cookie": ["first=1", "second=2", "flavor=é"],
});
res.end(`one=${req.headers["x-echo-one"]} two=${req.headers["x-echo-two"]}`);
} else if (url === "/header-empty") {
res.writeHead(200, {
"content-type": "text/plain",
"x-empty": ["", "b"],
});
res.end("empty header");
} else if (url === "/headers-source") {
// `connection: close` keeps Node from minting its keep-alive
// response header, which undici correctly refuses as a request
// header when the response Headers object is reused below.
res.writeHead(200, {
connection: "close",
"content-length": "0",
"x-reuse": ["one", "two"],
});
res.end();
} else if (url === "/headers-reuse") {
res.writeHead(200, { "content-type": "text/plain" });
res.end(String(req.headers["x-reuse"] ?? "missing"));
} else if (url === "/request-defaults") {
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
host: req.headers.host ?? null,
accept: req.headers["accept"] ?? null,
acceptLanguage: req.headers["accept-language"] ?? null,
secFetchMode: req.headers["sec-fetch-mode"] ?? null,
userAgent: req.headers["user-agent"] ?? null,
acceptEncoding: req.headers["accept-encoding"] ?? null,
}),
);
} else if (url === "/raw-headers") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(req.rawHeaders));
} else if (url === "/header-init-echo") {
const count = (name) => {
let matches = 0;
for (let i = 0; i < req.rawHeaders.length; i += 2) {
if (req.rawHeaders[i].toLowerCase() === name) matches++;
}
return matches;
};
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
duplicate: req.headers["x-duplicate"] ?? null,
duplicateLines: count("x-duplicate"),
cookie: req.headers.cookie ?? null,
cookieLines: count("cookie"),
}),
);
} else if (url === "/redirect") {
res.writeHead(302, { location: "/text" });
res.end();
} else if (url === "/redirect-stream-302") {
res.writeHead(302, { location: "/post-echo" });
res.end();
} else if (url === "/redirect-stream-303") {
res.writeHead(303, { location: "/post-echo" });
res.end();
} else if (url === "/redirect-credentials") {
res.writeHead(302, {
location: `http://user:pass@${req.headers.host}/text`,
});
res.end();
} else if (url === "/redirect-backslash") {
// WHATWG special URLs treat backslashes as path separators. Two
// leading separators make this an authority-relative reference.
res.writeHead(302, {
location: `\\\\${req.headers.host}\\text`,
});
res.end();
} else if (url === "/redirect-same-scheme/dir/start") {
// In a special URL, a matching scheme without `//` is relative to
// the current URL rather than an absolute URL whose host is "next".
res.writeHead(302, { location: "http:next" });
res.end();
} else if (url === "/redirect-same-scheme/dir/next") {
res.writeHead(200, { "content-type": "text/plain" });
res.end("same-scheme final");
} else if (url === "/redirect-invalid-utf8") {
// node:http serializes this ByteString as a raw E9 octet. Fetch
// UTF-8-decodes that invalid one-byte sequence to U+FFFD before
// resolving the redirect URL.
res.writeHead(302, { location: "/café" });
res.end();
} else if (url.startsWith("/caf")) {
res.writeHead(200, { "content-type": "text/plain" });
res.end(url);
} else if (url === "/redirect-fragment/path") {
const key = String(req.headers["x-redirect-key"] ?? "missing");
if (!fragmentRedirects.has(key)) {
fragmentRedirects.add(key);
res.writeHead(302, { location: "#next" });
res.end();
} else {
res.writeHead(200, { "content-type": "text/plain" });
res.end("fragment final");
}
} else if (url === "/early-hints") {
res.writeEarlyHints({
link: "</style.css>; rel=preload; as=style",
});
res.writeHead(200, { "content-type": "text/plain" });
res.end("final");
} else if (url === "/switching-protocols") {
res.writeHead(101, {
connection: "upgrade",
upgrade: "fixture",
});
res.end();
} else if (url === "/invalid-utf8") {
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
res.end(Buffer.from([0x61, 0xc3, 0x28, 0x62]));
} else if (url === "/slow") {
// Answers after 1500ms: the abort/timeout cases cancel long before.
setTimeout(() => {
res.writeHead(200, { "content-type": "text/plain" });
res.end("slow done");
}, 1500);
} else if (url === "/drip") {
// First chunk immediately, the rest after 3000ms: the mid-stream
// abort case reads the first chunk and cancels during the gap.
res.writeHead(200, { "content-type": "text/plain" });
res.write("first");
setTimeout(() => {
res.end("tail");
}, 3000);
} else if (url === "/chunked") {
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
// split a multibyte sequence ACROSS chunks: € is e2 82 ac
const bytes = Buffer.from("first€second", "utf8");
res.write(bytes.subarray(0, 6)); // "first" + e2
setTimeout(() => {
res.write(bytes.subarray(6, 8)); // 82 ac
setTimeout(() => {
res.end(bytes.subarray(8));
}, 15);
}, 15);
} else if (url === "/gzip") {
// gzip-encoded body, written in two chunks so decompression spans
// arrivals: fetch must deliver the DECODED text on both lanes.
const payload = gzipSync(Buffer.from("compressed héllo 😀 ".repeat(40), "utf8"));
res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "content-encoding": "gzip", "content-length": String(payload.length) });
res.write(payload.subarray(0, Math.floor(payload.length / 2)));
setTimeout(() => {
res.end(payload.subarray(Math.floor(payload.length / 2)));
}, 15);
} else if (url === "/gzip-concat") {
// RFC 1952 permits multiple gzip members in one representation.
const firstMembers = Buffer.concat([
gzipSync(Buffer.from("member one ", "utf8")),
gzipSync(Buffer.from("member two", "utf8")),
]);
const finalMember = gzipSync(Buffer.from(" member three", "utf8"));
const contentLength = firstMembers.length + finalMember.length;
res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "content-encoding": "gzip", "content-length": String(contentLength) });
res.write(firstMembers);
setTimeout(() => {
res.end(finalMember);
}, 15);
} else if (url === "/gzip-truncated") {
// Node fetch accepts a clean HTTP EOF with incomplete gzip framing
// and exposes the decoded prefix; pin that distinct fetch contract.
const complete = gzipSync(Buffer.from("truncated compressed body", "utf8"));
const payload = complete.subarray(0, Math.floor(complete.length / 2));
res.writeHead(200, {
"content-type": "text/plain; charset=utf-8",
"content-encoding": "gzip",
"content-length": String(payload.length),
});
res.end(payload);
} else if (url === "/gzip-pressure") {
// The representation fits in one 64 KiB socket read but expands to
// 64 MiB. An unread body must retain only one decoded stream chunk.
gzipPressurePayload ??= gzipSync(Buffer.alloc(64 * 1024 * 1024, 0x61));
res.writeHead(200, {
"content-type": "application/octet-stream",
"content-encoding": "gzip",
"content-length": String(gzipPressurePayload.length),
});
res.end(gzipPressurePayload);
} else if (url === "/gzip-incompressible") {
// 4 MiB of fixed pseudo-random bytes: the representation spans many
// socket reads, so an unread body pauses the socket before HTTP EOF.
gzipIncompressiblePayload ??= gzipSync(
createCipheriv("aes-128-ctr", Buffer.alloc(16), Buffer.alloc(16)).update(
Buffer.alloc(4 * 1024 * 1024),
),
);
res.writeHead(200, {
"content-type": "application/octet-stream",
"content-encoding": "gzip",
"content-length": String(gzipIncompressiblePayload.length),
});
res.end(gzipIncompressiblePayload);
} else if (url === "/deflate") {
// zlib-wrapped deflate (Node servers' `deflate` spelling).
const payload = deflateSync(Buffer.from("deflated wörld", "utf8"));
res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "content-encoding": "deflate" });
res.end(payload);
} else if (url === "/status-meta") {
res.writeHead(206, "Custom Partial", { "content-type": "text/plain" });
res.end("partial");
} else if (url === "/no-content") {
res.writeHead(204);
res.end();
} else if (url === "/reset-content") {
res.writeHead(205, { "content-length": "4" });
res.end("body");
} else if (url === "/reset-content-large") {
// Fetch exposes a null body for 205 even when a peer violates the
// protocol and sends content. Make the payload larger than the
// native socket's read window so an inaccessible backpressured
// stream would stall the process instead of merely wasting bytes.
const payload = Buffer.alloc(2 * 1024 * 1024, 0x61);
res.writeHead(205, { "content-length": String(payload.length) });
res.end(payload);
} else if (url === "/truncated-response") {
res.writeHead(200, {
"content-type": "text/plain",
"content-length": "100",
});
res.write("partial");
setTimeout(() => res.destroy(), 10);
} else if (url === "/sse") {
res.writeHead(200, { "content-type": "text/event-stream" });
const frames = [
'data: {"type":"delta","text":"hé"}\n\n',
'event: usage\nid: 7\ndata: {"tokens":3}\n\n',
"data: multi\ndata: line 😀\n\n",
"data: [DONE]\n\n",
];
let i = 0;
const tick = () => {
if (i === frames.length) {
res.end();
return;
}
// split each frame mid-way so parsing must span chunk boundaries
const buf = Buffer.from(frames[i], "utf8");
const cut = Math.floor(buf.length / 2);
res.write(buf.subarray(0, cut));
setTimeout(() => {
res.write(buf.subarray(cut));
i++;
setTimeout(tick, 10);
}, 10);
};
tick();
} else {
res.writeHead(404, { "content-type": "text/plain" });
res.end(`not found: ${url}`);
}
});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const addr = server.address();
if (addr === null || typeof addr !== "object") throw new Error("no server address");
const baseUrl = `http://127.0.0.1:${addr.port}`;
// A port that was just bound and released: connecting to it refuses.
const probe = createServer(() => {});
await new Promise((resolve) => probe.listen(0, "127.0.0.1", resolve));
const paddr = probe.address();
if (paddr === null || typeof paddr !== "object") throw new Error("no probe address");
const refusedUrl = `http://127.0.0.1:${paddr.port}`;
await new Promise((resolve) => probe.close(() => resolve()));
// The forward proxy, both wire forms: absolute-URI requests (curl's
// http_proxy shape for http:// targets) relay through http.request, and
// CONNECT tunnels (undici's ProxyAgent shape — it tunnels even plain
// http) splice raw sockets. One count per proxied request either way.
let proxiedRequests = 0;
let tunneledRequests = 0;
const proxyAuthorizations = [];
const proxyRequest = (req, res) => {
// Relative-path requests are never proxy traffic — the standalone
// form's count query rides one; anything else relative is a 404.
if ((req.url ?? "").startsWith("/")) {
if (req.url === "/__count") {
res.writeHead(200, { "content-type": "text/plain" });
res.end(String(proxiedRequests));
} else {
res.writeHead(404);
res.end();
}
return;
}
proxiedRequests++;
proxyAuthorizations.push(req.headers["proxy-authorization"] ?? "");
const target = new URL(req.url ?? "");
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const headers = {};
for (const [k, v] of Object.entries(req.headers)) {
if (typeof v === "string" && k !== "proxy-connection" && k !== "connection") headers[k] = v;
}
const upstream = request(
{ hostname: target.hostname, port: target.port, path: target.pathname + target.search, method: req.method, headers },
(ures) => {
res.writeHead(ures.statusCode ?? 502, ures.headers);
ures.pipe(res);
},
);
upstream.on("error", () => {
res.writeHead(502);
res.end("proxy error");
});
upstream.end(Buffer.concat(chunks));
});
};
const proxyConnect = (req, clientSocket, head) => {
proxiedRequests++;
tunneledRequests++;
proxyAuthorizations.push(req.headers["proxy-authorization"] ?? "");
const [host, portStr] = (req.url ?? "").split(":");
const upstream = connect(Number(portStr ?? "80"), host ?? "127.0.0.1", () => {
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (head.length > 0) upstream.write(head);
upstream.pipe(clientSocket);
clientSocket.pipe(upstream);
});
upstream.on("error", () => clientSocket.destroy());
clientSocket.on("error", () => upstream.destroy());
};
const proxy = createServer(proxyRequest);
proxy.on("connect", proxyConnect);
await new Promise((resolve) => proxy.listen(0, "127.0.0.1", resolve));
const pxaddr = proxy.address();
if (pxaddr === null || typeof pxaddr !== "object") throw new Error("no proxy address");
const proxyUrl = `http://127.0.0.1:${pxaddr.port}`;
const secureProxy = createHttpsServer(
{
key: readFileSync(new URL("../server/certs/localhost-key.pem", import.meta.url)),
cert: readFileSync(new URL("../server/certs/localhost.pem", import.meta.url)),
},
proxyRequest,
);
secureProxy.on("connect", proxyConnect);
await new Promise((resolve) => secureProxy.listen(0, "127.0.0.1", resolve));
const securePxaddr = secureProxy.address();
if (securePxaddr === null || typeof securePxaddr !== "object") {
throw new Error("no secure proxy address");
}
const secureProxyUrl = `https://localhost:${securePxaddr.port}`;
return {
baseUrl,
refusedUrl,
proxyUrl,
secureProxyUrl,
proxiedRequests: () => proxiedRequests,
tunneledRequests: () => tunneledRequests,
proxyAuthorizations: () => proxyAuthorizations.slice(),
close: async () => {
await new Promise((resolve) => server.close(() => resolve()));
await new Promise((resolve) => proxy.close(() => resolve()));
await new Promise((resolve) => secureProxy.close(() => resolve()));
},
};
}
if (process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]) {
const s = await startFetchServers();
process.stderr.write(`BASE ${s.baseUrl}\nREFUSED ${s.refusedUrl}\nPROXY ${s.proxyUrl}\n`);
process.stderr.write(`SECURE_PROXY ${s.secureProxyUrl}\n`);
// Serve until killed (the lane owns the process's lifetime).
}