Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0ce2b37
feat(bench): let the density benchmark measure any published app
proggeramlug Aug 17, 2026
97a18c7
ci: measure the Next.js fixture's density, not just the tiny app
proggeramlug Aug 17, 2026
345ff72
Merge remote-tracking branch 'origin/main' into feat/measure-next-den…
proggeramlug Aug 17, 2026
23e83d8
ci: run the Next.js roundtrip, which ran in no workflow at all
proggeramlug Aug 17, 2026
c043ed2
ci: build the Next fixture before the tests that need it
proggeramlug Aug 18, 2026
f25703e
fix(bench): stage the Next production bundle where the compiler can s…
proggeramlug Aug 19, 2026
bbe62b0
fix(bench): stage the Next bundle where Perry compiles it natively
proggeramlug Aug 19, 2026
03d7656
fix(bench): wait for either load path, not just the dedicated-thread one
proggeramlug Aug 20, 2026
358be55
fix(bench): make the Next App Route actually run under Perry
proggeramlug Aug 20, 2026
09caba8
fix(bench): give the Next fixture a compile budget that fits it
proggeramlug Aug 20, 2026
4d4c9ec
ci: check the disk arithmetic before replicating the Next fixture 100x
proggeramlug Aug 20, 2026
13496df
ci: keep the Next fixture compile inside the runner's memory
proggeramlug Aug 20, 2026
4de2f6f
ci: gate the Next fixture on a host that can build it
proggeramlug Aug 20, 2026
12811d2
docs: correct the bench host's memory, and why that matters here
proggeramlug Aug 20, 2026
06b7535
bench: make the density harness able to measure what it claims
proggeramlug Aug 21, 2026
e57671a
style: cargo fmt the density harness
proggeramlug Aug 27, 2026
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
108 changes: 108 additions & 0 deletions .github/workflows/linux-shared-runtime.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ on:
push:
branches: [main]
workflow_dispatch:
inputs:
next_fixture:
description: >-
Build, verify and measure the Next.js fixture. OFF by default: the
compile peaks above 8.3 GB RSS and a GitHub-hosted runner has 7.75 GB,
so it cannot complete here. Enable only on a host with more memory.
type: boolean
default: false

permissions:
contents: read
Expand Down Expand Up @@ -235,6 +243,34 @@ jobs:
scripts/capture-linux-benchmark-environment.sh > "$metadata"
mv "$metadata" linux-benchmark-environment.txt

# Ordered BEFORE the loader tests on purpose: `binary_http_roundtrip`
# loads the published `next-bench` package, and when none exists it
# rebuilds via prepare-next-benchmark.sh, which needs the dependency
# tree. Placed after the tests instead, it fails with "the Next
# dependency tree is not installed" -- which is how this was found.
- name: Set up Node for the Next fixture build
if: inputs.next_fixture
uses: actions/setup-node@v4
with:
node-version: "22"

- name: Build and publish the Next.js fixture through Coop
if: inputs.next_fixture
shell: bash
env:
# Peak compile RSS scales with CONCURRENT LLVM units, and this
# fixture's units are 15-21 MB of IR apiece. The default on this
# runner is 2 module jobs x 2 unit workers -- four at once, which
# peaked at 4.2 GB and tripped the daemon's cap. The runner has 2
# vCPUs, so that concurrency was buying nothing anyway.
PERRY_MODULE_JOBS: "2"
PERRY_CODEGEN_UNIT_JOBS: "1"
run: |
set -euo pipefail
( cd benchmarks/next-small && npm ci --no-audit --no-fund )
free -m | sed "s/^/ mem: /" || true
scripts/prepare-next-benchmark.sh

- name: Loader, integrity, and lifecycle tests
shell: bash
run: |
Expand All @@ -248,6 +284,18 @@ jobs:
cargo test -p coop-worker --test plugin_roundtrip repeated_load_dispatch_shutdown_reclaims_executor_threads -- --ignored --nocapture
cargo test -p coop-daemon --test auto_compile -- --nocapture
cargo test -p coop-worker --test plugin_roundtrip hundred_preloaded_apps_dispatch -- --ignored --nocapture
# NOTE: binary_http_roundtrip is NOT run here. It loads the
# published next-bench package, and building that package needs
# more memory than a GitHub-hosted runner has (see the
# `next_fixture` input). It runs on a larger host instead. Leaving
# it in unconditionally would fail every run for a reason that has
# nothing to do with the change under test.
# The Next.js fixture, which until now ran in NO workflow at all.
# It is excluded from fast-check as a provider suite (it needs built
# provider images) and was never added here, so the only test that
# exercises a real framework route was gated by nothing. A proof
# that never runs its most interesting case is the failure mode this
# repository keeps finding in its own gates.
'

