Skip to content

fix(actions): return body-limit errors without closing responses - #2026

Merged
james-elicx merged 4 commits into
mainfrom
codex/parity-action-body-limit
Jun 15, 2026
Merged

fix(actions): return body-limit errors without closing responses#2026
james-elicx merged 4 commits into
mainfrom
codex/parity-action-body-limit

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • preserve Node response sockets when oversized request bodies are cancelled
  • adapt Node request streams to Web streams with backpressure-aware pause/resume behavior
  • keep ordinary 500 Server Action failures as serialized action errors while limiting access fallbacks to 401/403/404
  • add deployed overflow and browser error-boundary regressions

Next.js parity

Fixes the plaintext and multipart failures in:

  • test/e2e/app-dir/actions/app-action-size-limit-invalid.test.ts

Validation

  • pinned Next.js v16.2.6 targeted deploy suite: 5/5 passed
  • browser fixture renders Something went wrong! and excludes NEXT_HTTP_ERROR_FALLBACK;500
  • request adapter/action/middleware and production integration tests passed
  • scoped vp check and vinext build passed
  • two independent review rounds; no actionable findings remain

@pkg-pr-new

pkg-pr-new Bot commented Jun 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2026
npm i https://pkg.pr.new/vinext@2026

commit: d28ae28

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/bigbonk review

Reviewed the body-limit / backpressure changes against Next.js v16.2.6 behavior and traced the stream lifecycle through the entry, RSC handler, middleware, and prod-server paths. The Next.js parity for the HTTP-access-fallback restriction is correct, but I found one real correctness bug, one no-op/dead-code issue, and a couple of smaller notes.

1. new Uint8Array(chunk.buffer, …) aliases Node's pooled buffer — data corruption risk (blocking)

packages/vinext/src/server/prod-server.ts:165

controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));

This enqueues a view into the chunk's underlying ArrayBuffer rather than a copy. Node's IncomingMessage/socket reads are backed by a shared internal buffer pool (Buffer.allocUnsafe), so consecutive data chunks frequently share the same ArrayBuffer. Demonstrated locally:

captured views share underlying buffer for chunk[0] and chunk[1]? true
v1 before slab reused: [1,1,1,1]
v1 after slab reused:  [9,9,9,9]   // queued view corrupted

The old code (new Uint8Array(chunk)) copied the bytes, so this was safe. The new backpressure design makes this worse, not better: chunks now sit queued in the ReadableStream (paused/resumed via pull) for longer, giving Node more opportunity to reuse the pooled slab region before the queued view is consumed. The result is intermittent body corruption that won't reproduce deterministically in tests.

Fix: copy the bytes when enqueuing, e.g. controller.enqueue(Uint8Array.prototype.slice.call(chunk)) or new Uint8Array(chunk) (the previous behavior), or controller.enqueue(new Uint8Array(chunk.subarray())) — anything that does not alias the pool.

2. sourceRequest.body.cancel() after cloneRequestWithHeaders is dead code (no-op)

packages/vinext/src/server/app-router-entry.ts:120-124 and packages/vinext/src/server/app-rsc-handler.ts:1067-1070

const sourceRequest = request;
request = cloneRequestWithHeaders(sourceRequest, filteredHeaders);
if (process.env.NODE_ENV !== "development" && sourceRequest.body) {
  void sourceRequest.body.cancel().catch(() => {});
}

cloneRequestWithHeaders does new Request(request, { headers }) (request-pipeline.ts:658). Constructing a Request from an existing one transfers/locks the source body into the new request. So by the time we reach the cancel() call, sourceRequest.body is already locked. Verified:

src.bodyUsed after new Request: true
src.body locked: true
src cancel threw: TypeError Invalid state: ReadableStream is locked
cloned text: "hello world"   // clone still has the body

The TypeError is swallowed by .catch(() => {}), so this never cancels anything in the normal path. Two problems:

  • It does nothing toward the stated goal (no body is drained here).
  • In the rare fallback branch of cloneRequestWithHeaders (the catch that passes body: request.body by reference, request-pipeline.ts:663), the source and clone share the same stream — there, a successful cancel() would destroy the clone's body and break every POST that reaches a route handler/Server Action. Today it's masked only because the standard path locks the stream first.

