Repository navigation
[Next.js/dylib] Preserve returned NextResponse headers, cookies, and stream across module boundaries #8038
Description
What happened?
The current Perry/Perch Next.js adapter can execute NextResponse.json(...) inside an imported route, but it cannot consume the returned Response reliably. It therefore discards the real return value and fabricates deterministic response bytes in the caller. That makes the RSS/CPU result a lower bound rather than an apples-to-apples Next.js result.
Closed work such as #237 and #1650 established the Web Streams / Response.body surface. This issue is the remaining composed case: a NextResponse returned through an imported async App Route boundary, then inspected and drained by the app-library entry point.
Reproduction app (provided)
Use the exact pinned app and verifier attached to #8034. Do not replace the streamed response with NextResponse.json() or a hard-coded Buffer.
The supplied route returns:
- status
207; content-type: application/json; charset=utf-8;x-perry-repro: <request id>;Set-Cookie: perry_ctx=<request id>; ...viaNextResponse.cookies;- a
ReadableStream<Uint8Array>that emits the JSON in two chunks, with the second chunk queued in a microtask.
Focused oracle:
BASE_URL=http://127.0.0.1:3100 npm run verifyRequired implementation behavior
For both Response and the NextResponse subclass:
- preserve the native response identity/brand across a cross-module return and an async
Promise<Response>return; - preserve status, status text, headers, and cookie mutations;
- preserve
body: ReadableStream | null, including the queued second chunk and close signal; - keep the response, Headers backing, stream handle, controller, queued chunks, and reader rooted for the full drain;
- make repeated property reads and body reads resolve the same backing record;
- surface a thrown/rejected stream error instead of hanging or returning an empty body.
The host ABI can serialize the final response after Perry has exposed these fields; that serialization is not a reason to fabricate a different response inside the test.
Regression coverage
Add a two-module minimal fixture in which module A returns a streamed Response both synchronously and asynchronously and module B validates status/headers/cookies and drains body.getReader(). Then keep #8034 as the production Next subclass/composition test.
Run both tests in executable mode and app-only dylib mode against the shared runtime/stdlib providers; this catches native-handle registries accidentally duplicated or hidden across images.
Acceptance criteria
- [Next.js/dylib] Add a pinned production App Route parity fixture and CI gate #8034's verifier receives exact status, response header, cookie, and JSON for all 21 requests.
- Both stream chunks are consumed and EOF is observed; an empty, truncated, buffered substitute does not pass the minimal stream test.
- Sync and async cross-module returns pass with normal and forced/verified GC.
- App-only dylib mode has the same result as a classic Perry executable.
- The fixture contains no hard-coded status/body/headers and no ignored
GET(...)return value.
Baseline / references
- Observed baseline: Perry
0.5.1503at564c56308d221a51b50308d9165578fbb176e877 - Reproduction app: [Next.js/dylib] Add a pinned production App Route parity fixture and CI gate #8034
- Web Fetch / Streams groundwork: Web Streams API: implement ReadableStream + blob.stream() / response.body #237, Web Fetch: Response.body is a numeric handle, not a ReadableStream | null #1650, globals: Fetch Request/Response body and metadata parity cut #3406
Activity
proggeramlug commentedon Aug 16, 2026
Closing — every item here has landed on
mainand is gated:Fix — #8045 (merged 2026-08-13):
Response/Response-subclass native handles,Headersidentity + mutations, and body streams are preserved across sync andPromise<Response>module boundaries; stream roots and runtime-owned allocations go through the shared provider ABI; GC maps are loaded from app dylibs.Two-module minimal fixture —
test-files/test_issue_8038_cross_module_response_stream.ts+test-files/_helpers/issue_8038_response_producer.ts(#8045). Module A returns a streamedResponsesynchronously, asynchronously, as aResponsesubclass (NextLikeResponse), and as an errored stream; module B checks brand, status/statusText, header +set-cookievalues,headers/bodyidentity stability across collections, drainsbody.getReader(), and assertschunks=2 eof=trueanderror:surfaced=Error: stream-boom. Runs underPERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_SCHEDULE_SEED=8038 PERRY_GC_SCHEDULE_RATE=1(parity-env header). No hard-coded status/body/headers, no ignored return values.App-only dylib == executable — #8089 (merged 2026-08-14):
scripts/gc_provider_dylib_gate.shcompiles the same fixture as an app-only dylib against separately loaded runtime + stdlib providers, roots the exported Promise in a native host, anddiffs both the normal and forced/verified-GC arms against the same expected file the executable oracle uses, with a positive evacuation-liveness assert. It runs ingc-native-roots.yml(PR + every 6 h). Latestmainrun (31933223109, 2026-08-16 07:32 UTC):#8038 provider-dylib Response: evacuation live — 147 copying minor(s), 32194 objects copied, exact output.#8034 production route — #8082 (merged): the pinned fixture (
tests/fixtures/next-app-route/,tests/release/packages/next-app-route/) serves 21/21 requests with exact status/header/cookie/JSON, cold + warm, through Next's realhandler→routeModule.handle(bypass guard armed), returning a real streamedNextResponsewithcookies.set(...)— nothing fabricated in the caller. #8034 is closed.Not covered here, tracked separately: the forced-evacuation arm of the production route (a stale closure from a holder outside the GC heap) is #8163. The Perch
benchmarks/next-smalladapter that discardsGET()'s return value lives in the Perch repo and can now be switched to consuming the real response.