You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Authenticated App Router browser SSR calls legacy react-dom/server API (E394 InvariantError) — full-HTML render resolves streaming export to the poison stub #6710
A logged-in user loading an authenticated App Router page in a real browser (full HTML SSR) crashes under Perry with Next.js E394 — Internal Error: do not use legacy react-dom/server APIs — repeated as unhandledRejection: InvariantError, and the response stream never cleanly closes (browser spins "loading forever"; curl reports 500). Node renders the identical request 200.
E394 is a Next.js poison-pill stub: function a(){ throw Error("Internal Error: do not use legacy react-dom/server APIs...") } assigned to the legacy react-dom/server exports (renderToString, renderToStaticMarkup, renderToNodeStream, …). App Router must only call the streaming APIs. So under Perry, a react-dom/server module export is resolving to the throwing legacy stub instead of the real streaming function during the browser SSR of an authenticated page.
Reproduction (gscmaster, Next 16.1.1 standalone)
request
Perry
Node
GET /de/dashboard with Accept: text/html + browser UA, authenticated
500 + E394/InvariantError flood
200
GET /de/dashboard plain curl (no Accept/UA), authenticated
200 (byte-identical to node)
200
GET /de/dashboard with RSC: 1 (client nav), authenticated
200
200
public pages with browser UA
200
200
So it is specific to the full-HTML SSR path of an authenticated, dynamic = "force-dynamic" page — exactly what a browser does on the post-login redirect. Plain curl and the RSC-only path take a different render route and succeed, which is why a 220-request curl soak passed while a real browser login hangs.
Likely in the react-dom/server (Fizz) HTML-shell render, possibly interacting with the PPR/postpone-resume path (the bundle also carries Invariant: isDynamicPostpone misidentified a postpone reason), cf. the module-export "value form ≠ static form" resolution family (#6667/#6668) and PPR resume #5437.
Next step
Wrap the bundle's E394 stub a() to log a stack when called → identify exactly which react-dom/server export is mis-resolved to the stub, then fix Perry's resolution of that export (probable: the streaming API being read as a value/namespace member and getting the legacy alias).
Observed on main @ 3fd379f, macOS arm64. Workaround unknown; the RSC/plain-fetch path is unaffected.
Also a heisenbug: instrumenting the react-dom-server alias (wrapping the E394 stub + logging t.renderToReadableStream===a) makes the failure disappear (dashboard 200) — the added module strings shift codegen/interning, so the "exports look correct" reading can't be trusted for the failing build. Same signature as #6682 (React Flight symbol serialization), which was in this same family and got fixed forward on main by unrelated symbol/codegen fixes.
So E394 is not a react-dom/server export-resolution bug per se — it's a downstream default-opt (-O3) miscompile in the authenticated full-HTML SSR path that causes the legacy react-dom/server stub to be reached (or a value to mis-resolve to it), layout-dependent. -O0 is the reliable workaround.
Bracketing which sub-6MB -O3 module miscompiles would follow the same threshold-bisect plan as #6682 — but given #6682 self-resolved on main within days via forward fixes, worth re-baselining default-opt on latest main before an expensive bisect.
Observed on main @ 3fd379f, macOS arm64. Workaround: PERRY_LL_O0_THRESHOLD_BYTES=1.
Severity upgrade: E394 poisons global render state → permanent server wedge (reproduces at -O0)
Correcting the earlier "-O0 fixes it" — that was based on sequential single-request curls. Under a realistic concurrent browser session, E394 fires even at -O0, and once it fires it wedges the entire server permanently.
2× RSC prefetches (RSC: 1) of /de/dashboard, /de/settings
2× client API calls /api/dashboard/{summary,insights}
→ E394 fires (×4), then:
GET /de (a public page) → 500, and stays 500 after 5s (does not recover)
The failing public /de emits the sameInvariantError / E394
Only a process restart clears it (fresh server → /de 200)
Mechanism
Once E394 fires during one authenticated streaming render, a global/module-singleton render binding is left poisoned — every subsequent render (including public pages that never touch the dashboard) re-throws E394. The prime suspect is the shared react-dom/server alias export t.renderToReadableStream flipping to the poison stub a process-wide (the comma-sequence member-assignment t.renderToReadableStream=n.renderToReadableStream,t.renderToString=a,t.renderToStaticMarkup=a mis-resolving under the opt-miscompile / a re-entrant module init), after which all renders call the legacy stub.
This turns a per-request opt-miscompile into a full-server-outage resilience bug: one bad render takes down the whole process until restart. That is the user-visible "one tab spins forever, all other tabs → Internal Server Error."
Impact
The app is not usable for authenticated browsing under Perry at any opt level — login succeeds, but the first real browser load of the authenticated dashboard wedges the server. -O0 is NOT a sufficient workaround.
Key narrowing: the wedge is a CONCURRENCY (async-interleaving) bug, not a plain opt-miscompile
Controlled test on the -O0 build (PERRY_LL_O0_THRESHOLD_BYTES=1), authenticated:
Strictly SEQUENTIAL — 50 back-to-back full-HTML /de/dashboard renders (one at a time): 50/50 → 200, zero E394, server stays healthy.
CONCURRENT / interleaved — parallel mixed authed requests (HTML renders + RSC prefetches + /api/dashboard/*): E394 fires, and intermittently the whole server wedges (persistent 500 on every route incl. public /de, until process restart).
Since Perry is single-threaded, "concurrent" = the requests' async renders interleave through await/microtasks. So E394 is triggered by async interleaving, not by opt level per se (-O0 doesn't prevent it). The signature is a per-invocation value shared across interleaved async renders: one render's react-dom/server streaming binding (or a value feeding it) gets clobbered by another interleaved render → the legacy poison stub a is reached → E394; when the clobbered value lands in shared module state, every subsequent render throws → full-server wedge.
This is the async-locals-shared-cell family (#6328/#6345): an async function's locals boxed into one shared cell so two concurrent activations corrupt each other. The prior fixes may be incomplete for the async shape the Next SSR/Flight render uses, or a different container (render context / a captured binding) has the same aliasing.
Repro (reliable-ish; intermittent wedge)
Log in, then 3 rounds of: concurrently GET (browser headers) /de/dashboard, /de/settings, /de/dashboard/settings; RSC:1 GET /de/dashboard, /de/settings; GET /api/dashboard/{summary,insights}. E394 fires within a round or two; the wedge appears within a few concurrent bursts.
Next: debug-symbol -O0 build + lldb on the E394 throw under the concurrent repro to identify the exact shared slot/cell being clobbered across the interleaved renders.
Verification: ca27b6fae (the async-step CURRENT_MICROTASK_PROMISE save/restore) does NOT fix the wedge
Built the fix onto current main (3fd379fae + the 62-line microtasks.rs patch, -O0, auto-opt runtime rebuilt from the patched source — confirmed the fix is linked), and ran a head-to-head against the unfixed build with a now-reliable trigger.
Reliable repro (authenticated mixed-session): log in, then per round concurrently: browser-header GET /de/dashboard/de/settings/de/dashboard/settings; RSC:1 GET /de/dashboard/de/settings; GET /api/dashboard/{summary,insights}. Wedge check = /de 500 persisting across 3× 30s retries (distinguishes a true wedge from transient overload-000).
Result:
Unfixed build: wedged at round 10 (persistent 500, InvariantError/E394).
Fixed (ca27b6fae) build: wedged at round 7 — same persistent 500, same InvariantError/E394.
So the fix is insufficient. This matches the subagent's own late doubt: in the re-entrant async cases, the InvariantError throw is caught by the async function's own try/catch, not the run_microtasks setjmp trap the patch guards — so the mechanism that poisons global render state is elsewhere (candidate: async-step next-promise crossing under re-entrancy, or a different shared-state corruption).
ca27b6fae may still be a valid latent-correctness improvement (it mirrors the Task::Microtask arm's save/restore in the async arms), but it should NOT be treated as the #6710 fix. Reopening the root-cause hunt. Repro remains: authenticated mixed-session wedges within ~10 rounds; public-page-only concurrency is less reliable at moderate load.
Fixed by merged PR #6735. The root cause was recycled HTTP handle IDs retaining per-request string/symbol/descriptor side-table state, which let an HTML request inherit NextInternalRequestMeta from an earlier RSC request. The fix clears request, response, HTTP/2 stream, and upgrade-handle side tables before JS observes them; the PR validated the mixed RSC/HTML concurrency repro without a wedge. Revalidated the six handle-expando cleanup tests on current main; all pass. Closing as fixed.
Activity
proggeramlug commentedon Jul 20, 2026
Confirmed: opt-miscompile family (#6546/#6682);
-O0renders correctlySame source/runtime, only clang opt level differs:
/de/dashboard, browser HTML render (Accept: text/html+ UA)InvariantError, legacy react-dom/server stub called); stream never closes → browser hangsPERRY_LL_O0_THRESHOLD_BYTES=1(all modules-Os/-O0)Also a heisenbug: instrumenting the react-dom-server alias (wrapping the E394 stub + logging
t.renderToReadableStream===a) makes the failure disappear (dashboard 200) — the added module strings shift codegen/interning, so the "exports look correct" reading can't be trusted for the failing build. Same signature as #6682 (React Flight symbol serialization), which was in this same family and got fixed forward on main by unrelated symbol/codegen fixes.So E394 is not a react-dom/server export-resolution bug per se — it's a downstream default-opt (
-O3) miscompile in the authenticated full-HTML SSR path that causes the legacy react-dom/server stub to be reached (or a value to mis-resolve to it), layout-dependent.-O0is the reliable workaround.Bracketing which sub-6MB
-O3module miscompiles would follow the same threshold-bisect plan as #6682 — but given #6682 self-resolved on main within days via forward fixes, worth re-baselining default-opt on latest main before an expensive bisect.Observed on main @ 3fd379f, macOS arm64. Workaround:
PERRY_LL_O0_THRESHOLD_BYTES=1.proggeramlug commentedon Jul 20, 2026
Severity upgrade: E394 poisons global render state → permanent server wedge (reproduces at -O0)
Correcting the earlier "-O0 fixes it" — that was based on sequential single-request curls. Under a realistic concurrent browser session, E394 fires even at
-O0, and once it fires it wedges the entire server permanently.Repro (O0 build,
PERRY_LL_O0_THRESHOLD_BYTES=1, authenticated)3 rounds of, per round, concurrent:
Accept: text/html+ UA) of/de/dashboard,/de/settings,/de/dashboard/settingsRSC: 1) of/de/dashboard,/de/settings/api/dashboard/{summary,insights}→ E394 fires (×4), then:
GET /de(a public page) → 500, and stays 500 after 5s (does not recover)/deemits the sameInvariantError/E394/de200)Mechanism
Once E394 fires during one authenticated streaming render, a global/module-singleton render binding is left poisoned — every subsequent render (including public pages that never touch the dashboard) re-throws E394. The prime suspect is the shared
react-dom/serveralias exportt.renderToReadableStreamflipping to the poison stubaprocess-wide (the comma-sequence member-assignmentt.renderToReadableStream=n.renderToReadableStream,t.renderToString=a,t.renderToStaticMarkup=amis-resolving under the opt-miscompile / a re-entrant module init), after which all renders call the legacy stub.This turns a per-request opt-miscompile into a full-server-outage resilience bug: one bad render takes down the whole process until restart. That is the user-visible "one tab spins forever, all other tabs → Internal Server Error."
Impact
The app is not usable for authenticated browsing under Perry at any opt level — login succeeds, but the first real browser load of the authenticated dashboard wedges the server.
-O0is NOT a sufficient workaround.Observed on main @ 3fd379f, macOS arm64.
proggeramlug commentedon Jul 20, 2026
Key narrowing: the wedge is a CONCURRENCY (async-interleaving) bug, not a plain opt-miscompile
Controlled test on the
-O0build (PERRY_LL_O0_THRESHOLD_BYTES=1), authenticated:/de/dashboardrenders (one at a time): 50/50 → 200, zero E394, server stays healthy./api/dashboard/*): E394 fires, and intermittently the whole server wedges (persistent 500 on every route incl. public/de, until process restart).Since Perry is single-threaded, "concurrent" = the requests' async renders interleave through
await/microtasks. So E394 is triggered by async interleaving, not by opt level per se (-O0doesn't prevent it). The signature is a per-invocation value shared across interleaved async renders: one render'sreact-dom/serverstreaming binding (or a value feeding it) gets clobbered by another interleaved render → the legacy poison stubais reached → E394; when the clobbered value lands in shared module state, every subsequent render throws → full-server wedge.This is the async-locals-shared-cell family (#6328/#6345): an async function's locals boxed into one shared cell so two concurrent activations corrupt each other. The prior fixes may be incomplete for the async shape the Next SSR/Flight render uses, or a different container (render context / a captured binding) has the same aliasing.
Repro (reliable-ish; intermittent wedge)
Log in, then 3 rounds of: concurrently GET (browser headers)
/de/dashboard,/de/settings,/de/dashboard/settings;RSC:1GET/de/dashboard,/de/settings; GET/api/dashboard/{summary,insights}. E394 fires within a round or two; the wedge appears within a few concurrent bursts.Next: debug-symbol
-O0build + lldb on the E394 throw under the concurrent repro to identify the exact shared slot/cell being clobbered across the interleaved renders.Observed on main @ 3fd379f, macOS arm64.
proggeramlug commentedon Jul 20, 2026
Verification:
ca27b6fae(the async-step CURRENT_MICROTASK_PROMISE save/restore) does NOT fix the wedgeBuilt the fix onto current main (
3fd379fae+ the 62-linemicrotasks.rspatch,-O0, auto-opt runtime rebuilt from the patched source — confirmed the fix is linked), and ran a head-to-head against the unfixed build with a now-reliable trigger.Reliable repro (authenticated mixed-session): log in, then per round concurrently: browser-header GET
/de/dashboard/de/settings/de/dashboard/settings;RSC:1GET/de/dashboard/de/settings; GET/api/dashboard/{summary,insights}. Wedge check =/de500 persisting across 3× 30s retries (distinguishes a true wedge from transient overload-000).Result:
InvariantError/E394).ca27b6fae) build: wedged at round 7 — same persistent 500, sameInvariantError/E394.So the fix is insufficient. This matches the subagent's own late doubt: in the re-entrant async cases, the
InvariantErrorthrow is caught by the async function's own try/catch, not therun_microtaskssetjmp trap the patch guards — so the mechanism that poisons global render state is elsewhere (candidate: async-stepnext-promise crossing under re-entrancy, or a different shared-state corruption).ca27b6faemay still be a valid latent-correctness improvement (it mirrors theTask::Microtaskarm's save/restore in the async arms), but it should NOT be treated as the #6710 fix. Reopening the root-cause hunt. Repro remains: authenticated mixed-session wedges within ~10 rounds; public-page-only concurrency is less reliable at moderate load.Observed on main @ 3fd379f, macOS arm64.
fix(http): address review — h2 stream + upgrade handles, O(1) descrip…
fix(http): clear per-handle JS side tables on handle-id recycle (#6710)…
proggeramlug commentedon Jul 30, 2026
Fixed by merged PR #6735. The root cause was recycled HTTP handle IDs retaining per-request string/symbol/descriptor side-table state, which let an HTML request inherit NextInternalRequestMeta from an earlier RSC request. The fix clears request, response, HTTP/2 stream, and upgrade-handle side tables before JS observes them; the PR validated the mixed RSC/HTML concurrency repro without a wedge. Revalidated the six handle-expando cleanup tests on current main; all pass. Closing as fixed.
perf(runtime): decide the URLSearchParams backing probe from the GC h…