Skip to content

transform: async-step render state machines lose per-activation identity at scale — Next.js dynamic RSC deadlock (last 3/8 SSR routes) #5941

Description

@proggeramlug

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=-1event 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_ptratos 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_ptratos -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

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