fix: skip worker forwarding when action was already forwarded#93710
fix: skip worker forwarding when action was already forwarded#93710elishagreenwald wants to merge 2 commits into
Conversation
|
@unstubbable it looks like you may have written much of the surrounding action-handler code so I'm hoping this would be something you can review. This is causing a memory leak in prod for us that we had to patch nextjs to avoid. Issue #84504 has been open since October with two stalled prior PRs (#84525 & #92053 ) so I'm really hoping this one is shippable. Thank you kindly! |
Avoid infinite peer-forward loops when middleware rewrites paths or the request was already forwarded (x-action-forwarded). The receiving worker short-circuits the forwarding decision and falls through to lenient serverModuleMap resolution instead of forwarding again. Adds a self-contained e2e regression test that reproduces the loop via a proxy.ts rewrite: with the fix the request resolves in ~150ms; without the fix it hangs until the test's 10s abort timer fires. Fixes vercel#84504 Supersedes vercel#84525, vercel#92053
e07eda2 to
8738a5d
Compare
Failing test suitesCommit: 8738a5d | About building and testing Next.js
Expand output● action forward loop prevention › does not loop when a rewrite sends the action POST to a route that does not bundle it
Expand output● action forward loop prevention › does not loop when a rewrite sends the action POST to a route that does not bundle it
Expand output● hmr-deleted-page › should not show errors for a deleted page
Expand output● optimistic-routing › rewrite detection (search params): does not use cached pattern when search params cause different rewrite |
Reopens #93710 from a branch on this repo so the required deploy test matrix can run — GitHub Actions doesn't expose secrets to fork PRs, and those deploy jobs are required checks. All commits and credit carry forward from the original PR by @elishagreenwald, which built on earlier attempts by @LiamBoz in #84525 and @claygeo in #92053. When middleware rewrites a Server Action POST to a page that doesn't bundle the action, the receiving worker forwards the request to a worker that does. The forwarded request hits middleware again and gets rewritten the same way, so the new receiving worker forwards again — looping until undici's headers timeout (~300s) or memory pressure brings the server down (#84504). The fix is two related changes on the forwarding code path. The first is an `!actionWasForwarded` guard in `action-handler.ts` so a request carrying `x-action-forwarded: 1` is never forwarded a second time. The second is a new branch in `createForwardedActionResponse` that copies the `x-nextjs-action-not-found` header and 404 status onto the originating response when the forwarded worker can't find the action either; without it the client would see a generic "unexpected response" error instead of the `UnrecognizedActionError` that the rest of the framework expects. The e2e test at `test/e2e/app-dir/action-forward-loop` reproduces the scenario. A `proxy.ts` rewrites POSTs to `/with-action` so GETs still render the page normally; the page renders a `<form>` with an inline server action wrapped in a React error boundary that branches on `unstable_isUnrecognizedActionError`. The test clicks the button and waits for `#action-not-found-error`. Without the loop guard the test times out at 10s, and without the pass-through the boundary catches a generic error and the assertion fails — so both changes are individually load-bearing. This is a short-term fix. The mid-term direction is to remove server-side action forwarding altogether and have the client dispatch each Server Action to the route that actually bundles it, which makes the entire forwarding code path (and this bug surface) unnecessary. #90549 is the draft exploring that approach. Fixes #84504 Closes #93710 Closes #84525 Closes #92053 --------- Co-Authored-By: Elisha Greenwald <ejgreenwald@gmail.com> Co-Authored-By: LiamBoz <thisamazingnow@gmail.com> Co-Authored-By: kmaclip <kmartclips@proton.me>
Reopens #93710 from a branch on this repo so the required deploy test matrix can run — GitHub Actions doesn't expose secrets to fork PRs, and those deploy jobs are required checks. All commits and credit carry forward from the original PR by @elishagreenwald, which built on earlier attempts by @LiamBoz in #84525 and @claygeo in #92053. When middleware rewrites a Server Action POST to a page that doesn't bundle the action, the receiving worker forwards the request to a worker that does. The forwarded request hits middleware again and gets rewritten the same way, so the new receiving worker forwards again — looping until undici's headers timeout (~300s) or memory pressure brings the server down (#84504). The fix is two related changes on the forwarding code path. The first is an `!actionWasForwarded` guard in `action-handler.ts` so a request carrying `x-action-forwarded: 1` is never forwarded a second time. The second is a new branch in `createForwardedActionResponse` that copies the `x-nextjs-action-not-found` header and 404 status onto the originating response when the forwarded worker can't find the action either; without it the client would see a generic "unexpected response" error instead of the `UnrecognizedActionError` that the rest of the framework expects. The e2e test at `test/e2e/app-dir/action-forward-loop` reproduces the scenario. A `proxy.ts` rewrites POSTs to `/with-action` so GETs still render the page normally; the page renders a `<form>` with an inline server action wrapped in a React error boundary that branches on `unstable_isUnrecognizedActionError`. The test clicks the button and waits for `#action-not-found-error`. Without the loop guard the test times out at 10s, and without the pass-through the boundary catches a generic error and the assertion fails — so both changes are individually load-bearing. This is a short-term fix. The mid-term direction is to remove server-side action forwarding altogether and have the client dispatch each Server Action to the route that actually bundles it, which makes the entire forwarding code path (and this bug surface) unnecessary. #90549 is the draft exploring that approach. Fixes #84504 Closes #93710 Closes #84525 Closes #92053 --------- Co-Authored-By: Elisha Greenwald <ejgreenwald@gmail.com> Co-Authored-By: LiamBoz <thisamazingnow@gmail.com> Co-Authored-By: kmaclip <kmartclips@proton.me>
What?
Stops the server action handler from forwarding to another worker when the incoming request was already forwarded (
x-action-forwarded: 1).Why?
When middleware rewrites a Server Action POST to a page that doesn't bundle the action, the receiving worker forwards the request to a worker that does. The forwarded request hits middleware again and gets rewritten the same way, so the new receiving worker forwards again — looping until undici's headers timeout (~300s) or memory pressure brings the server down (#84504).
How?
!actionWasForwardedto the existing forwarding guard inaction-handler.ts. Thex-action-forwardedheader was already set on forwarded requests but wasn't being used to gate the forwarding decision.test/e2e/app-dir/action-forward-loop.proxy.tsrewrites POSTs to/with-action(GETs still render normally, so the user can see the page); the page renders a<form>with an inline server action wrapped in a React error boundary. The test clicks the button and waits for the boundary's error UI. Without the fix, the action POST loops on the server and Playwright's 10swaitForSelectortimes out.Follow-up TODO left in
createForwardedActionResponse: when the forwarded worker returns an action-not-found 404, the forwarder currently swallows it and returns a generic JSON body, so the client throws a generic "unexpected response" error instead ofUnrecognizedActionError. Fixing that would let the boundary branch on the specific error class (the boundary has a matching TODO).Fixes #84504
Closes #84525
Closes #92053
Co-Authored-By: @LiamBoz
Co-Authored-By: @claygeo
Fixes #