// A request body that crosses a service binding and is not read gets the connection reset.
// npm i miniflare (which brings workerd), then node repro.mjs. Runs workerd on its own with four
// Workers, Miniflare with one, and a Node HTTP server as a reference. Every request goes over a raw
// socket and declares and sends its body with the headers in one write; every server answers 413.
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import http from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { convertV4MiniflareOptions, Miniflare } from "miniflare";
import workerd from "workerd";
const REFUSE = `export default { async fetch() { return new Response("too large", { status: 413 }); } };`;
const READ = `export default { async fetch(request) { await request.arrayBuffer(); return new Response("too large", { status: 413 }); } };`;
const FORWARD = `export default { async fetch(request, env) { return env.TARGET.fetch(request); } };`;
const worker = (script, target) =>
`(modules = [(name = "worker.js", esModule = ${JSON.stringify(script)})], compatibilityDate = "2026-09-01"` +
(target ? `, bindings = [(name = "TARGET", service = "${target}")])` : ")");
const PORT = { refuse: 18701, read: 18702, forwardToRefuse: 18703, forwardToRead: 18704, node: 18705 };
const config = `using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [
(name = "refuse", worker = ${worker(REFUSE)}),
(name = "read", worker = ${worker(READ)}),
(name = "forwardToRefuse", worker = ${worker(FORWARD, "refuse")}),
(name = "forwardToRead", worker = ${worker(FORWARD, "read")}),
],
sockets = [
(name = "refuse", address = "127.0.0.1:${PORT.refuse}", http = (), service = "refuse"),
(name = "read", address = "127.0.0.1:${PORT.read}", http = (), service = "read"),
(name = "forwardToRefuse", address = "127.0.0.1:${PORT.forwardToRefuse}", http = (), service = "forwardToRefuse"),
(name = "forwardToRead", address = "127.0.0.1:${PORT.forwardToRead}", http = (), service = "forwardToRead"),
],
);
`;
// One request with a body of `bytes`. The client starts reading the response `readAfterMs` after
// sending, as a busy client would.
function probe(port, bytes, readAfterMs) {
return new Promise((resolve) => {
const socket = net.connect(port, "127.0.0.1");
const chunks = [];
let respondedAt;
let settled = false;
const finish = (ending) => {
if (settled) return;
settled = true;
const status = /^HTTP\/1\.1 (\d{3})/.exec(Buffer.concat(chunks).toString("latin1"))?.[1];
const after = respondedAt === undefined ? undefined : performance.now() - respondedAt;
resolve({ outcome: `${status ?? "no response"}, then ${ending}`, after });
socket.destroy();
};
socket.on("connect", () => {
socket.write(`POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: ${bytes}\r\n\r\n${"x".repeat(bytes)}`);
if (readAfterMs > 0) {
socket.pause();
setTimeout(() => socket.resume(), readAfterMs);
}
});
socket.on("data", (chunk) => {
respondedAt ??= performance.now();
chunks.push(chunk);
});
socket.on("end", () => finish("FIN"));
socket.on("error", (error) => finish(error.code));
socket.setTimeout(2000, () => finish("connection still open 2 s later"));
});
}
async function listening(port) {
for (let attempt = 0; attempt < 100; attempt++) {
const open = await new Promise((resolve) => {
const socket = net.connect(port, "127.0.0.1", () => {
socket.destroy();
resolve(true);
});
socket.on("error", () => resolve(false));
});
if (open) return;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`nothing is listening on ${port}`);
}
const folder = mkdtempSync(path.join(os.tmpdir(), "workerd-repro-"));
writeFileSync(path.join(folder, "config.capnp"), config);
const child = spawn(workerd.default, ["serve", path.join(folder, "config.capnp")], {
stdio: ["ignore", "inherit", "inherit"],
windowsHide: true,
});
const reference = http.createServer((request, response) => response.writeHead(413).end("too large"));
await new Promise((resolve) => reference.listen(PORT.node, "127.0.0.1", resolve));
const miniflare = (script) =>
new Miniflare(convertV4MiniflareOptions({ modules: true, compatibilityDate: "2026-09-01", script }));
const miniflares = { refuse: miniflare(REFUSE), read: miniflare(READ) };
try {
await Promise.all(Object.values(PORT).map(listening));
const miniflarePort = async (mf) => Number((await mf.ready).port);
console.log(`workerd ${workerd.version}, Node ${process.version}, ${os.type()} ${os.release()}; 20 requests per row`);
const rows = [
["workerd: Worker does not read the body", PORT.refuse, 5000],
["workerd: Worker forwards through a service binding to that Worker", PORT.forwardToRefuse, 5000],
["workerd, control: Worker reads the body", PORT.read, 5000],
["workerd, control: Worker forwards through a service binding to the reading Worker", PORT.forwardToRead, 5000],
["Miniflare: Worker does not read the body", await miniflarePort(miniflares.refuse), 5000],
["Miniflare, control: Worker reads the body", await miniflarePort(miniflares.read), 5000],
["Node http server, reference: does not read the body", PORT.node, 5000],
["workerd: Worker does not read the body", PORT.refuse, 100000],
];
for (const [name, port, bytes] of rows) {
for (const readAfterMs of [0, 300]) {
const outcomes = new Map();
const after = [];
for (let i = 0; i < 20; i++) {
const result = await probe(port, bytes, readAfterMs);
outcomes.set(result.outcome, (outcomes.get(result.outcome) ?? 0) + 1);
if (readAfterMs === 0 && result.after !== undefined && !result.outcome.includes("still open")) {
after.push(result.after);
}
}
const counts = [...outcomes].map(([outcome, n]) => `${n} x ${outcome}`).join("; ");
const timing = after.length === 0
? ""
: ` (closed ${Math.min(...after).toFixed(1)}-${Math.max(...after).toFixed(1)} ms after the response arrived)`;
console.log(`${name}, ${bytes}-byte body, client reads after ${readAfterMs} ms: ${counts}${timing}`);
}
}
} finally {
reference.close();
await Promise.all(Object.values(miniflares).map((mf) => mf.dispose()));
child.kill();
await new Promise((resolve) => (child.exitCode !== null ? resolve() : child.once("exit", resolve)));
rmSync(folder, { recursive: true, force: true });
}
What versions & operating system are you using?
miniflare5.20260921.0-alpha (thelatesttag) with itsworkerd1.20260921.1, and thatworkerdrun on its own.workerd, 1.20260923.1, gives the same results, on its own and under the same Miniflare (through an npmoverridesentry).@cloudflare/vite-plugin1.57.3 andwrangler4.136.3, with Miniflare'sundici7.29.0.Please provide a link to a minimal reproduction
Inline below:
npm i miniflare, thennode repro.mjs. It runs Miniflare, and it runs theworkerdthat Miniflare installs directly, without Miniflare.Describe the Bug
When a Worker responds without reading the request body, and the body reached it through a service binding, workerd resets the client's connection a few milliseconds after the response. Miniflare's entry Worker passes each request to the user's Worker through a service binding (
await service.fetch(request)inpackages/miniflare/src/workers/core/entry.worker.ts), so under Miniflare this happens to any Worker that answers early - for example one that refuses an oversized upload with 413 before reading it. A client that has not yet read the response when the reset arrives loses it: all 20 did when the client started reading 300 ms after sending.Without the binding, workerd keeps the connection, as Node's HTTP server does. That fits KJ's
HttpServer: when a service responds without reading the whole body, it reads and discards up tocanceledUploadGraceBytes(64 KiB) withincanceledUploadGracePeriod(1 s) before deciding whether to close (HttpServerSettingsinkj/compat/http.h). Through a binding, a 5,000-byte body, well inside that grace, is reset as if there were none.Since workerd alone reproduces it, the fault looks to be in workerd rather than Miniflare. We are filing it here because Miniflare is where it surfaces; it may belong in
cloudflare/workerd.From
repro.mjsbelow, with workerd 1.20260921.1. Each request sends its headers and its whole body in one write, and every server answers 413. Every row is 20 requests with the same outcome. "Kept" means the connection was still open 2 s later, past the 1 s grace; "reset" means the client's socket failed withECONNRESET.httpserver, reference: does not read the bodyRFC 9112 §9.6 is why a reset matters: "If a server performs an immediate close of a TCP connection, there is a significant risk that the client will not be able to read the last HTTP response."
How it shows up. We found it as a rare 500 from
vite preview, on a POST that a Worker refuses with 413 before reading the body: the Vite plugin'sdispatchFetchrejected withTypeError: fetch failed(stack below) and the plugin answered 500. In runs of 300 and 1,000 requests throughmf.dispatchFetch, with a streamed 5,011-byte body and 16 threads keeping every core busy, one request in each failed that way, withread ECONNRESETas the cause. On an idle machine all 1,000 succeeded, and so did a later 8,000 with a 5,000-byte body under the same or heavier load. It takes the client being slow to read at the wrong moment;repro.mjscontrols when the client reads, so it reproduces every time.Related. #15709 looks like a relative: a Worker cancels a ~2 MB body part-way under
unstable_dev, and the connection is lost. The last row above shows that even served directly, workerd resets once more than the 64 KiB grace is left unread: that is KJ's grace running out, and the staged close below would address it. This report is the case inside the grace, which fails only because the body crossed a binding - and in Miniflare every body has.What this does not establish.
Suggested fix
canceledUploadGraceByteswithincanceledUploadGracePeriod, then keep the connection or close it cleanly.Please provide any relevant error logs
The error behind the 500, as
vite previewprinted it (paths shortened, frames elided):repro.mjsIts output with workerd 1.20260921.1, and with 1.20260923.1