From 734ccb8a0faecd5bb848365d597f6cfe5f976044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 16 Aug 2026 15:38:59 +0200 Subject: [PATCH 1/5] test(next): arm the routeModule.handle guard and make verifier passes per cold start a knob (default 10) in the release App Route fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the armed perry-host.js from tests/fixtures/next-app-route (byte-identical) into tests/release/packages/next-app-route and grep every cold-start log for 'generated handler bypassed' as a hard failure — the guard's only signal is the host log, verify.mjs exits 0 when it fires. Replace the hard-coded two verifier runs per cold start with PERRY_NEXT_ROUTE_VERIFIERS_PER_START (default 10), so the default run is 10 cold starts x 10 passes = 100 batches, matching #8040's 100-iteration bullet and tests/test_next_app_route_dylib.sh. With the default this fixture is red on today's main because of #8163; =2 recovers the previous coverage. --- .../packages/next-app-route/fixture.sh | 51 +++++++++++++++---- .../packages/next-app-route/perry-host.js | 24 +++++++-- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/tests/release/packages/next-app-route/fixture.sh b/tests/release/packages/next-app-route/fixture.sh index 7657af3e91..33e3eaf6b3 100755 --- a/tests/release/packages/next-app-route/fixture.sh +++ b/tests/release/packages/next-app-route/fixture.sh @@ -4,8 +4,25 @@ # What this asserts, on every run: Next 16.3.0's UNTOUCHED production webpack # output compiles to an app-only dylib against separately loaded runtime and # stdlib provider images, serves through a `dlopen` host, and matches the Node -# production oracle byte-for-byte across 10 cold starts of two 21-request -# verifier passes each. +# production oracle byte-for-byte across 10 cold starts of +# `PERRY_NEXT_ROUTE_VERIFIERS_PER_START` (default 10) 21-request verifier +# passes each — 100 batches by default, #8040's "100-iteration run of the +# 20-way concurrent request batch". Every request must also enter the generated +# `AppRouteRouteModule.handle`: `perry-host.js` wraps it and logs +# `generated handler bypassed routeModule.handle` for any request that reached +# the userland handler another way, and each cold-start log is grepped for that +# line as a hard failure. That signal lives ONLY in the host log — the guard +# throws inside a `.then()` after the response is already sent, so +# `verify.mjs` still exits 0 when it fires (#8161). +# +# Known state (#8163): with the default 10 verifier passes per process this +# fixture is RED on today's `main` — a default-mode copying minor occasionally +# strands a stale closure, and ~2% of warm batches then lose one response +# (`TypeError: value is not a function` in the host log right after a +# `[gc-copy-minor] ran` line, then an empty body in `verify.mjs`). Two passes +# per process never reached a copying minor, which is how the fixture read +# green while #8040's 100-iteration bullet was red. Set +# `PERRY_NEXT_ROUTE_VERIFIERS_PER_START=2` to recover the old coverage. # # Odd cold starts run under FORCED evacuation with a seeded GC schedule and the # moving-GC liveness assert (#8163 — fixed; `PERRY_NEXT_ROUTE_FORCED_GC=0` @@ -18,6 +35,7 @@ REPO_ROOT="$(cd ../../../.. && pwd)" PERRY_BIN="${PERRY_BIN:-$REPO_ROOT/target/release/perry}" PORT_BASE="${PERRY_NEXT_ROUTE_PORT:-31836}" COLD_STARTS="${PERRY_NEXT_ROUTE_COLD_STARTS:-10}" +VERIFIERS_PER_START="${PERRY_NEXT_ROUTE_VERIFIERS_PER_START:-10}" if [[ -n "${PERRY_NEXT_ROUTE_BUILD_DIR:-}" ]]; then BUILD_DIR="$PERRY_NEXT_ROUTE_BUILD_DIR" BUILD_DIR_OWNED=0 @@ -60,6 +78,14 @@ fail() { for tool in npm node cargo cc ar nm python3; do command -v "$tool" >/dev/null 2>&1 || fail "$tool is not on PATH" done +for value in "$COLD_STARTS" "$VERIFIERS_PER_START"; do + [[ "$value" =~ ^[1-9][0-9]*$ ]] || fail "cold starts and verifiers per start must be positive integers (got '$value')" +done +TOTAL_BATCHES=$((COLD_STARTS * VERIFIERS_PER_START)) +# `generated handler bypassed` is the routeModule.handle guard in perry-host.js. +# It is a log line, not an exit code: verify.mjs passes even when it fires, so +# the per-cold-start grep below is the only place the guard can fail the run. +FORBIDDEN_DIAGNOSTICS='generated handler bypassed|\[perry-gc\].*SKIPPED|unsettled-await|unimplemented|compatibility[- ]fallback' [[ -x "$PERRY_BIN" ]] || fail "perry not found at $PERRY_BIN" case "$(uname -s)" in @@ -174,14 +200,19 @@ run_cold_start() { done [[ "$ready" == "1" ]] || fail "$mode cold start $index did not become ready" - BASE_URL="http://127.0.0.1:$port" node verify.mjs >>"$log" 2>&1 || fail "$mode cold verifier 1 failed" - BASE_URL="http://127.0.0.1:$port" node verify.mjs >>"$log" 2>&1 || fail "$mode warm verifier 2 failed" + local verifier label + for verifier in $(seq 1 "$VERIFIERS_PER_START"); do + if (( verifier == 1 )); then label="cold"; else label="warm"; fi + BASE_URL="http://127.0.0.1:$port" node verify.mjs >>"$log" 2>&1 \ + || fail "$mode cold start $index: $label verifier $verifier/$VERIFIERS_PER_START failed" + done if [[ "$mode" == "forced" ]]; then python3 "$REPO_ROOT/scripts/gc_evacuation_liveness_assert.py" \ "$log" --probe "$NAME-$mode-$index" \ || fail "$mode cold start $index did not prove moving-GC liveness" fi - if grep -Eiq '\[perry-gc\].*SKIPPED|unsettled-await|unimplemented|compatibility[- ]fallback' "$log"; then + if grep -Eiq "$FORBIDDEN_DIAGNOSTICS" "$log"; then + grep -Ein "$FORBIDDEN_DIAGNOSTICS" "$log" | head -20 | sed 's/^/ /' fail "$mode cold start $index emitted a forbidden fallback diagnostic" fi cleanup_server @@ -196,9 +227,9 @@ run_cold_start() { # the normal arm only; it is a knob, not a skip, and never `continue-on-error`. FORCED_GC="${PERRY_NEXT_ROUTE_FORCED_GC:-1}" if [[ "$FORCED_GC" == "1" ]]; then - echo " [6/7] $COLD_STARTS cold processes (alternating normal / FORCED-evacuation), two 21-request verifier runs each" + echo " [6/7] $COLD_STARTS cold processes (alternating normal / FORCED-evacuation), $VERIFIERS_PER_START 21-request verifier runs each ($TOTAL_BATCHES batches)" else - echo " [6/7] $COLD_STARTS cold processes, two 21-request verifier runs each" + echo " [6/7] $COLD_STARTS cold processes, $VERIFIERS_PER_START 21-request verifier runs each ($TOTAL_BATCHES batches)" echo " forced-evacuation arm OFF (PERRY_NEXT_ROUTE_FORCED_GC=0)" fi for index in $(seq 0 $((COLD_STARTS - 1))); do @@ -206,9 +237,9 @@ for index in $(seq 0 $((COLD_STARTS - 1))); do run_cold_start "$index" "$mode" done -echo " [7/7] production AppRouteRouteModule.handle parity complete" +echo " [7/7] production AppRouteRouteModule.handle parity complete: $TOTAL_BATCHES verifier batches over $COLD_STARTS cold starts, 0 bypass-guard fires" if [[ "$FORCED_GC" == "1" ]]; then - echo "PASS $NAME (with forced-evacuation arm)" + echo "PASS $NAME ($TOTAL_BATCHES batches, with forced-evacuation arm)" else - echo "PASS $NAME (forced-evacuation arm not run — PERRY_NEXT_ROUTE_FORCED_GC=0)" + echo "PASS $NAME ($TOTAL_BATCHES batches, forced-evacuation arm not run — PERRY_NEXT_ROUTE_FORCED_GC=0)" fi diff --git a/tests/release/packages/next-app-route/perry-host.js b/tests/release/packages/next-app-route/perry-host.js index 2de3bc2c06..3e3330c264 100644 --- a/tests/release/packages/next-app-route/perry-host.js +++ b/tests/release/packages/next-app-route/perry-host.js @@ -8,6 +8,13 @@ if (typeof routeModule.handle !== "function" || typeof handler !== "function") { throw new Error("production App Route handler exports are missing"); } +const enteredRequestIds = new Set(); +const routeModuleHandle = routeModule.handle.bind(routeModule); +routeModule.handle = async (request, context) => { + enteredRequestIds.add(request.nextUrl.searchParams.get("id") ?? "missing"); + return routeModuleHandle(request, context); +}; + const pending = new Set(); const port = Number(process.env.PORT ?? "3100"); const hostname = process.env.HOSTNAME ?? "127.0.0.1"; @@ -19,11 +26,18 @@ const server = createServer((request, response) => { promise.finally(() => pending.delete(promise)); }, }); - work.catch((error) => { - console.error(error); - if (!response.headersSent) response.statusCode = 500; - response.end(); - }); + work + .then(() => { + const id = new URL(request.url, `http://${hostname}:${port}`).searchParams.get("id") ?? "missing"; + if (!enteredRequestIds.delete(id)) { + throw new Error(`${id}: generated handler bypassed routeModule.handle`); + } + }) + .catch((error) => { + console.error(error); + if (!response.headersSent) response.statusCode = 500; + response.end(); + }); }); server.listen(port, hostname, () => { From bf987ef79ea0f53bb9b36698455ff9811988221a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 16 Aug 2026 15:40:12 +0200 Subject: [PATCH 2/5] changelog: fragment for #8210 --- .../8210-release-next-fixture-guard-knob.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 changelog.d/8210-release-next-fixture-guard-knob.md diff --git a/changelog.d/8210-release-next-fixture-guard-knob.md b/changelog.d/8210-release-next-fixture-guard-knob.md new file mode 100644 index 0000000000..6bd3f1b793 --- /dev/null +++ b/changelog.d/8210-release-next-fixture-guard-knob.md @@ -0,0 +1,14 @@ +### Testing + +- `tests/release/packages/next-app-route/fixture.sh` now runs the armed + `routeModule.handle` bypass guard (its `perry-host.js` is byte-identical to + `tests/fixtures/next-app-route/perry-host.js`) and greps every cold-start log + for `generated handler bypassed` as a hard failure — the guard's only signal is + the host log; `verify.mjs` exits 0 when it fires (#8161). +- The two hard-coded verifier passes per cold start became + `PERRY_NEXT_ROUTE_VERIFIERS_PER_START` (default 10), so the default run is + 10 cold starts × 10 passes = 100 batches, matching + `tests/test_next_app_route_dylib.sh` and #8040's 100-iteration bullet. With + the default this fixture is red on today's `main` because of #8163 (~2% of + warm batches lose one response after a default-mode copying minor); `=2` + restores the previous coverage. The forced-evacuation arm is unchanged. From 2f27d650776461a975c454ae86e2b92579e1726d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 16 Aug 2026 15:44:26 +0200 Subject: [PATCH 3/5] test(next): show the cold-start log tail when a verifier pass fails --- tests/release/packages/next-app-route/fixture.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/release/packages/next-app-route/fixture.sh b/tests/release/packages/next-app-route/fixture.sh index 33e3eaf6b3..af25042335 100755 --- a/tests/release/packages/next-app-route/fixture.sh +++ b/tests/release/packages/next-app-route/fixture.sh @@ -203,8 +203,10 @@ run_cold_start() { local verifier label for verifier in $(seq 1 "$VERIFIERS_PER_START"); do if (( verifier == 1 )); then label="cold"; else label="warm"; fi - BASE_URL="http://127.0.0.1:$port" node verify.mjs >>"$log" 2>&1 \ - || fail "$mode cold start $index: $label verifier $verifier/$VERIFIERS_PER_START failed" + BASE_URL="http://127.0.0.1:$port" node verify.mjs >>"$log" 2>&1 || { + tail -40 "$log" | sed 's/^/ /' + fail "$mode cold start $index: $label verifier $verifier/$VERIFIERS_PER_START failed" + } done if [[ "$mode" == "forced" ]]; then python3 "$REPO_ROOT/scripts/gc_evacuation_liveness_assert.py" \ From 6412347ca638e69eac5e872969f25803adf0c9d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 16 Aug 2026 18:09:32 +0200 Subject: [PATCH 4/5] test(next): document the two coverage regimes at the verifier knob Per-cold-start passes buy restart/ABI/parity/bypass-guard coverage at ~2-3 copying minors per fresh process; collection depth in one warm process is PERRY_NEXT_ROUTE_WARM_PASSES (#8215). Neither substitutes for the other. --- .../8210-release-next-fixture-guard-knob.md | 8 ++++--- .../packages/next-app-route/fixture.sh | 22 +++++++++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/changelog.d/8210-release-next-fixture-guard-knob.md b/changelog.d/8210-release-next-fixture-guard-knob.md index 6bd3f1b793..0fa1aa52ed 100644 --- a/changelog.d/8210-release-next-fixture-guard-knob.md +++ b/changelog.d/8210-release-next-fixture-guard-knob.md @@ -8,7 +8,9 @@ - The two hard-coded verifier passes per cold start became `PERRY_NEXT_ROUTE_VERIFIERS_PER_START` (default 10), so the default run is 10 cold starts × 10 passes = 100 batches, matching - `tests/test_next_app_route_dylib.sh` and #8040's 100-iteration bullet. With - the default this fixture is red on today's `main` because of #8163 (~2% of - warm batches lose one response after a default-mode copying minor); `=2` + `tests/test_next_app_route_dylib.sh` and #8040's 100-iteration bullet. Each + 10-pass process runs ~2-3 copying minors, so the default is sensitive to + per-collection bugs and intermittently red on today's `main` (#8163: ~2% of + post-minor batches lose one response); collection depth in one warm process + is `PERRY_NEXT_ROUTE_WARM_PASSES` (#8215), a complementary knob. `=2` restores the previous coverage. The forced-evacuation arm is unchanged. diff --git a/tests/release/packages/next-app-route/fixture.sh b/tests/release/packages/next-app-route/fixture.sh index af25042335..a2e621c73a 100755 --- a/tests/release/packages/next-app-route/fixture.sh +++ b/tests/release/packages/next-app-route/fixture.sh @@ -16,13 +16,14 @@ # `verify.mjs` still exits 0 when it fires (#8161). # # Known state (#8163): with the default 10 verifier passes per process this -# fixture is RED on today's `main` — a default-mode copying minor occasionally -# strands a stale closure, and ~2% of warm batches then lose one response -# (`TypeError: value is not a function` in the host log right after a -# `[gc-copy-minor] ran` line, then an empty body in `verify.mjs`). Two passes -# per process never reached a copying minor, which is how the fixture read -# green while #8040's 100-iteration bullet was red. Set -# `PERRY_NEXT_ROUTE_VERIFIERS_PER_START=2` to recover the old coverage. +# fixture is intermittently RED on today's `main` — a default-mode copying +# minor occasionally strands a stale closure, and ~2% of the batches that +# follow one lose a response (`TypeError: value is not a function` in the host +# log right after a `[gc-copy-minor] ran` line, then an empty body in +# `verify.mjs`). Two passes per process finished before the first copying +# minor (~pass 3), which is how the fixture read green while #8040's +# 100-iteration bullet was red. Set `PERRY_NEXT_ROUTE_VERIFIERS_PER_START=2` +# to recover the old coverage. # # Odd cold starts run under FORCED evacuation with a seeded GC schedule and the # moving-GC liveness assert (#8163 — fixed; `PERRY_NEXT_ROUTE_FORCED_GC=0` @@ -35,6 +36,13 @@ REPO_ROOT="$(cd ../../../.. && pwd)" PERRY_BIN="${PERRY_BIN:-$REPO_ROOT/target/release/perry}" PORT_BASE="${PERRY_NEXT_ROUTE_PORT:-31836}" COLD_STARTS="${PERRY_NEXT_ROUTE_COLD_STARTS:-10}" +# Verifier passes per cold start: restart/ABI/parity/bypass-guard coverage +# across N fresh processes. Each 10-pass process runs ~2-3 copying minors +# (the first lands around pass 3), so this arm is sensitive to per-collection +# bugs — but a fresh process lives permanently in the early/small-heap regime +# and never reaches the grown heap where collections accelerate. Collection +# DEPTH in one process is a different knob (`PERRY_NEXT_ROUTE_WARM_PASSES`, +# #8215); neither substitutes for the other. VERIFIERS_PER_START="${PERRY_NEXT_ROUTE_VERIFIERS_PER_START:-10}" if [[ -n "${PERRY_NEXT_ROUTE_BUILD_DIR:-}" ]]; then BUILD_DIR="$PERRY_NEXT_ROUTE_BUILD_DIR" From e298c5ea5825889d406d098bdf4c477a368a1340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 16 Aug 2026 18:55:47 +0200 Subject: [PATCH 5/5] test(next): reconcile the header with post-#8211 state (#8163 reopened on the default-GC residual) --- .../packages/next-app-route/fixture.sh | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/tests/release/packages/next-app-route/fixture.sh b/tests/release/packages/next-app-route/fixture.sh index a2e621c73a..d224dce79a 100755 --- a/tests/release/packages/next-app-route/fixture.sh +++ b/tests/release/packages/next-app-route/fixture.sh @@ -15,19 +15,21 @@ # throws inside a `.then()` after the response is already sent, so # `verify.mjs` still exits 0 when it fires (#8161). # -# Known state (#8163): with the default 10 verifier passes per process this -# fixture is intermittently RED on today's `main` — a default-mode copying -# minor occasionally strands a stale closure, and ~2% of the batches that -# follow one lose a response (`TypeError: value is not a function` in the host -# log right after a `[gc-copy-minor] ran` line, then an empty body in -# `verify.mjs`). Two passes per process finished before the first copying -# minor (~pass 3), which is how the fixture read green while #8040's -# 100-iteration bullet was red. Set `PERRY_NEXT_ROUTE_VERIFIERS_PER_START=2` -# to recover the old coverage. +# Known state (#8163, reopened): #8211 rooted the two holders behind the +# forced-evacuation arm, but a default-GC residual remains — a default-mode +# copying minor occasionally strands a stale closure, and ~1-2% of the batches +# that follow one lose a response (`TypeError: value is not a function` in the +# host log right after a `[gc-copy-minor] ran` line, then an empty body in +# `verify.mjs`). With the default 10 verifier passes per process this fixture +# is therefore intermittently RED until that residual lands. Two passes per +# process finished before the first copying minor (~pass 3), which is how the +# fixture read green while #8040's 100-iteration bullet was red; set +# `PERRY_NEXT_ROUTE_VERIFIERS_PER_START=2` to recover exactly the old +# coverage. # -# Odd cold starts run under FORCED evacuation with a seeded GC schedule and the -# moving-GC liveness assert (#8163 — fixed; `PERRY_NEXT_ROUTE_FORCED_GC=0` -# turns that arm off for a normal-only run). +# Odd cold starts run under FORCED evacuation with a seeded GC schedule and +# the moving-GC liveness assert (its two #8163 holders were fixed in #8211; +# `PERRY_NEXT_ROUTE_FORCED_GC=0` turns that arm off for a normal-only run). set -euo pipefail cd "$(dirname "$0")"