- name: Linux Perry 1/10/100 RSS, PSS, private-dirty, and cgroup evidence
Expand Down Expand Up @@ -281,6 +329,65 @@ jobs:
2>&1 | tee linux-node-resource-results.txt
'

# The density matrices above measure the TINY dependency-free app, which
# shares almost everything through the providers and therefore sits near
# the floor for marginal cost per deployment. The architecture's claim is
# about real applications, so measure one.
#
# This is meaningful rather than a trick: Perry compiles the Next route
# INTO the application dylib, so a loaded copy needs the shared providers
# and nothing else -- no node_modules, no build output. N copies are the
# genuine marginal cost of an Nth Next deployment on one box.
#
# Node is required to BUILD the fixture (`npm ci` + `next build`), never
# to run it. `prepare-next-benchmark.sh` rebuilds the production route
# whenever the source is newer, because a committed build output silently
# drifted from its source once already.
- name: Linux Next.js 1/10/100 RSS, PSS, private-dirty, and cgroup evidence
if: inputs.next_fixture
shell: bash
env:
COOP_BENCH_APP_COUNTS: 1,10,100
COOP_BENCH_TRIALS: "2"
COOP_BENCH_REQUESTS: "2000"
COOP_BENCH_PRELOAD_CONCURRENCY: "4"
run: |
set -euo pipefail
# Resolve the package the daemon just published. Named explicitly so a
# missing fixture fails here, rather than silently measuring the tiny
# app again and reporting it as Next.
app="$(find target/next-benchmark/coop-run/compiled/next-bench \
-mindepth 2 -maxdepth 2 -name 'app.so' -print -quit)"
if [ -z "$app" ]; then
echo "::error::no published next-bench package to measure"
find target/next-benchmark/coop-run/compiled -maxdepth 3 2>&1 | head -20
exit 1
fi
# The density harness COPIES the app image once per deployment
# (resource_benchmark.rs: `std::fs::copy(source_app, &app)`), so 100
# apps means 100 physical copies. The tiny fixture is trivial; this
# one carries Next's whole compiled server surface, so check the
# arithmetic BEFORE spending 20 minutes discovering it as ENOSPC --
# which surfaces as unrelated-looking failures rather than "disk
# full" (see the phantom codegen bug in PerryTS/perry#8228).
app_mib=$(( $(stat -c %s "$app") / 1048576 ))
free_mib=$(df -m --output=avail . | tail -1 | tr -d ' ')
need_mib=$(( app_mib * 111 )) # 1 + 10 + 100 copies
echo "app image: ${app_mib} MiB; 111 copies need ~${need_mib} MiB; free ${free_mib} MiB"
if [ "$need_mib" -gt "$free_mib" ]; then
echo "::error::Not enough disk to replicate the Next fixture 100x:"
echo "::error:: need ~${need_mib} MiB, have ${free_mib} MiB free."
echo "::error::Reduce COOP_BENCH_APP_COUNTS or free space on the runner."
exit 1
fi
echo "measuring Next fixture: $app"
COOP_BENCH_APP_LIBRARY="$PWD/$app" scripts/run-in-delegated-cgroup.sh bash -c '
set -euo pipefail
cargo test --release -p coop-daemon --test resource_benchmark \
measure_in_process_startup_and_rss -- --ignored --nocapture \
2>&1 | tee linux-next-resource-results.txt
'

- name: Upload raw Linux evidence
if: always()
uses: actions/upload-artifact@v4
Expand All @@ -289,6 +396,7 @@ jobs:
path: |
linux-resource-results.txt
linux-node-resource-results.txt
linux-next-resource-results.txt
linux-benchmark-environment.txt
target/dynamic-smoke/prepare.log
if-no-files-found: warn
26 changes: 26 additions & 0 deletions benchmarks/next-small/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,32 @@ hook. Reverse them and `next/server` resolves to the edge build, whose module
init throws `Invariant: AsyncLocalStorage accessed in runtime where it is not
available`.


## Where this fixture is measured, and why not in CI

