Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .github/workflows/next-app-route.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: Next App Route dylib

# NOT a per-PR gate yet, by design. The pinned production graph is 104 modules
# and one generated chunk alone has taken ~112 min in a single codegen unit, so a
# `pull_request` trigger on `crates/**` would attach a multi-hour job to nearly
# every PR. It also cannot pass yet: the computed relative chunk require is still
# in flight (#8146). Run it on demand and nightly until it is green on `main`,
# then promote it — a gate that has never been green must not be made required
# (CLAUDE.md, "a *new* gate has never been green").
on:
workflow_dispatch:
schedule:
# 03:17 UTC daily, off the hour to avoid the Actions scheduling spike.
- cron: "17 3 * * *"

permissions:
contents: read

concurrency:
# `github.run_id` makes every run its own group. A group that is CONSTANT
# across scheduled runs lets GitHub keep at most one pending run and cancel
# the rest with zero jobs, so only one scheduled run would ever execute
# (#7205, relapsed as #7966) — and a cancelled run is a gate that did not run
# (CLAUDE.md, "four ways a gate can be unable to fail", #3).
group: next-app-route-${{ github.run_id }}
cancel-in-progress: false

jobs:
next-app-route:
runs-on: ubuntu-latest
timeout-minutes: 180
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_CACHE_SIZE: "4G"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false

- uses: dtolnay/rust-toolchain@stable
- uses: ./.github/actions/setup-llvm22

- uses: mozilla-actions/sccache-action@v0.0.11
- uses: Swatinem/rust-cache@v2
with:
shared-key: "${{ runner.os }}-perry"
save-if: ${{ github.ref == 'refs/heads/main' }}

- uses: actions/setup-node@v7
with:
node-version-file: .node-version
cache: npm
cache-dependency-path: tests/fixtures/next-app-route/package-lock.json

- name: Build Perry and provider archives
run: >-
cargo build --profile perry-dev
-p perry
-p perry-runtime
-p perry-stdlib
-p perry-runtime-static
-p perry-stdlib-static

- name: Focused four-store continuation parity
env:
PERRY_BIN: target/perry-dev/perry
PERRY_RUNTIME_DIR: target/perry-dev
PERRY_SKIP_BUILD: "1"
run: >-
./run_parity_tests.sh
--suite node-suite
--module async_hooks
--filter next-route-continuations

- name: Production App Route provider gate
run: tests/test_next_app_route_dylib.sh
25 changes: 25 additions & 0 deletions changelog.d/8161-next-app-route-fixture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
### Testing