Either drop these two blocks, or restructure so the drain targets the branch you actually intend to discard (e.g. tee/clone explicitly and cancel the discarded branch before it's locked). As written it's confusing dead code at best and a latent footgun at worst.

The genuinely effective drain is the one in app-server-action-execution.ts:1040-1043 (cancels options.request.body on the content-length > maxActionBodySize short-circuit, before the body is locked) — that one is correct.

3. Middleware finally body cancel — verify the mwRequest === request aliasing case

packages/vinext/src/server/middleware-runtime.ts:317-321 cancels nextRequest.body in a finally that runs even when middleware returns NextResponse.next() and the request continues downstream.

This is currently safe because createNextRequest (line 214) clones when request.body && !request.bodyUsed, so nextRequest.body is an isolated tee branch. But when request.body is null or already used, mwRequest = request (same object), so nextRequest.body IS options.request.body. It happens to be safe only because that branch implies the body is null/consumed. Please add a comment documenting this invariant, or guard the cancel on the clone explicitly — it's fragile to a future refactor of createNextRequest.

4. Next.js parity (looks correct)

  • createServerActionHttpFallbackError restricting to 401/403/404 (app-browser-action-result.ts:76) matches Next.js ALLOWED_CODES in http-access-fallback.ts (only NOT_FOUND/FORBIDDEN/UNAUTHORIZED). 500s correctly fall through to the serialized action error, matching the server-action-reducer behavior (res.status >= 400 && content-type === 'text/plain'throw new Error(message)). Good.
  • void reader.cancel() in text-stream.ts:64 and app-server-action-execution.ts:527 avoids hanging on the unconsumed tee sibling; releaseLock() after a fire-and-forget cancel is safe (verified). Good, and the new sibling-branch tests cover it.

Summary

  • #1 is blocking — it's a real (if intermittent) request-body corruption bug introduced by switching from copy to view.
  • #2 should be removed or fixed — it's non-functional and hides a footgun.
  • #3 is a maintainability/robustness note.

Suggest fixing #1 and #2 before merge.

@ask-bonk

ask-bonk Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review posted on PR #2026. Summary of findings:

Review summary

I traced the body-limit/backpressure changes through the entry, RSC handler, middleware, and prod-server paths and validated stream semantics with standalone Node reproductions.

Blocking — data corruption (#1): prod-server.ts:165 enqueues new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength) — a view into Node's shared/pooled buffer rather than a copy. Consecutive socket chunks share the same ArrayBuffer, so a queued view can be overwritten before it's consumed. The old code copied (new Uint8Array(chunk)), and the new backpressure design keeps chunks queued longer, amplifying the risk. Reproduced the corruption locally.

Dead code / footgun (#2): The sourceRequest.body.cancel() calls in app-router-entry.ts:122 and app-rsc-handler.ts:1068 run after cloneRequestWithHeaders does new Request(source, {headers}), which locks the source body. So cancel() throws TypeError and is swallowed — a no-op. In the fallback clone path (shared stream), a successful cancel would instead destroy the clone's body and break downstream POSTs. Should be removed or restructured.

Robustness note (#3): The middleware finally cancel is safe today only because of the clone-on-body invariant in createNextRequest; worth documenting or guarding.

Parity confirmed (#4): Restricting HTTP-access-fallback to 401/403/404 matches Next.js ALLOWED_CODES; 500s correctly fall through as serialized action errors. The void reader.cancel() changes are safe and well-tested.

The app-server-action-execution.ts:1040 drain (on the content-length short-circuit) is the one that actually works correctly.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/bigbonk review

Re-reviewed after 2beea30 fix(server): own Node request stream chunks. The previously-blocking data-corruption bug is resolved. One issue from the prior round (sourceRequest.body.cancel() dead code / footgun) remains unaddressed.

1. ✅ Resolved — Node chunk aliasing (was blocking)

prod-server.ts:165 now copies bytes (new Uint8Array(chunk)) instead of aliasing Node's pooled ArrayBuffer (new Uint8Array(chunk.buffer, …)). The added regression test node-to-web-request.test.ts ("owns queued bytes when Node reuses a pooled chunk buffer") covers the slab-reuse case. Verified the test passes. Good fix.

2. ⚠️ Still present — sourceRequest.body.cancel() after cloneRequestWithHeaders is a no-op (and a latent footgun)

app-router-entry.ts:122-124 and app-rsc-handler.ts:1068-1070:

const sourceRequest = request;
request = cloneRequestWithHeaders(sourceRequest, filteredHeaders);
if (process.env.NODE_ENV !== "development" && sourceRequest.body) {
  void sourceRequest.body.cancel().catch(() => {});
}

cloneRequestWithHeaders (request-pipeline.ts:658) does new Request(request, { headers }), which transfers/locks the source body. By the time cancel() runs, sourceRequest.body is already locked. Reproduced in this runtime:

src.bodyUsed after new Request: true
src.body locked: true
src cancel threw: TypeError Invalid state: ReadableStream is locked   // swallowed by .catch
cloned text: "hello world"   // clone retains the full body

Two problems, unchanged from last round:

  • Normal path: the cancel() throws and is swallowed — it drains nothing, so it does nothing toward the stated goal.
  • Fallback path (request-pipeline.ts:663, body: request.body ?? undefined — by reference): source and clone share the same stream. A successful cancel here destroys the clone's body. Reproduced:
src cancel succeeded (would destroy shared stream)
cloned.text() threw: TypeError Body is unusable: Body has already been read

Today this is masked only because the standard path locks the stream first. It's confusing dead code in the common case and a latent breakage of every POST that reaches a route handler / Server Action if the fallback path is ever taken. Recommend dropping both blocks (the body is owned by request after the clone and is consumed normally), or restructuring to explicitly tee and cancel the discarded branch before it's locked.

The genuinely effective drain remains app-server-action-execution.ts:1042-1043, which cancels options.request.body on the content-length > maxActionBodySize short-circuit before the body is locked — that one is correct.

3. Note — middleware finally body cancel relies on a clone invariant

middleware-runtime.ts:317-321 cancels nextRequest.body in a finally that also runs on the NextResponse.next() continue path. This is safe today because createNextRequest (line 214) takes the request.clone() branch precisely when request.body && !request.bodyUsed, so a non-null nextRequest.body is always an isolated tee branch. When mwRequest === request (the alias branch), the body is null/consumed, so the nextRequest.body guard is falsy. The invariant is correct but fragile to a future createNextRequest refactor — worth a comment documenting it.

4. ✅ Parity confirmed

  • createServerActionHttpFallbackError restricting to 401/403/404 (app-browser-action-result.ts) matches Next.js ALLOWED_CODES (NOT_FOUND/FORBIDDEN/UNAUTHORIZED); 500s correctly fall through as serialized action errors.
  • void reader.cancel() + releaseLock() changes in text-stream.ts and app-server-action-execution.ts:527 are safe and covered by the new sibling-branch tests.

Summary

  • #1 fixed — no longer blocking.
  • #2 should still be removed or restructured — non-functional in the normal path, latent footgun in the fallback path.
  • #3 is a maintainability note.

Recommend addressing #2 before merge.

@ask-bonk

ask-bonk Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review posted on PR #2026.

Review summary

I re-reviewed the body-limit/backpressure changes after the latest commit (2beea30 fix(server): own Node request stream chunks), reproducing the stream behaviors locally to verify each finding.

#1 — Resolved (was blocking): The Node chunk-aliasing data-corruption bug is fixed. prod-server.ts:165 now copies bytes (new Uint8Array(chunk)) instead of aliasing Node's pooled buffer, and a regression test ("owns queued bytes when Node reuses a pooled chunk buffer") covers the slab-reuse case. Verified the test passes.

#2 — Still present (recommend fixing before merge): The sourceRequest.body.cancel() calls in app-router-entry.ts:122 and app-rsc-handler.ts:1068 run after cloneRequestWithHeaders does new Request(source, {headers}), which locks the source body. Reproduced both cases:

  • Normal path: cancel() throws TypeError: ReadableStream is locked, swallowed by .catch() → no-op.
  • Fallback path (shared stream by reference): a successful cancel destroys the clone's body, breaking downstream POSTs.

#3 — Robustness note: The middleware finally cancel is safe today only due to the clone-on-body invariant in createNextRequest; worth documenting.

#4 — Parity confirmed: 401/403/404 HTTP-access-fallback restriction matches Next.js ALLOWED_CODES; 500s fall through as serialized action errors; the void reader.cancel() changes are safe and tested.

Net: the previously-blocking bug is fixed; #2 remains the one actionable item before merge.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/bigbonk review

Re-reviewed after the new commit d28ae28 fix(server): preserve cloned request bodies. Both previously-flagged issues are now resolved, and I re-verified the stream/parity behavior. No actionable findings remain.

1. ✅ Resolved — Node chunk aliasing (was blocking in round 1)

prod-server.ts:165 copies bytes via new Uint8Array(chunk) instead of aliasing Node's pooled ArrayBuffer. The regression test node-to-web-request.test.ts ("owns queued bytes when Node reuses a pooled chunk buffer") covers the slab-reuse case. Verified passing.

2. ✅ Resolved — sourceRequest.body.cancel() dead code / footgun (was flagged rounds 1 & 2)

d28ae28 removes both blocks:

  • app-router-entry.ts — the sourceRequest capture + post-clone cancel() is gone; now just request = cloneRequestWithHeaders(request, filteredHeaders).
  • app-rsc-handler.ts — the rawRequest.body.cancel() block is gone.

This eliminates both the no-op (the clone locks the source body so cancel() always threw and was swallowed) and the latent fallback-path footgun (a successful cancel destroying the shared clone body). The body is owned by the clone and consumed normally downstream. Good.

3. Note (unchanged) — middleware finally body cancel relies on a clone invariant

middleware-runtime.ts:317-321 cancels nextRequest.body in a finally that also runs on the NextResponse.next() continue path. I re-traced createNextRequest:

  • request.body && !request.bodyUsedmwRequest = request.clone(), so a non-null nextRequest.body is always an isolated tee branch — safe to cancel.
  • Otherwise mwRequest === request (alias), but the body is null/consumed, so the nextRequest.body guard is falsy — no cancel runs.
  • The new Request(mwUrl, mwRequest) rebuild at line 232 transfers the (clone's) body into the new request, so nextRequest.body never aliases options.request.body.

The invariant is correct but fragile to a future createNextRequest refactor. A one-line comment documenting it would help, but this is a maintainability note, not a blocker.

4. ✅ Parity confirmed

  • createServerActionHttpFallbackError now restricts to 401/403/404 (app-browser-action-result.ts:76), matching Next.js ALLOWED_CODES (UNAUTHORIZED/FORBIDDEN/NOT_FOUND); 500s correctly fall through as serialized action errors.
  • void reader.cancel() + releaseLock() changes in text-stream.ts and app-server-action-execution.ts:527 are safe; the effective content-length short-circuit drain at app-server-action-execution.ts:1042 cancels the body before it's locked.
  • readNodeStream cancel() correctly drains (req.resume()) without destroying the Node request — covered by the new "cancels without destroying the Node request and drains the remainder" test.

Verification

  • vp test run on node-to-web-request.test.ts, app-server-action-execution.test.ts, app-router-production-server.test.ts, app-browser-entry.test.ts → 340 passed.
  • vp check on all 7 changed source files → clean (format, lint, types).

Note on the diff vs main

The branch is currently 3 commits behind main (#2031, #1940, #2023). A git diff main..HEAD shows some unrelated image-quality and dedup-params tests as "deleted" — those are main-only additions the branch hasn't merged yet, not regressions from this PR. A rebase/merge before landing will resolve it.

Summary

  • #1 fixed (round 2), #2 fixed (this round) — no remaining blockers.
  • #3 is a maintainability note; an inline comment on the invariant would be nice-to-have.
  • Parity is correct. LGTM.

@ask-bonk

ask-bonk Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review posted on PR #2026.

Review summary

I re-reviewed after the new commit d28ae28 fix(server): preserve cloned request bodies, reproducing/verifying behavior with the test suite. Both previously-flagged issues are now resolved.

#1 — Resolved (was blocking): prod-server.ts:165 copies bytes (new Uint8Array(chunk)), no longer aliasing Node's pooled buffer. Regression test verified passing.

#2 — Resolved (this round): d28ae28 removed both body.cancel() blocks in app-router-entry.ts and app-rsc-handler.ts. This eliminates the no-op (clone locks the source body, so cancel always threw and was swallowed) and the latent fallback-path footgun. The clone owns the body and consumes it normally downstream.

#3 — Maintainability note: The middleware finally cancel is safe today only due to the clone-on-body invariant in createNextRequest (re-traced and confirmed correct, including the new Request(mwUrl, mwRequest) rebuild path). An inline comment documenting the invariant would help, but it's not a blocker.

#4 — Parity confirmed: 401/403/404 HTTP-access-fallback restriction matches Next.js ALLOWED_CODES; 500s fall through as serialized action errors; readNodeStream cancel drains without destroying the Node request.

Verification: 340 tests passed across the 4 relevant files; vp check clean on all 7 changed source files.

Net: No remaining blockers. The only open item is the optional #3 documentation comment. LGTM.

I also noted that the branch is 3 commits behind main, which makes a diff main..HEAD show some unrelated image-quality/dedup-params tests as "deleted" — those are main-only additions, not regressions from this PR, and a rebase will resolve it.

github run

@james-elicx
james-elicx marked this pull request as ready for review June 15, 2026 10:48
@james-elicx

Copy link
Copy Markdown
Member Author

PR #2026 local regression confirmation

Verdict

The deploy-suite failure from run 27549611191 does not reproduce locally on PR head d28ae28. No code changes were made.

Environment

  • vinext branch: codex/parity-action-body-limit
  • vinext head: d28ae28
  • Next.js tag/build: v16.2.6 / ee6e79b1792a4d401ddf2480f40a83549fe8e722
  • Node: 24 via vp env exec --node 24
  • Local deploy mode, built vinext, concurrency 1, retries 0

Results

  1. Full targeted suite:

    • Command: NEXTJS_PREPARE=0 NEXT_TEST_CONCURRENCY=1 vp env exec --node 24 -- ./scripts/run-nextjs-deploy-suite.sh /Users/jamesanderson/Developer/vinext/.nextjs-ref --retries 0 -c 1 --debug test/e2e/app-dir/actions/app-action.test.ts
    • Result: PASS, 70 passed / 8 skipped
    • Suspect assertion passed in 627ms
    • Log: /tmp/vinext-pr2026-local/branch-app-action.log
  2. Exact Jest test-name probe:

    • Test: should handle redirects to routes that provide an invalid RSC response
    • Result: PASS in 364ms
    • Log: /tmp/vinext-pr2026-local/branch-invalid-rsc-exact-1.log
  3. Exact Jest test-name repeat:

    • Result: PASS in 917ms
    • Log: /tmp/vinext-pr2026-local/branch-invalid-rsc-exact-2.log

All three deployments completed successfully and reached the assertion. The full suite also passed every downstream action test that had cascaded after the CI timeout. The CI-only timeout is therefore best classified as a one-run flake/harness stall rather than a confirmed branch regression.

@james-elicx
james-elicx merged commit 8b53905 into main Jun 15, 2026
55 checks passed
@james-elicx
james-elicx deleted the codex/parity-action-body-limit branch June 15, 2026 20:56
james-elicx added a commit that referenced this pull request Jul 31, 2026
…ng (#2741)

* fix(server): transfer request bodies into NextRequest instead of teeing

`Request.clone()` tees the body stream, and a tee branch that is never read
buffers every chunk the other branch pulls. The NextRequest constructor cloned
any body-bearing input, so wrapping the incoming request left an unread branch
holding the whole body. A route handler that streams a 128 MiB upload to
storage — O(1) memory by design — retained the full 128 MiB instead, letting an
unauthenticated client exhaust the process or Worker isolate by repeating the
request.

Upstream Next.js does `super(input, init)` here, transferring the body rather
than branching it. Match that. Callers that genuinely need two live branches
(middleware vs. downstream routing) already clone explicitly, so nothing loses
the isolation added in #1132.

The clone also defeated the mitigation added in #2026: `executeMiddleware`
cancels the middleware body branch in a `finally`, but the constructor's extra
tee sat between that cancel and the branch actually accumulating chunks, so the
cancel released a branch nobody was filling. With the clone gone the existing
cancel reaches the real branch again.

Drop the same dead tee from the basePath re-prefix path in
`createTrackedAppRouteRequest`, where the source request is replaced outright
and its body is never read again.

Measured with a 128 MiB streamed body, reading through and discarding:

  before: arrayBuffers +128.0 MiB (route handler wrap, and middleware that
          does not touch the body)
  after:  arrayBuffers +0.0 MiB, matching an unwrapped baseline

* fix(server): avoid teeing normalized RSC request bodies

* fix(middleware): release isolated request body branches

---------

Co-authored-by: James <james@eli.cx>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant