Skip to content

A client disconnect on a chunked upload rejects out of handleServerFunctionRequest instead of answering a status #3217

Description

@frenzzy

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

  1. 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 */;
    }
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions