Skip to content

[Next.js/dylib] Preserve request/work AsyncLocalStorage through concurrent App Routes #8037

Closed

Description

@proggeramlug

Summary

Make the AsyncLocalStorage subset used by Next.js App Routes correct under the real AppRouteRouteModule.handle path, including concurrent requests and continuations. This is a scoped Next.js acceptance cut of the broader node:async_hooks backlog in #6764, not a duplicate request to complete all 113 parity failures.

The current lower-bound adapter calls the userland GET directly because Perry cannot yet rely on Next's private request/work-store machinery. That bypass must disappear before Perry can claim it runs Next.js.

Reproduction app (provided)

Use the exact pinned app and 21-request verifier attached to #8034. Do not invent a new context test.

The supplied handler reads Next's public headers() API before and after both:

  1. a dynamic import() continuation; and
  2. a setTimeout(..., 1) continuation.

The verifier launches 20 requests concurrently, each with a distinct x-request-id, and requires both reads to equal that request's ID. It then sends a POST as an additional lifecycle check.

BASE_URL=http://127.0.0.1:3100 npm run verify

Required implementation behavior

  • Enter through the generated production routeModule.handle; calling GET directly is invalid because it bypasses the stores being tested.
  • Support the AsyncLocalStorage instances Next wires for action, request, work, and work-unit state.
  • Preserve each store through direct promises, async/await, dynamic import initialization, microtasks, timers, stream callbacks, and route completion.
  • Restore the prior store after nested run() / exit() and after throws or rejections.
  • Clear request-owned state after the response completes; do not retain a request or leak its store into the next call.
  • Isolate concurrent requests even when they share one process, runtime provider, stdlib provider, and event loop.

Regression coverage

Add one focused parity fixture mirroring #8034's continuation sequence, then make the production fixture a dylib integration gate. The focused test should include nested stores, a rejected branch, and two interleaved IDs so failures localize below Next.js.

Update/check off the matching propagation/provider cases in #6764 as they become covered; this issue is complete when the Next route cut passes even if unrelated async_hooks provider tests remain.

Acceptance criteria

  • [Next.js/dylib] Add a pinned production App Route parity fixture and CI gate #8034's beforeAwait and afterAwait fields equal the correct request ID in all 20 concurrent responses.
  • 100 repetitions show zero cross-request contamination, missing store values, hangs, or retained requests.
  • A rejected request does not poison the next request's store.
  • The production test invokes AppRouteRouteModule.handle and contains no direct-handler compatibility path.
  • Relevant focused tests pass in executable and app-only dylib modes.

Baseline / references

Activity

  1. added
    bugConfirmed defect or regression
    parityCompatibility gap with Node.js, ECMAScript, or the supported ecosystem
    on Aug 13, 2026
  2. proggeramlug commented on Aug 16, 2026

    @proggeramlug
    ContributorAuthor

    Status against the production route on main 3c95020f8 (2026-08-16, details in the #8040 comment of today): across 500 batches of the 20-concurrent-request verifier (each request reads Next's ALS-backed headers() before and after a dynamic-import and a timer continuation), beforeAwait/afterAwait were never wrong and never missing — zero cross-request contamination, zero hangs. What did fail (8 batches) is a lost response after a default-mode copying minor: #8163's stale closure, not an ALS defect.

    So the ALS cut itself holds up (focused four-store fixture from #8042 + the production route through the armed routeModule.handle guard, 0 bypasses in 520 batches). The one bullet this issue cannot claim yet is "100 repetitions … zero retained requests": RSS climbs 160 → ~600 MB and heap-in-use 5 → 40 MB over 100 batches with no plateau, which reads as retention. Leaving open pending #8163 and that retention question; if you consider retention out of this issue's scope, this can close on the evidence above.

  3. proggeramlug commented on Aug 17, 2026

    @proggeramlug
    ContributorAuthor

    Closing: every acceptance criterion here is met, and the one loose thread has its own issue and owner.

    Fix: #8042 (merged) moved the AsyncLocalStorage context ops behind a C ABI owned by the runtime provider, keeping run()/exit() throw-safe restoration in the runtime image so promise, timer, microtask, dynamic-import and stream schedulers all snapshot the same ACTIVE_CONTEXT.

    Focused coverage: test-parity/node-suite/async_hooks/propagation/next-route-continuations.ts (#8042) — action/request/work/work-unit stores, interleaved IDs, nested throw/rejection, post-rejection reuse, lifecycle cleanup. Validated in both executable and app-only dylib modes, 100/100 executable repetitions against the Node oracle. It is a step in next-app-route.yml.

    Production coverage against the pinned #8034 fixture, where verify.mjs asserts beforeAwait/afterAwait — Next's ALS-backed headers() read either side of a dynamic-import continuation and a setTimeout continuation — equal the request's own ID, for all 20 concurrent requests plus a POST:

    run backend passes ALS field errors bypass-guard fires
    text path, 695daf302b INPROCESS=off 500 (10,500 requests) 0 0
    native path, 183d30c53a default 310 (6,510 requests) 0 0

    Against the acceptance criteria as written:

    • ✅ beforeAwait/afterAwait correct in all 20 concurrent responses — across 810 passes on two backends.
    • ✅ 100 repetitions, zero cross-request contamination, zero missing store values, zero hangs — exceeded by 8× (a single 300-pass warm process, plus 10 cold × 10 twice).
    • ✅ A rejected request does not poison the next request's store — covered by the focused fixture's nested throw/rejection and post-rejection reuse cases.
    • ✅ The production test enters AppRouteRouteModule.handle with no direct-handler path: the bypass guard is armed in both perry-host.js copies and fired 0 times in every run.
    • ✅ Focused tests pass in executable and app-only dylib modes.

    The one criterion I am explicitly not claiming, and why it does not block this issue: "zero retained requests". The warm process does retain — post_in_use 5.2 → 130.3 MB and RSS ~882 MB over 300 passes. That is real, but it is not an async-context defect: it was root-caused in #8213 to ~1,100 async box cells per request plus (before #8219) an append-only proxy registry, neither of which is ALS state. No store leaked, no request's context outlived it, and no context was ever observed on the wrong request. Retention is tracked in #8213 with a named mechanism and an owner; leaving this issue open for it would attribute a footprint defect to the async-context subsystem.

    Also worth recording: the earlier concern that the lower-bound adapter had to call userland GET directly is gone — every number above went through Next's real routeModule.handle, proven by the guard rather than asserted.

    Related work that landed alongside: #8220 (the GC rooting defect that made requests intermittently lose their response — not an ALS bug, but it was the reason the production path could not be trusted end to end), #8209, #8210, #8215, #8241, #8274.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regressionparityCompatibility gap with Node.js, ECMAScript, or the supported ecosystem

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions