Summary
bufferBodyWithin is called outside any try/catch. When the request body stream errors — which is what a client disconnect mid-upload looks like on Node/undici — the error escapes handleServerFunctionRequest as a rejected promise instead of a status.
Every other body problem in this runtime answers a status. This one does not, and which contract you get is decided by whether the client declared a Content-Length.
Reproduction
const dying = () => new ReadableStream({
start(c) { c.enqueue(new Uint8Array([91])); },
pull(c) { c.error(new Error("client disconnected")); }
});
for (const [label, extra] of [
["chunked (no content-length)", {}],
["declared content-length", { "content-length": "64" }]
]) {
try {
const r = await handleServerFunctionRequest(new Request("http://x/_server/f", {
method: "POST", body: dying(), duplex: "half",
headers: { "Sec-Fetch-Site": "same-origin", "x-server-function-format": "8", ...extra }
}));
console.log(`${label} -> status ${r.status}`);
} catch (e) {
console.log(`${label} -> HANDLER REJECTED: ${e.message}`);
}
}
Observed on next @ ee73e053:
chunked (no content-length) -> HANDLER REJECTED: client disconnected
declared content-length -> status 400
The declared-length row is the control: it skips bufferBodyWithin and reaches parseArguments, which is guarded (server.ts:2748), so the identical broken upload is answered cleanly. Same failure, two contracts.
Where
packages/web/server-functions/src/server.ts:2798
const bounded = await bufferBodyWithin(request, bodySizeLimit);
Introduced in 51392f36 feat(web): bound server-function request payloads (#3115, #3119), which added the bounded buffer for undeclared bodies. The bound is right; the call just sits outside the guard that covers the declared-length path.
Why it matters
"Client disconnects mid-upload" is not an edge case in production — it is what a closed laptop, a lost mobile connection or a proxy timeout looks like. An adapter that does not wrap handleServerFunctionRequest in its own try/catch gets an unhandled rejection, which on default Node policy terminates the process (see #3216 for a second, unrelated route to the same outcome).
Options
- Wrap the call so a body-stream error lands on the same malformed-body answer the declared-length path already gives:
let bounded;
try {
bounded = await bufferBodyWithin(request, bodySizeLimit);
} catch {
return /* the 400 this function already builds for a malformed body */;
}
- Move the size bound inside
parseArguments, so one guard covers both roads and the asymmetry cannot come back. Larger change, but it removes the class of bug rather than this instance.
(2) is the more durable shape if the bound belongs with the parse; (1) is the minimal fix.
Regression test
it("answers a broken chunked upload rather than rejecting", async () => {
await expect(
handleServerFunctionRequest(postWithDyingBody(id)) // no content-length
).resolves.toHaveProperty("status", 400);
});
Reverting the guard makes this reject instead of resolve, so the test earns its place.
Summary
bufferBodyWithinis called outside anytry/catch. When the request body stream errors — which is what a client disconnect mid-upload looks like on Node/undici — the error escapeshandleServerFunctionRequestas a rejected promise instead of a status.Every other body problem in this runtime answers a status. This one does not, and which contract you get is decided by whether the client declared a
Content-Length.Reproduction
Observed on
next@ee73e053:The declared-length row is the control: it skips
bufferBodyWithinand reachesparseArguments, which is guarded (server.ts:2748), so the identical broken upload is answered cleanly. Same failure, two contracts.Where
packages/web/server-functions/src/server.ts:2798Introduced in
51392f36feat(web): bound server-function request payloads (#3115, #3119), which added the bounded buffer for undeclared bodies. The bound is right; the call just sits outside the guard that covers the declared-length path.Why it matters
"Client disconnects mid-upload" is not an edge case in production — it is what a closed laptop, a lost mobile connection or a proxy timeout looks like. An adapter that does not wrap
handleServerFunctionRequestin its owntry/catchgets an unhandled rejection, which on default Node policy terminates the process (see #3216 for a second, unrelated route to the same outcome).Options
parseArguments, so one guard covers both roads and the asymmetry cannot come back. Larger change, but it removes the class of bug rather than this instance.(2) is the more durable shape if the bound belongs with the parse; (1) is the minimal fix.
Regression test
Reverting the guard makes this reject instead of resolve, so the test earns its place.