Compiling it peaks **above 8.3 GB RSS**. A GitHub-hosted runner has 7.75 GB, so
it cannot complete there at any cap setting — both attempts died at the
daemon's limit rather than at their true peak, which is why the first
measurement (4.2 GB) was the cap and not the peak.

Reducing Perry's codegen concurrency would fix it, but Coop deliberately calls
`env_clear()` before spawning the compiler so ambient `PERRY_*` switches cannot
silently change emitted code without changing build identity, and
`COMPILER_ENV_ALLOWLIST` carries toolchain paths only. That guard is worth more
than the convenience of overriding it.

So the Linux proof gates the tiny dependency-free fixture, and the Next steps
are behind the `next_fixture` workflow-dispatch input, off by default. Run them
on a host that can carry the compile. It has been done on an 8-core/8 GB M1
mini in about eight minutes — note that is barely more RAM than the runner, so
the deciding factor is not memory alone: macOS compresses and swaps under
pressure, while the Linux path is stopped dead by the daemon's `compile_max_rss_mb`
cap. A machine with more real memory is still the safer choice.

Verified there against the pinned Perry: a COOP request frame in,
`AppRouteRouteModule.handle` executed natively, `status: 200` and the route's
own body out.

Build the Node form from this directory:

```sh
Expand Down
27 changes: 21 additions & 6 deletions benchmarks/next-small/coop/coop-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@
//
// So accept either shape and fail loudly if neither carries a routeModule,
// rather than dereferencing undefined and reporting something confusing.
import * as routeBundleNamespace from "../.next/server/app/api/benchmark/route.js";
// Staged as ordinary deployment source by prepare-next-benchmark.sh. Outside
// node_modules and without a leading dot, so Perry compiles it natively --
// location is what decides AOT vs runtime-JS classification, not extension.
import * as routeBundleNamespace from "../next-build/server/app/api/benchmark/route.js";

// IMPORT ORDER IS LOAD-BEARING. The route bundle must be loaded BEFORE
// `next/server`: loading it installs Next's require hook, and without that
Expand All @@ -46,11 +49,23 @@ import * as routeBundleNamespace from "../.next/server/app/api/benchmark/route.j
// available". Verified by reordering these two lines and watching it break.
import { NextRequest } from "next/server";

const routeBundle: Record<string, unknown> =
(routeBundleNamespace as Record<string, unknown>).routeModule !== undefined
? (routeBundleNamespace as Record<string, unknown>)
: (((routeBundleNamespace as Record<string, unknown>).default ??
{}) as Record<string, unknown>);
// Resolved with plain statements rather than a nested ternary plus `??`.
// Measured shape under Perry for a webpack CommonJS bundle:
// ns keys : default, module.exports
// ns.routeModule : undefined
// ns.default : object <- the real exports live here
// default.routeModule: object, and .handle IS a function
// The interop shape differs by toolchain, so try the namespace first and fall
// back to `default`, and keep the expression simple enough to be obviously
// correct at a glance.
const routeNamespace = routeBundleNamespace as unknown as Record<string, unknown>;
let routeBundle: Record<string, unknown> = routeNamespace;
if (routeNamespace.routeModule === undefined) {
const fallback = routeNamespace.default;
if (fallback !== undefined && fallback !== null) {
routeBundle = fallback as Record<string, unknown>;
}
}

const routeModule = routeBundle.routeModule as
| { handle: (request: object, context: unknown) => Promise<Response> }
Expand Down
16 changes: 16 additions & 0 deletions benchmarks/next-small/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@ import type { NextConfig } from "next";

const nextConfig: NextConfig = {
output: "standalone",
// Emit ONE server chunk. Next's server bundles otherwise load chunks with a
// computed require -- webpack: `require("./chunks/" + id + ".js")`,
// turbopack: `require(path.resolve(RUNTIME_ROOT, chunkPath))` -- and an
// ahead-of-time compiler cannot statically resolve either, so the chunk
// never lands in the binary and the route fails at first dispatch.
//
// Disabling server-side splitting makes the route self-contained, which is
// what an AOT host needs. It is also closer to what a serverless bundle
// looks like, so it is not an exotic configuration.
webpack: (config, { isServer }) => {
if (isServer) {
config.optimization.splitChunks = false;
config.optimization.runtimeChunk = false;
}
return config;
},
};

export default nextConfig;
Loading
Loading