Summary
The buffering loop in bufferBodyWithin bounds the request body by size and by nothing else. It never consults request.signal, and there is no deadline or rate floor anywhere in the file (grep -i timeout server.ts returns nothing).
Two consequences: an aborted request keeps being buffered and the handler never returns, and a slowloris body occupies a request slot for as long as the sender likes while staying under the size bound.
Reproduction
const ac = new AbortController();
let pulls = 0, sourceCancelled = false;
const slow = new ReadableStream({
pull(c) { // 1 byte every 5 ms
pulls++;
return new Promise(res => setTimeout(() => { c.enqueue(new Uint8Array(1)); res(); }, 5));
},
cancel() { sourceCancelled = true; }
});
const pending = handleServerFunctionRequest(new Request("http://x/_server/g", {
method: "POST", body: slow, duplex: "half", signal: ac.signal,
headers: { "Sec-Fetch-Site": "same-origin", "x-server-function-format": "8" }
}));
setTimeout(() => ac.abort(new Error("client gone")), 200);
Observed on next @ ee73e053:
abort during buffering: outcome=STILL BUFFERING after=1501ms pulls=264 sourceCancelled=false
The abort landed at 200 ms. At 1501 ms the loop is still reading, the source has never been cancelled, and the handler promise has not settled. My probe process then hung until I killed it — the handler never returns at all.
At that rate the default 1 MiB bound is reached after roughly 87 minutes, so the size bound is not a meaningful backstop for a slow sender.
Where
packages/web/server-functions/src/server.ts:1148-1163
async function bufferBodyWithin(request, limit) {
const reader = request.clone().body.getReader();
const chunks = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > limit) { reader.cancel().catch(() => {}); return null; }
chunks.push(value);
…
Introduced in 51392f36 feat(web): bound server-function request payloads (#3115, #3119).
Scope, honestly stated
On stock Node/undici the platform errors the body stream when the client goes away, so in that deployment you land in #3217 (a rejection) rather than a hang. The hang is reachable on any adapter that builds a Request with a signal but does not error the body — and the slowloris half needs no adapter quirk at all: a sender that stays under the size bound is bounded by nothing.
This is the one place in an otherwise careful signal story where the signal is not consulted; every streaming path in this file does honour it (server.ts:2163 wires an abort listener, and teardown there is clean — I verified readers, sources and timers are all released).
Options
- Consult the signal in the loop — reject or return
null when request.signal.aborted, and cancel the reader. Minimal, and it matches how the rest of the file treats the signal.
- Add a time bound alongside the size bound — a deadline, or a minimum-rate floor. Bounds a slowloris that never aborts, which (1) does not.
- Both, since they close different halves: (1) is about a client that left, (2) about one that is still there and dribbling.
Size, time and liveness are three separate bounds; the function currently has one. Whether the other two belong here or in the adapter is a judgement call I would rather leave to you — but with bodySizeLimit living in this config, a caller could reasonably expect its companions to as well.
Regression test
it("stops buffering when the request is aborted", async () => {
const ac = new AbortController();
const pending = handleServerFunctionRequest(slowUpload(id, ac.signal));
ac.abort();
await expect(withTimeout(pending, 200)).resolves.toBeDefined(); // settles, not hangs
});
Summary
The buffering loop in
bufferBodyWithinbounds the request body by size and by nothing else. It never consultsrequest.signal, and there is no deadline or rate floor anywhere in the file (grep -i timeout server.tsreturns nothing).Two consequences: an aborted request keeps being buffered and the handler never returns, and a slowloris body occupies a request slot for as long as the sender likes while staying under the size bound.
Reproduction
Observed on
next@ee73e053:The abort landed at 200 ms. At 1501 ms the loop is still reading, the source has never been cancelled, and the handler promise has not settled. My probe process then hung until I killed it — the handler never returns at all.
At that rate the default 1 MiB bound is reached after roughly 87 minutes, so the size bound is not a meaningful backstop for a slow sender.
Where
packages/web/server-functions/src/server.ts:1148-1163Introduced in
51392f36feat(web): bound server-function request payloads (#3115, #3119).Scope, honestly stated
On stock Node/undici the platform errors the body stream when the client goes away, so in that deployment you land in #3217 (a rejection) rather than a hang. The hang is reachable on any adapter that builds a
Requestwith a signal but does not error the body — and the slowloris half needs no adapter quirk at all: a sender that stays under the size bound is bounded by nothing.This is the one place in an otherwise careful signal story where the signal is not consulted; every streaming path in this file does honour it (
server.ts:2163wires an abort listener, and teardown there is clean — I verified readers, sources and timers are all released).Options
nullwhenrequest.signal.aborted, and cancel the reader. Minimal, and it matches how the rest of the file treats the signal.Size, time and liveness are three separate bounds; the function currently has one. Whether the other two belong here or in the adapter is a judgement call I would rather leave to you — but with
bodySizeLimitliving in this config, a caller could reasonably expect its companions to as well.Regression test