Summary
After #5854 (c7feba788) the CPS async→generator transform lowers the entire Next.js App Router render chain to suspending async-step state machines. At scale, those machines lose per-activation identity, so the render forms a promise cycle that never settles and the request-time dynamic RSC routes hang.
With the runtime fixes in #5932 the static + API routes are byte-identical vs node (5/8); the 3 request-time dynamic renders — /posts/[id], /fetcher, /plain — still hang (0 bytes, no throw). This issue tracks the remaining transform-side fix needed for 8/8.
This is the follow-up called out in #5880 / #5932 and is separate from the runtime promise-machinery bugs those PRs fixed. It sits on top of #5854's transform (worktree/branch async-transform-cps).
Ground-truth diagnosis (perturbation-proof)
Measured on the full-transform build (current main + #5932) with a global-registry background stuck-machine dumper (see toolkit below — env-gated, does not perturb timing):
- The
/plain render parks with ~18 async-step machines, every awaited promise Pending with a STEP_THUNK reaction registered.
- Await-graph: 6 EXTERNAL/orphan promises (React-internal
new Promise((resolve)=>…) owned by no async-step machine) + 6 await-DONE-machine + chained.
[PUMP] has_work=1 microtasks=0 timer=-1 interval=-1 callback_timer=-1 → event loop is empty (the only "work" is the idle listening HTTP server via aux_has_active()).
AbortController.abort() never called, no abort listeners registered → the render parks before the params/dynamicIO-abort phase; the orphans are React-core promises, not makeHangingPromise.
Named frames (func_ptr→atos on a PERRY_DEBUG_SYMBOLS build)
The parked call-chain is clean:
start-server → next-server → router-server → base-server
→ app-page-turbo runtime (React server renderer) __4057 / __4063 / __4066 / __4256
→ app-page template (render callbacks) __132 / __147 / __150
The 3 orphan-promise root awaiters are the app-page template render callbacks (__132, __150) and the React renderer (__4057). React's server renderer awaits internal new Promises whose resolvers live in React's own work loop — which is itself in the parked set (__4063/__4066) → self-referential deadlock. In node this never happens because React's prerender yields the shell early; under the full transform on Perry it does not.
Root cause (localized across prior diagnostic passes)
The render's simultaneously-active state machines collide on per-activation identity — a shared boxed state-var (state/sent/done) and/or step closure reused across two live activations via the capture-value-keyed singleton cache (the #1029 / #5143 PreallocateBoxes / compute_max_func_id family). A machine's continuation then settles the wrong activation's promise, orphaning the real awaiter → the cycle.
- Disabling the step-closure singleton alone (
PERRY_NO_CAPTURED_SINGLETON) does not fix it → the collision is the boxed state-var box, not (only) the step closure.
- It is scale-dependent: two disjoint ~367-module subsets each break
curl /; a 366-module subset works. No minimal repro exists — the interaction needs the bundle's simultaneous-machine volume.
Fix target
Make the transformed generator state machines' boxed state-var (and step closure) per-activation-distinct in transform_generator_function_with_extra_captures (perry-transform) + the box/closure singleton cache keying — i.e. extend the #1029 PreallocateBoxes distinctness to the async-step machines the CPS transform now produces at Next.js scale. This is transform territory (the async-transform-cps area), which is why it's handed here rather than duplicated in a runtime PR.
Reproduction
Next.js 16 App Router standalone build (output: 'standalone', dynamicIO/cacheComponents on), routes: static (/, /about, /counter), API (/api/hello GET+POST), dynamic (/posts/[id] awaits params; /fetcher force-dynamic in-process fetch; /plain force-dynamic static JSX). Compile server.js with perry (+ #5932). Static + API = byte-identical vs node v26; the 3 dynamic routes hang.
Diagnostic toolkit (for whoever picks this up)
A PERRY_STUCK_DUMP=1-gated, perturbation-proof toolkit (global Mutex<HashMap> of active machines + a 6s background dumper thread that classifies awaited values by NaN-tag without deref, plus [PUMP]/await-graph/[DIVERGE]/[MISRESOLVE]) cracked the mechanism where every env/JS probe perturbed it. It was stripped from #5932 for shipping but is fully specified in the diagnosis and quick to re-add to async_step.rs. func_ptr→atos -l <vmmap __TEXT base> on a PERRY_DEBUG_SYMBOLS=1 compile names the stuck JS closures off-stack (SIGBT can't — the machines are suspended).
References
Summary
After #5854 (
c7feba788) the CPS async→generator transform lowers the entire Next.js App Router render chain to suspending async-step state machines. At scale, those machines lose per-activation identity, so the render forms a promise cycle that never settles and the request-time dynamic RSC routes hang.With the runtime fixes in #5932 the static + API routes are byte-identical vs node (5/8); the 3 request-time dynamic renders —
/posts/[id],/fetcher,/plain— still hang (0 bytes, no throw). This issue tracks the remaining transform-side fix needed for 8/8.This is the follow-up called out in #5880 / #5932 and is separate from the runtime promise-machinery bugs those PRs fixed. It sits on top of #5854's transform (worktree/branch
async-transform-cps).Ground-truth diagnosis (perturbation-proof)
Measured on the full-transform build (current
main+ #5932) with a global-registry background stuck-machine dumper (see toolkit below — env-gated, does not perturb timing):/plainrender parks with ~18 async-step machines, every awaited promisePendingwith aSTEP_THUNKreaction registered.new Promise((resolve)=>…)owned by no async-step machine) + 6 await-DONE-machine + chained.[PUMP] has_work=1 microtasks=0 timer=-1 interval=-1 callback_timer=-1→ event loop is empty (the only "work" is the idle listening HTTP server viaaux_has_active()).AbortController.abort()never called, no abort listeners registered → the render parks before the params/dynamicIO-abort phase; the orphans are React-core promises, notmakeHangingPromise.Named frames (
func_ptr→atoson aPERRY_DEBUG_SYMBOLSbuild)The parked call-chain is clean:
The 3 orphan-promise root awaiters are the app-page template render callbacks (
__132,__150) and the React renderer (__4057). React's server renderer awaits internalnew Promises whose resolvers live in React's own work loop — which is itself in the parked set (__4063/__4066) → self-referential deadlock. In node this never happens because React's prerender yields the shell early; under the full transform on Perry it does not.Root cause (localized across prior diagnostic passes)
The render's simultaneously-active state machines collide on per-activation identity — a shared boxed state-var (
state/sent/done) and/or step closure reused across two live activations via the capture-value-keyed singleton cache (the #1029 / #5143PreallocateBoxes/compute_max_func_idfamily). A machine's continuation then settles the wrong activation's promise, orphaning the real awaiter → the cycle.PERRY_NO_CAPTURED_SINGLETON) does not fix it → the collision is the boxed state-var box, not (only) the step closure.curl /; a 366-module subset works. No minimal repro exists — the interaction needs the bundle's simultaneous-machine volume.Fix target
Make the transformed generator state machines' boxed state-var (and step closure) per-activation-distinct in
transform_generator_function_with_extra_captures(perry-transform) + the box/closure singleton cache keying — i.e. extend the #1029PreallocateBoxesdistinctness to the async-step machines the CPS transform now produces at Next.js scale. This is transform territory (theasync-transform-cpsarea), which is why it's handed here rather than duplicated in a runtime PR.Reproduction
Next.js 16 App Router standalone build (
output: 'standalone',dynamicIO/cacheComponentson), routes: static (/,/about,/counter), API (/api/helloGET+POST), dynamic (/posts/[id]awaitsparams;/fetcherforce-dynamic in-process fetch;/plainforce-dynamic static JSX). Compileserver.jswith perry (+ #5932). Static + API = byte-identical vs node v26; the 3 dynamic routes hang.Diagnostic toolkit (for whoever picks this up)
A
PERRY_STUCK_DUMP=1-gated, perturbation-proof toolkit (globalMutex<HashMap>of active machines + a 6s background dumper thread that classifies awaited values by NaN-tag without deref, plus[PUMP]/await-graph/[DIVERGE]/[MISRESOLVE]) cracked the mechanism where every env/JS probe perturbed it. It was stripped from #5932 for shipping but is fully specified in the diagnosis and quick to re-add toasync_step.rs.func_ptr→atos -l <vmmap __TEXT base>on aPERRY_DEBUG_SYMBOLS=1compile names the stuck JS closures off-stack (SIGBT can't — the machines are suspended).References
c7feba788) — the CPS transform this sits on;async-transform-cpsbranch