- Add the pinned Next.js 16.3.0 / React 19.2.4 production App Route fixture and
its 21-request verifier as real, checked-in files (`tests/fixtures/next-app-route`),
plus a Node oracle test that rebuilds the app from its lockfile and proves the
generated bundle exports `AppRouteRouteModule.handle` for `/api/benchmark`
before any Perry-specific assertion runs (#8034).

- Add `tests/test_next_app_route_dylib.sh`: it compiles that unmodified
production route to an **app-only dylib**, links it against separately built
runtime and stdlib/HTTP provider images, checks every undefined `js_`/`perry_`
symbol the app needs is exported by those providers, and then drives 10 cold
starts x 10 verifier repetitions (the 100 repetitions #8037 asks for).

The host installs a wrapper around `routeModule.handle` and fails any request
that reaches the generated handler without passing through it, so the route
cannot be "passed" by a compatibility path that calls the userland `GET`
directly. Because that assertion runs inside a `.then()` after the response is
already sent, the verifier's exit code cannot carry it — the gate greps the
host log for the diagnostic instead.

- Add the `Next App Route dylib` workflow on `workflow_dispatch` + a nightly
schedule. It is deliberately **not** a per-PR required gate yet: the graph is
104 modules, one generated chunk has taken ~112 min in a single codegen unit,
and the route still needs the computed relative chunk require from #8146.
3 changes: 3 additions & 0 deletions tests/fixtures/next-app-route/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/.next/
/node_modules/
/provider/Cargo.lock
38 changes: 38 additions & 0 deletions tests/fixtures/next-app-route/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Production Next App Route fixture

This is the pinned fixture from #8034. Its application and verifier sources are
copied verbatim from the issue:

- Next.js 16.3.0, built with `next build --webpack`
- React and React DOM 19.2.4
- twenty concurrent GET requests plus one POST request
- the generated `routeModule.handle` / `AppRouteRouteModule.handle` path
- request-local `headers()` reads across a dynamic import and a timer
- a two-chunk streamed `NextResponse` with status, header, and cookie checks

`package-lock.json` is committed so a gate cannot silently move the framework
graph. Generated `.next/` output and `node_modules/` are deliberately ignored.

Run the Node oracle and generated-route structural check with:

```sh
tests/test_next_app_route_node_oracle.sh
```

The Node check is only the oracle half of #8034. It does not claim Perry
acceptance. The Perry half compiles the production webpack output as an
app-only dylib, loads separate runtime and stdlib provider images before the app
with eager relocation, and builds both providers from one unified Cargo graph
so all runtime bindings share one image. It enters the generated route-module
handle path and runs the same `verify.mjs` without a direct `GET` call or
fabricated response.

Run that integration gate with:

```sh
PERRY_BIN=target/perry-dev/perry tests/test_next_app_route_dylib.sh
```

By default it performs ten cold provider-host starts and ten verifier passes
per start (100 total). `PERRY_NEXT_COLD_STARTS` and
`PERRY_NEXT_VERIFICATIONS_PER_START` can reduce the count for local iteration.
2 changes: 2 additions & 0 deletions tests/fixtures/next-app-route/app/api/benchmark/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const dynamic = "force-dynamic";
export { GET, POST } from "../../../lib/route-impl";
3 changes: 3 additions & 0 deletions tests/fixtures/next-app-route/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return <html><body>{children}</body></html>;
}
3 changes: 3 additions & 0 deletions tests/fixtures/next-app-route/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <main>Perry Next.js App Route fixture</main>;
}
7 changes: 7 additions & 0 deletions tests/fixtures/next-app-route/lib/lazy-work.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function checksum(iterations: number): number {
let value = 0x811c9dc5;
for (let index = 0; index < iterations; index += 1) {
value = Math.imul(value ^ index, 0x01000193) >>> 0;
}
return value;
}
52 changes: 52 additions & 0 deletions tests/fixtures/next-app-route/lib/route-impl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";

async function handle(request: NextRequest): Promise<NextResponse> {
const id = request.nextUrl.searchParams.get("id") ?? "missing";
const requestedIterations = Number(request.nextUrl.searchParams.get("iterations") ?? "100");
const iterations = Number.isInteger(requestedIterations)
? Math.max(1, Math.min(1_000, requestedIterations))
: 100;

const beforeAwait = (await headers()).get("x-request-id");
const { checksum } = await import("./lazy-work");
await new Promise<void>((resolve) => setTimeout(resolve, 1));
const afterAwait = (await headers()).get("x-request-id");
const requestBody = request.method === "POST" ? await request.text() : "";

const payload = JSON.stringify({
runtime: "next",
method: request.method,
pathname: request.nextUrl.pathname,
id,
iterations,
checksum: checksum(iterations),
beforeAwait,
afterAwait,
requestBody,
});
const bytes = new TextEncoder().encode(payload);
const split = Math.max(1, Math.floor(bytes.length / 2));
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes.subarray(0, split));
queueMicrotask(() => {
controller.enqueue(bytes.subarray(split));
controller.close();
});
},
});

const response = new NextResponse(stream, {
status: 207,
headers: {
"content-type": "application/json; charset=utf-8",
"x-perry-repro": id,
},
});
response.cookies.set("perry_ctx", id, { httpOnly: true, sameSite: "strict" });
return response;
}

export const GET = handle;
export const POST = handle;
7 changes: 7 additions & 0 deletions tests/fixtures/next-app-route/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
7 changes: 7 additions & 0 deletions tests/fixtures/next-app-route/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
output: "standalone",
};

export default nextConfig;
Loading
Loading