Files
1a028f7f03 [internal] Add a skill for benching changes in a sandbox (#95943)
Vibed. This should help with running React PRs on sandboxes to measure
impact on `bench/`.

## Usage

```
use bench skill to to measure impact of https://github.com/react/react/pull/37066
```

- Should be able to point at React or Next PRs
- Compares against mergebase
- Grabs React builds from CI when possible
- Before benching, asserts both React builds are green, or that tests
pass
- Runs on many sandboxes with interleaved runs
- Caches reusable stuff (builds) between sandboxes
- Uses `bench/` examples with realistic payloads

## Test Plan

Running a bench:

<img width="1445" height="584" alt="Screenshot 2026-07-20 at 03 08 02"
src="https://github.com/user-attachments/assets/ac8b6aac-1e62-4695-9a59-fd0577a8a441"
/>

Bench that shows good results:

<img width="1078" height="725" alt="Screenshot 2026-07-20 at 03 46 20"
src="https://github.com/user-attachments/assets/ce49a806-f1b1-497c-a3dd-e5f8d08ea25b"
/>

Neutral bench:

<img width="1032" height="455" alt="Screenshot 2026-07-20 at 03 47 27"
src="https://github.com/user-attachments/assets/de3b700c-4875-4c70-b1c1-55cdddd73a1a"
/>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-25 17:46:38 +01:00

1728 lines
58 KiB
JavaScript

// Remote Next.js e2e A/B on Vercel Sandbox: blocks x arms x runs of
// bench:render-pipeline, both arms always in the SAME VM, paired per
// (block, run), ABBA order. The VM boot is the unit of replication;
// see bench-stats.mjs.
//
// Arms vary React (--pr / --arms, Next side fixed) or Next
// (--next-pr / --next-arms, React side fixed). The Next side defaults
// to canary. Refs resolve in configured/auto-cloned clones of the two
// repos (config.mjs); branch and tag names resolve against the remote.
//
// Usage:
// node sandbox-e2e.mjs --pr <react pr url|num> [--vms 16] [--label x]
// node sandbox-e2e.mjs --arms base=<ref>,cand=<ref> [--next-ref canary]
// node sandbox-e2e.mjs --next-pr <next pr url|num> [--react-ref main]
// node sandbox-e2e.mjs --next-arms base=<ref>,cand=<ref> [--react-ref main]
// Common: [--blocks 1] [--runs 2] [--vms 16] [--routes /blog,/dashboard,/docs]
// [--warmup 200] [--serial 800] [--load-requests 8] [--load-concurrency 8]
// [--isolate-routes] [--bench-env K=V] [--profile] [--keep] [--dry-run]
import { execFile, spawn } from 'child_process'
import { promisify } from 'util'
import fs from 'fs'
import os from 'os'
import path from 'path'
import crypto from 'crypto'
import { analyzeE2eRows, tTestP } from './bench-stats.mjs'
import { openDb, importRun, loadRows, verify as verifyDb } from './bench-db.mjs'
import {
loadConfig,
sandboxScope,
ensureNextRepo,
ensureReactRepo,
} from './config.mjs'
const execFileP = promisify(execFile)
const DRY_RUN = process.argv.includes('--dry-run')
const CONFIG = loadConfig({ requireScope: !DRY_RUN })
// Clones happen on first use: the react one only when a react ref is
// benched or pinned (pure Next A/B uses each ref's vendored React).
let nextRepoResolved
function NEXT_REPO_LAZY() {
if (!nextRepoResolved) nextRepoResolved = ensureNextRepo(CONFIG)
return nextRepoResolved
}
let reactRepoResolved
function REACT_REPO_LAZY() {
if (!reactRepoResolved) reactRepoResolved = ensureReactRepo(CONFIG)
return reactRepoResolved
}
const VERCEL = CONFIG.vercelBin
const SCOPE = CONFIG.team ? sandboxScope(CONFIG) : []
const CACHE = path.join(CONFIG.cacheDir, 'e2e')
const REACT_SNAP_CACHE = path.join(CONFIG.cacheDir, 'react-snap')
const SETUP_VERSION = 'v1-al2023-node24-jdk21'
// Targets build-all-release-channels needs for a sync-react-able arm
// (both channels, oss-stable + oss-experimental).
const E2E_BUILD_TARGETS =
'react/,react.react-server,react-dom/,react-dom.,react-dom-server,scheduler/,react-is,react-server-dom-turbopack,react-server-dom-webpack'
// owner/repo for CI-artifact lookup, from the configured clone URL.
const REACT_GH_REPO =
CONFIG.reactRepoUrl.match(/github\.com[:/]+([^/]+\/[^/.]+)/)?.[1] ??
'react/react'
const NEXT_GH_REPO =
CONFIG.nextRepoUrl.match(/github\.com[:/]+([^/]+\/.+?)(?:\.git)?$/)?.[1] ??
'vercel/next.js'
// Runtime provenance: the compiled server files prod app-page runtimes
// are bundled from — BOTH bundlers, so changes touching only one still
// move the fingerprint.
const FP_FILES = [
'packages/next/dist/compiled/react-server-dom-turbopack-experimental/cjs/react-server-dom-turbopack-server.node.production.js',
'packages/next/dist/compiled/react-server-dom-webpack-experimental/cjs/react-server-dom-webpack-server.node.production.js',
]
function parseArgs() {
const a = process.argv.slice(2)
const get = (name, dflt) => {
const i = a.indexOf(name)
return i >= 0 ? a[i + 1] : dflt
}
// React arms: name=<git ref in the react repo> (built remotely, cached
// by sha). Next arms: name=<git ref in this checkout>. Exactly one of
// the two sides varies; the other is fixed for both arms.
const parseArmSpec = (spec, flag) =>
spec
.split(',')
.filter(Boolean)
.map((s) => {
const [name, src] = s.split('=')
if (!name || !src)
throw new Error(`bad arm "${s}" in ${flag}, want name=<ref>`)
return { name, ref: src }
})
const arms = parseArmSpec(get('--arms', ''), '--arms')
const nextArms = parseArmSpec(get('--next-arms', ''), '--next-arms')
const pr = get('--pr', undefined)
const nextPr = get('--next-pr', undefined)
const reactModes = (arms.length ? 1 : 0) + (pr ? 1 : 0)
const nextModes = (nextArms.length ? 1 : 0) + (nextPr ? 1 : 0)
if (reactModes + nextModes !== 1) {
throw new Error('need exactly one of: --pr, --arms, --next-pr, --next-arms')
}
// One react arm = "vs what this Next ships": base derived as
// merge-base(cand, react synced into the Next ref).
if (arms.length > 2)
throw new Error(
'--arms takes one arm (vs synced react) or two (base first)'
)
if (nextArms.length && nextArms.length !== 2)
throw new Error('--next-arms needs exactly two arms (base first)')
return {
arms,
nextArms,
pr,
nextPr,
dryRun: a.includes('--dry-run'),
allowUngated: a.includes('--allow-ungated'),
// Fixed sides. Next defaults to canary.
nextRef: get('--next-ref', 'canary'),
// For Next A/B the React side defaults to whatever each Next ref
// vendors (that's what would ship); --react-ref pins both arms to
// one React build instead.
reactRef: get('--react-ref', ''),
// The VM boot is the unit of replication (see bench-stats.mjs):
// allocate toward more boots with fewer runs each.
blocks: Number(get('--blocks', '1')),
runs: Number(get('--runs', '2')),
vms: Number(get('--vms', '16')),
routes: get('--routes', '/blog,/dashboard,/docs'),
warmup: Number(get('--warmup', '200')),
serial: Number(get('--serial', '800')),
loadRequests: Number(get('--load-requests', '8')),
loadConcurrency: Number(get('--load-concurrency', '8')),
isolateRoutes: a.includes('--isolate-routes'),
keep: a.includes('--keep'),
prepare: a.includes('--prepare'),
// Profiles are captured by default: the pass runs strictly after the
// timed runs (never touches the numbers), costs ~10-15 min of VM
// wall-clock, and cross-VM profile diffs proved highly stable
// (16/16 sign agreement on movers). --no-profile opts out.
profile: !a.includes('--no-profile'),
// KEY=VALUE env exported around bench:render-pipeline (e.g.
// NEXT_FLIGHT_RENDER=0 to force the byte-tee SSR baseline).
benchEnv: get('--bench-env', ''),
label: get('--label', 'e2e'),
}
}
// Recovery record for bench-collect.mjs: if this launcher dies, the
// remote VMs keep executing their detached loops, and this file names
// them so the results can still be collected.
let statusFile = null
let statusState = {}
function writeStatus(patch) {
if (!statusFile) return
statusState = {
...statusState,
...patch,
updatedAt: new Date().toISOString(),
}
try {
fs.writeFileSync(statusFile, JSON.stringify(statusState, null, 2))
} catch {}
}
async function sb(args, opts = {}) {
const scoped = ['sandbox', ...args]
const sep = scoped.indexOf('--')
scoped.splice(sep < 0 ? scoped.length : sep, 0, ...SCOPE)
const { stdout, stderr } = await execFileP(VERCEL, scoped, {
maxBuffer: 64 * 1024 * 1024,
...opts,
})
// The CLI prints some results (e.g. snapshot ids) on stderr.
return `${stdout}\n${stderr}`
}
// The platform rejects single `sandbox cp` uploads somewhere above ~128MB
// ("Request Entity Too Large", observed 2026-07-20; 645MB tree tarballs that
// uploaded fine hours earlier started failing). Upload large files in parts
// and reassemble on the VM, verifying the sha256 end to end.
const CP_CHUNK_BYTES = 128 * 1024 * 1024
async function sbCpToVm(vm, localPath, vmDest) {
const size = fs.statSync(localPath).size
if (size <= CP_CHUNK_BYTES) {
await sb(['cp', localPath, `${vm}:${vmDest}`])
return
}
const partDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbcp-'))
try {
await execFileP('split', [
'-b',
String(CP_CHUNK_BYTES),
localPath,
path.join(partDir, 'part-'),
])
const parts = fs.readdirSync(partDir).sort()
for (const p of parts) {
await sb(['cp', path.join(partDir, p), `${vm}:${vmDest}.${p}`])
}
const localSha = (
await execFileP('shasum', ['-a', '256', localPath])
).stdout.split(' ')[0]
const catList = parts.map((p) => `'${vmDest}.${p}'`).join(' ')
const out = await sbExec(
vm,
'10m',
`cat ${catList} > '${vmDest}' && rm -f ${catList} && sha256sum '${vmDest}' | cut -d' ' -f1`,
`cp:${path.basename(vmDest)}`
)
// sbExec output interleaves stderr (CLI banners); take the last
// sha-shaped token rather than the last line.
const shaTokens = out.match(/\b[0-9a-f]{64}\b/g)
const remoteSha = shaTokens ? shaTokens[shaTokens.length - 1] : ''
if (remoteSha !== localSha) {
throw new Error(
`chunked upload of ${localPath} corrupt: local ${localSha} != remote ${remoteSha}`
)
}
} finally {
fs.rmSync(partDir, { recursive: true, force: true })
}
}
// PR spec ("37023" or a github PR URL) -> arms: base = merge-base of the
// PR head with upstream main, cand = PR head. Fetched into local refs so
// git archive / yarn.lock reads work as for any other sha.
async function resolvePrArms(pr, repo, repoUrl, defaultBranch) {
const num = String(pr).match(/(\d+)\/?$/)?.[1]
if (!num) throw new Error(`cannot parse PR number from "${pr}"`)
console.error(`fetching ${repoUrl} PR #${num} + ${defaultBranch}...`)
// The clone is shared: concurrent launchers fetching the same ref
// race the ref lock. Namespace the temp refs by pid and retry the
// fetch (pack files still contend occasionally).
const ns = `refs/bench-tmp/${process.pid}`
for (let attempt = 1; ; attempt++) {
try {
await execFileP('git', [
'-C',
repo,
'fetch',
'-q',
repoUrl,
`+refs/pull/${num}/head:${ns}/pr-${num}`,
`+refs/heads/${defaultBranch}:${ns}/upstream-${defaultBranch}`,
])
break
} catch (e) {
if (attempt >= 3) throw e
console.error(
`fetch attempt ${attempt} failed (${e.message.split('\n')[0].slice(0, 80)}); retrying...`
)
await new Promise((r) =>
setTimeout(r, 5000 * attempt + Math.random() * 5000)
)
}
}
const cand = (
await execFileP('git', ['-C', repo, 'rev-parse', `${ns}/pr-${num}`])
).stdout.trim()
const base = (
await execFileP('git', [
'-C',
repo,
'merge-base',
`${ns}/upstream-${defaultBranch}`,
cand,
])
).stdout.trim()
await execFileP('git', [
'-C',
repo,
'update-ref',
'-d',
`${ns}/pr-${num}`,
]).catch(() => {})
await execFileP('git', [
'-C',
repo,
'update-ref',
'-d',
`${ns}/upstream-${defaultBranch}`,
]).catch(() => {})
console.error(
`PR #${num}: cand=${cand.slice(0, 12)} base=${base.slice(0, 12)} (merge-base with ${defaultBranch})`
)
return [
{ name: 'base', ref: base },
{ name: `pr${num}`, ref: cand },
]
}
// Normalize every mode into two arms of {name, ref (react), nextRef};
// exactly one side differs between the arms.
// Green CI on the react repo is the correctness gate for react arms: a
// perf number from a broken build is worse than no number. Local or
// unpushed refs have no CI — --allow-ungated skips the check, and
// sandbox-gate.mjs exists to gate such refs on a VM instead.
const ciVerdicts = new Map()
async function assertCiGreen(sha, armName, allowUngated) {
if (!ciVerdicts.has(sha)) {
let verdict
try {
const out = (
await execFileP(
'gh',
[
'api',
'--paginate',
`repos/${REACT_GH_REPO}/commits/${sha}/check-runs?per_page=100`,
'--jq',
'[.check_runs[] | {name, conclusion}]',
],
{ maxBuffer: 1 << 24 }
)
).stdout
const checks = out
.trim()
.split('\n')
.filter(Boolean)
.flatMap((page) => JSON.parse(page))
if (checks.length === 0) {
verdict = 'no CI runs found (unpushed or unbuilt commit)'
} else if (checks.some((c) => c.conclusion === null)) {
verdict = 'CI still running — retry when it finishes'
} else {
// Policy: all tests (including build), flow, and lint must be
// green. DevTools suites and repo-infra jobs (artifact syncs,
// cleanup, staleness) don't gate benching. Ignore-by-name, so
// any NEW job blocks by default instead of being skipped.
const IGNORED =
/devtools|^cleanup$|^stale$|_artifacts$|^sizebot|^dependabot/i
const relevant = checks.filter((c) => !IGNORED.test(c.name))
const bad = relevant.filter(
(c) =>
c.conclusion !== 'success' &&
c.conclusion !== 'neutral' &&
c.conclusion !== 'skipped'
)
// Name the offenders: the human deciding whether to proceed
// ungated needs to see what failed at a glance, not re-query CI.
verdict =
bad.length === 0
? 'green'
: `CI not green (${bad.length}/${relevant.length} relevant checks): ` +
[...new Set(bad.map((c) => `${c.name} (${c.conclusion})`))]
.slice(0, 8)
.join('; ')
}
} catch (e) {
verdict = `could not query CI (${e.message.split('\n')[0].slice(0, 80)})`
}
ciVerdicts.set(sha, verdict)
}
const verdict = ciVerdicts.get(sha)
if (verdict === 'green') {
console.error(`arm ${armName}: react ${sha.slice(0, 12)} CI green`)
return
}
if (allowUngated) {
console.error(
`arm ${armName}: react ${sha.slice(0, 12)} UNGATED (${verdict}) — proceeding per --allow-ungated`
)
return
}
throw new Error(
`arm ${armName}: react ${sha.slice(0, 12)}: ${verdict}.\n` +
'Benching an unverified build produces untrustworthy numbers. Either wait for/fix CI, ' +
'gate the ref yourself (node sandbox-gate.mjs --arms ' +
armName +
'=<ref>), and then ' +
're-run with --allow-ungated, or pass --allow-ungated if you accept the risk.'
)
}
// The react commit a Next ref ships: sync-react records it in the root
// package.json ("react-builtin": "npm:[email protected]<sha>-<date>").
async function syncedReactSha(nextRef) {
const pkg = (
await execFileP(
'git',
['-C', NEXT_REPO_LAZY(), 'show', `${nextRef}:package.json`],
{ maxBuffer: 1 << 24 }
)
).stdout
const m = JSON.parse(pkg).devDependencies?.['react-builtin']?.match(
/-([0-9a-f]{8,40})-\d{8}$/
)
if (!m)
throw new Error(
`cannot parse synced react sha from ${nextRef}:package.json react-builtin`
)
return m[1]
}
// Next refs: shas and refs already fetched this run resolve locally;
// branch/tag names resolve against the remote. Each ref resolves once
// per run (memo) and everything downstream uses the sha, so the two
// arms always get the same tree. The clone is shared between
// concurrent launchers (pid-namespaced temp refs, fetch retried).
async function fetchNextRef(repo, spec, dst) {
for (let attempt = 1; ; attempt++) {
try {
await execFileP('git', [
'-C',
repo,
'fetch',
'-q',
CONFIG.nextRepoUrl,
`+${spec}:${dst}`,
])
return
} catch (e) {
if (attempt >= 3) throw e
await new Promise((r) => setTimeout(r, 2000 * attempt))
}
}
}
const nextShaMemo = new Map()
async function nextShaFor(ref) {
if (nextShaMemo.has(ref)) return nextShaMemo.get(ref)
const repo = NEXT_REPO_LAZY()
let sha
if (/^[0-9a-f]{7,40}$/i.test(ref) || ref.startsWith('refs/')) {
try {
sha = (
await execFileP('git', [
'-C',
repo,
'rev-parse',
'--verify',
`${ref}^{commit}`,
])
).stdout.trim()
} catch {}
}
if (!sha) {
const dst = `refs/bench-tmp/${process.pid}/next-fixed-${nextShaMemo.size}`
// "canary" means the latest published canary release, not the
// branch head: postinstall downloads the @next/swc binary for
// package.json's version, which only exists once that release is
// on npm. Between releases this is also a stable sha, so built
// snapshots stay warm until a new canary actually ships.
let spec = ref
let release
if (ref === 'canary') {
release = `v${(await execFileP('npm', ['view', 'next@canary', 'version'])).stdout.trim()}`
spec = `refs/tags/${release}`
}
await fetchNextRef(repo, spec, dst)
sha = (
await execFileP('git', ['-C', repo, 'rev-parse', `${dst}^{commit}`])
).stdout.trim()
console.error(
`next ${ref}: ${release ? `${release} = ` : ''}${sha.slice(0, 12)}`
)
}
nextShaMemo.set(ref, sha)
return sha
}
async function resolveArms(cfg) {
let arms
if (cfg.pr) {
arms = (
await resolvePrArms(
cfg.pr,
REACT_REPO_LAZY(),
CONFIG.reactRepoUrl,
'main'
)
).map((a) => ({ ...a, nextRef: cfg.nextRef }))
} else if (cfg.arms.length === 1) {
// Candidate react vs whatever this Next ref ships. merge-base keeps
// the base a real commit in the candidate's history even when the
// synced version isn't an exact ancestor.
const synced = await syncedReactSha(await nextShaFor(cfg.nextRef))
const repo = REACT_REPO_LAZY()
const base = (
await execFileP('git', [
'-C',
repo,
'merge-base',
synced,
cfg.arms[0].ref,
])
).stdout.trim()
console.error(
`react base = merge-base(${cfg.arms[0].ref}, synced ${synced}) = ${base.slice(0, 12)}`
)
arms = [
{ name: 'synced', ref: base, nextRef: cfg.nextRef },
{ ...cfg.arms[0], nextRef: cfg.nextRef },
]
} else if (cfg.arms.length) {
arms = cfg.arms.map((a) => ({ ...a, nextRef: cfg.nextRef }))
} else if (cfg.nextPr) {
arms = (
await resolvePrArms(
cfg.nextPr,
NEXT_REPO_LAZY(),
CONFIG.nextRepoUrl,
'canary'
)
).map((a) => ({ name: a.name, ref: cfg.reactRef || null, nextRef: a.ref }))
} else {
arms = cfg.nextArms.map((a) => ({
name: a.name,
ref: cfg.reactRef || null,
nextRef: a.ref,
}))
}
for (const arm of arms) {
arm.nextSha = await nextShaFor(arm.nextRef)
}
return arms
}
// Human context for reports: PR title/URL and the varying side's
// commit titles, recorded in meta.json and printed with the analysis
// so verdicts can link what was measured.
async function commitTitle(repo, ref) {
try {
return (
await execFileP('git', ['-C', repo, 'log', '-1', '--format=%s', ref])
).stdout.trim()
} catch {
return undefined
}
}
async function describeRun(cfg) {
const reactVaries = !(cfg.nextPr || cfg.nextArms.length)
let pr
const num = String(cfg.pr ?? cfg.nextPr ?? '').match(/(\d+)\/?$/)?.[1]
if (num) {
const ghRepo = cfg.pr ? REACT_GH_REPO : NEXT_GH_REPO
pr = { url: `https://github.com/${ghRepo}/pull/${num}` }
try {
pr.title = (
await execFileP('gh', [
'api',
`repos/${ghRepo}/pulls/${num}`,
'--jq',
'.title',
])
).stdout.trim()
} catch {}
}
const arms = []
for (const a of cfg.arms) {
arms.push({
name: a.name,
title: reactVaries
? await commitTitle(REACT_REPO_LAZY(), a.ref)
: await commitTitle(NEXT_REPO_LAZY(), a.nextSha),
})
}
return { pr, arms }
}
function printRunContext(out, d) {
if (!d) return
if (d.pr) out(`PR: ${d.pr.title ? `"${d.pr.title}" — ` : ''}${d.pr.url}`)
for (const a of d.arms ?? []) if (a.title) out(` ${a.name}: "${a.title}"`)
}
// Live running estimate, printed as pairs complete across all VMs.
const T95 = [
12.71, 4.3, 3.18, 2.78, 2.57, 2.45, 2.36, 2.31, 2.26, 2.23, 2.2, 2.18, 2.16,
2.14, 2.13,
]
function makeLive(baseName, metrics, keyOf) {
const rows = []
return (vm, row) => {
rows.push({ vmIdx: vm, ...row })
const key = keyOf(row)
const other = rows.find(
(r) =>
r.vmIdx === vm &&
keyOf(r) === key &&
r.block === row.block &&
r.run === row.run &&
r.arm !== row.arm
)
if (!other) return
const cands = rows.filter((r) => keyOf(r) === key && r.arm !== baseName)
const parts = []
let n = 0
for (const metric of metrics) {
const deltas = []
for (const c of cands) {
const b = rows.find(
(r) =>
r.vmIdx === c.vmIdx &&
keyOf(r) === key &&
r.block === c.block &&
r.run === c.run &&
r.arm === baseName
)
if (b && b[metric] > 0) deltas.push((c[metric] - b[metric]) / b[metric])
}
n = deltas.length
if (n < 2) return
const mean = deltas.reduce((a, b2) => a + b2, 0) / n
const sd = Math.sqrt(
deltas.reduce((a, b2) => a + (b2 - mean) ** 2, 0) / (n - 1)
)
const ci = ((T95[n - 2] ?? 2.0) * sd) / Math.sqrt(n)
const p = pairedP(deltas)
parts.push(
`${metric} ${(mean * 100).toFixed(1)}%±${(ci * 100).toFixed(1)} p=${p.toFixed(3)}`
)
}
console.error(`live ${key} n=${n}: ${parts.join(' ')}`)
}
}
function sbExec(vm, timeout, script, tag, onRow) {
return new Promise((resolve, reject) => {
const child = spawn(
VERCEL,
[
'sandbox',
'exec',
vm,
...SCOPE,
'--timeout',
timeout,
'--',
'bash',
'-c',
script,
],
{ stdio: ['ignore', 'pipe', 'pipe'] }
)
let out = ''
let buf = ''
child.stdout.on('data', (c) => {
out += c
buf += c
const lines = buf.split('\n')
buf = lines.pop()
for (const l of lines) {
if (!l) continue
if (l.startsWith('ROW ') && onRow) {
try {
onRow(JSON.parse(l.slice(4)))
} catch {}
} else {
process.stderr.write(`[${tag}] ${l}\n`)
}
}
})
child.stderr.on('data', (c) => {
out += c
process.stderr.write(
String(c)
.split('\n')
.filter(Boolean)
.map((l) => `[${tag}!] ${l}\n`)
.join('')
)
})
child.on('exit', (code) =>
code === 0
? resolve(out)
: reject(new Error(`${tag}: exit ${code}\n${out.slice(-2000)}`))
)
})
}
async function rmVm(name) {
try {
await sb(['rm', name])
} catch (e) {
process.stderr.write(`warning: could not remove ${name}: ${e.message}\n`)
}
}
// Long-running remote work detached from the exec stream (streams drop
// flakily on multi-minute silences): nohup the script on the VM, then
// poll its log with short execs. Immune to transport hiccups.
async function runDetached(vm, tag, script, onLine, deadlineMin) {
let transcript = ''
const local = path.join(os.tmpdir(), `loop-${vm}.sh`)
// EXIT trap, not ERR: the ERR trap does not fire for several failure
// shapes (e.g. `cmd || (tail; exit 1)`), which left loop.done unwritten
// and the poll waiting on heartbeats forever.
fs.writeFileSync(
local,
`trap 'code=$?; if [ $code -eq 0 ]; then echo LOOPOK; else echo LOOPFAIL; fi > /vercel/sandbox/loop.done' EXIT\nset -e\n${script}\n`
)
await sb(['cp', local, `${vm}:/vercel/sandbox/loop.sh`])
fs.rmSync(local, { force: true })
await sb([
'exec',
vm,
'--timeout',
'2m',
'--',
'bash',
'-c',
'rm -f /vercel/sandbox/loop.done /vercel/sandbox/loop.log; nohup bash /vercel/sandbox/loop.sh >/vercel/sandbox/loop.log 2>&1 & echo kicked',
])
let offset = 0
let failures = 0
const deadline = Date.now() + deadlineMin * 60_000
while (true) {
await new Promise((r) => setTimeout(r, 45_000))
if (Date.now() > deadline)
throw new Error(`${tag}: detached loop deadline exceeded`)
let out
try {
out = await sb([
'exec',
vm,
'--timeout',
'2m',
'--',
'bash',
'-c',
`tail -c +${offset + 1} /vercel/sandbox/loop.log | head -c 200000; printf '\\n@@SIZE %s @@DONE %s\\n' "$(stat -c %s /vercel/sandbox/loop.log 2>/dev/null || echo 0)" "$(cat /vercel/sandbox/loop.done 2>/dev/null || echo no)"`,
])
failures = 0
} catch (e) {
if (++failures >= 6)
throw new Error(
`${tag}: ${failures} consecutive poll failures: ${e.message.slice(0, 200)}`
)
continue
}
const m = out.match(/@@SIZE (\d+) @@DONE (\S+)/)
const body = out.slice(0, out.lastIndexOf('\n@@SIZE'))
transcript += body
for (const l of body.split('\n')) {
if (!l) continue
if (l.startsWith('ROW ') && onLine) {
try {
onLine(JSON.parse(l.slice(4)))
} catch {}
} else {
process.stderr.write(`[${tag}] ${l}\n`)
}
}
if (m) {
offset = Math.min(Number(m[1]), offset + 200000)
if (m[2] === 'LOOPOK') return transcript
if (m[2] === 'LOOPFAIL')
throw new Error(
`${tag}: remote loop failed; tail:\n${body.slice(-1500)}`
)
}
}
}
// ------------------------------------------------------------ snapshots
async function sha256(text) {
return crypto.createHash('sha256').update(text).digest('hex').slice(0, 16)
}
async function snapshotIdFor(cacheDir, key) {
const file = path.join(cacheDir, `snap-${key}`)
if (!fs.existsSync(file)) return undefined
const id = fs.readFileSync(file, 'utf8').trim()
try {
await sb(['snapshots', 'get', id])
return id
} catch {
return undefined
}
}
async function takeSnapshot(vm, cacheDir, key) {
const out = await sb(['snapshot', vm, '--stop', '--expiration', '30d'])
const id = out.match(/snap_[A-Za-z0-9]+/)?.[0]
if (!id)
throw new Error(`could not parse snapshot id from: ${out.slice(-500)}`)
fs.mkdirSync(cacheDir, { recursive: true })
fs.writeFileSync(path.join(cacheDir, `snap-${key}`), `${id}\n`)
console.error(`snapshot ${id} cached (${key})`)
return id
}
// React build environment (repo + node_modules + JDK), shared with
// sandbox-ab.mjs. Keyed on the arm's yarn.lock.
async function ensureReactBuildSnapshot(refSha) {
const lock = await execFileP(
'git',
['-C', REACT_REPO_LAZY(), 'show', `${refSha}:yarn.lock`],
{ maxBuffer: 1 << 28 }
)
const key = await sha256(SETUP_VERSION + lock.stdout)
let id = await snapshotIdFor(REACT_SNAP_CACHE, key)
if (id) return id
const vm = `react-snap-build-${Date.now().toString(36)}`
console.error(
`creating react build-env snapshot (one-time for this yarn.lock)...`
)
await sb([
'create',
'--name',
vm,
'--runtime',
'node24',
'--vcpus',
'8',
'--timeout',
'45m',
'--non-persistent',
'--network-policy',
'allow-all',
'--tag',
'purpose=sandbox-bench',
'--silent',
])
try {
const src = path.join(os.tmpdir(), `react-snap-src-${key}.tgz`)
await execFileP('bash', [
'-c',
`git -C ${REACT_REPO_LAZY()} archive ${refSha} | gzip -1 > ${src}`,
])
await sb(['cp', src, `${vm}:/vercel/sandbox/src.tgz`])
fs.rmSync(src, { force: true })
await sb([
'exec',
vm,
'--timeout',
'10m',
'--sudo',
'--',
'dnf',
'install',
'-y',
'-q',
'java-21-amazon-corretto-headless',
])
await sbExec(
vm,
'20m',
`set -e; mkdir -p /vercel/sandbox/react && cd /vercel/sandbox/react && tar -xzf ../src.tgz && rm -f ../src.tgz && ` +
`npm i -g yarn >/dev/null 2>&1 && yarn install --frozen-lockfile --ignore-engines >/dev/null 2>&1 && echo react env ready`,
'react-snap'
)
return await takeSnapshot(vm, REACT_SNAP_CACHE, key)
} finally {
await rmVm(vm)
}
}
// Experiment snapshot: both arms fully vendored + built as SEPARATE repo
// trees (/vercel/sandbox/next-<arm>), app .next included, so run VMs
// boot straight into measurement and arm switching is a cd. Keyed on
// (next ref, armA, armB); the app build happens once here, so every run
// VM measures byte-identical artifacts.
function armId(a) {
return `${a.sha ? a.sha.slice(0, 12) : 'vendored'}+${a.nextSha.slice(0, 12)}`
}
async function ensureExperimentSnapshot(cfg) {
// Arm NAMES are part of the key, not just shas: tree paths inside the
// snapshot embed the names (/vercel/sandbox/next-<name>), so a snapshot
// built for the same sha pair under different names has the wrong trees.
const key = await sha256(
SETUP_VERSION + 'exp3' + cfg.arms.map((a) => `${a.name}=${armId(a)}`).join()
)
let id = await snapshotIdFor(CACHE, key)
if (id) return id
// Built TREES are cached independently of the (pair, names) snapshot:
// the base side of a comparison repeats across cells far more often
// than the exact pair does, and a cached tree turns a ~15 min
// install+build+sync into an upload+extract.
for (const arm of cfg.arms) {
arm.treeKey = await sha256(SETUP_VERSION + 'tree1' + armId(arm))
arm.treeTgz = path.join(CACHE, `tree-${arm.treeKey}.tgz`)
arm.treeCached = fs.existsSync(arm.treeTgz)
}
const vm = `sbench-expsnap-${Date.now().toString(36)}`
console.error(
`creating experiment snapshot (one-time for arms=${cfg.arms.map(armId).join(',')}; ` +
`trees cached: ${
cfg.arms
.filter((a) => a.treeCached)
.map((a) => a.name)
.join(',') || 'none'
})...`
)
try {
await sb([
'create',
'--name',
vm,
'--runtime',
'node24',
'--vcpus',
'16',
'--timeout',
'1h',
'--non-persistent',
'--network-policy',
'allow-all',
'--tag',
'purpose=sandbox-bench',
'--silent',
])
} catch (e) {
// Only a genuine capacity/plan rejection falls back to 8 vCPUs;
// anything else (auth, quota, network) must surface as itself.
if (!/vcpu/i.test(e.message)) throw e
console.error('16 vCPUs unavailable, using 8')
await sb([
'create',
'--name',
vm,
'--runtime',
'node24',
'--vcpus',
'8',
'--timeout',
'1h',
'--non-persistent',
'--network-policy',
'allow-all',
'--tag',
'purpose=sandbox-bench',
'--silent',
])
}
try {
for (const tgz of new Set(
cfg.arms.filter((a) => !a.treeCached).map((a) => a.nextTgz)
)) {
await sb(['cp', tgz, `${vm}:/vercel/sandbox/${path.basename(tgz)}`])
}
for (const arm of cfg.arms) {
if (arm.treeCached) {
console.error(`uploading cached tree for ${arm.name}...`)
await sbCpToVm(vm, arm.treeTgz, `/vercel/sandbox/tree-${arm.name}.tgz`)
} else if (arm.tgz) {
await sb(['cp', arm.tgz, `${vm}:/vercel/sandbox/arm-${arm.name}.tgz`])
}
}
// Cached trees: extract. Missing trees: install sequentially (the
// shared pnpm store dislikes concurrent cold installs), then build,
// sync, and warm concurrently — the builds dominate and parallelize
// across the 16 vCPUs. PHASE lines make the time budget visible.
const extractCached = cfg.arms
.filter((a) => a.treeCached)
.map(
(a) => `
echo "PHASE extract-${a.name} $(date +%s)"
mkdir -p /vercel/sandbox/next-${a.name} && tar -xzf /vercel/sandbox/tree-${a.name}.tgz -C /vercel/sandbox/next-${a.name}`
)
.join('\n')
const installs = cfg.arms
.filter((a) => !a.treeCached)
.map(
(a) => `
echo "PHASE install-${a.name} $(date +%s)"
${a.tgz ? `mkdir -p /vercel/sandbox/arm-${a.name} && tar -xzf /vercel/sandbox/arm-${a.name}.tgz -C /vercel/sandbox/arm-${a.name}` : ':'}
mkdir -p /vercel/sandbox/next-${a.name} && cd /vercel/sandbox/next-${a.name} && tar -xzf /vercel/sandbox/${path.basename(a.nextTgz)} 2>/dev/null
pnpm install --frozen-lockfile >/tmp/i-${a.name}.log 2>&1 || (tail -10 /tmp/i-${a.name}.log; exit 1)`
)
.join('\n')
const builds = cfg.arms
.filter((a) => !a.treeCached)
.map(
(a, i) => `
(
set -e
cd /vercel/sandbox/next-${a.name}
echo "PHASE build-${a.name} $(date +%s)"
pnpm build >/tmp/b-${a.name}.log 2>&1
${a.tgz ? `pnpm run sync-react --version "file:///vercel/sandbox/arm-${a.name}/" >/tmp/s-${a.name}.log 2>&1` : ': # vendored react, no sync'}
pnpm --filter=@next/font build >/dev/null 2>&1
echo "PHASE next-build-${a.name} $(date +%s)"
pnpm --filter=next build >/tmp/n-${a.name}.log 2>&1
) &
BUILD_${i}=$!`
)
.join('\n')
const waits = cfg.arms
.filter((a) => !a.treeCached)
.map(
(a, i) =>
`wait $BUILD_${i} || (tail -10 /tmp/b-${a.name}.log /tmp/s-${a.name}.log /tmp/n-${a.name}.log; exit 1)`
)
.join('\n')
// Warm + verify runs per tree, sequential (they bind the same port).
const verifies = cfg.arms
.map(
(a) => `
cd /vercel/sandbox/next-${a.name}
VER=$(grep -aom1 "[0-9.]*-\\(canary\\|experimental\\)-[0-9a-f]*-[0-9]*" packages/next/dist/compiled/react-experimental/cjs/react.development.js || echo MISSING)
echo "tree ${a.name} ver=$VER"
[ "$VER" != MISSING ]
echo "PHASE warm-${a.name} $(date +%s)"
pnpm bench:render-pipeline --scenario=e2e --stream-mode=node --build=true --port=3720 --routes=${cfg.routes} --warmup-requests=1 --serial-requests=2 --load-requests=2 --load-concurrency=1 --json-out=/tmp/warm.json --artifact-dir=/tmp/warm-art >/tmp/w.log 2>&1 || (tail -10 /tmp/w.log; exit 1)
rm -rf /tmp/warm-art /tmp/warm.json
${a.treeCached ? ':' : `echo "PHASE pack-${a.name} $(date +%s)" && tar -czf /vercel/sandbox/tree-${a.name}-out.tgz -C /vercel/sandbox/next-${a.name} .`}
echo "tree ${a.name} ready"`
)
.join('\n')
await sbExec(
vm,
'55m',
`set -e\nnpm i -g [email protected] >/dev/null 2>&1\n` +
`(while true; do echo "hb mem=$(free -m | awk '/^Mem/{print $3}')MB"; sleep 30; done) & HB=$!\n` +
`${extractCached}\n${installs}\n${builds}\n${waits}\n${verifies}\nkill $HB\n` +
`echo "PHASE done $(date +%s)"\n` +
`find /vercel/sandbox -maxdepth 1 -name '*.tgz' ! -name 'tree-*-out.tgz' -delete\necho experiment ready`,
'expsnap'
)
// Pull freshly built trees into the cache before snapshotting (the
// snapshot must not contain the multi-GB tarballs).
for (const arm of cfg.arms) {
if (!arm.treeCached) {
console.error(`caching built tree for ${arm.name}...`)
// Temp + rename: concurrent launchers may cache the same tree.
const treeTmp = `${arm.treeTgz}.tmp-${process.pid}`
await sb([
'cp',
`${vm}:/vercel/sandbox/tree-${arm.name}-out.tgz`,
treeTmp,
])
if (fs.existsSync(treeTmp) && fs.statSync(treeTmp).size >= 50_000_000) {
fs.renameSync(treeTmp, arm.treeTgz)
} else {
fs.rmSync(treeTmp, { force: true })
console.error(
`tree cache download for ${arm.name} too small; skipping cache (snapshot unaffected)`
)
}
}
}
await sbExec(
vm,
'5m',
`rm -f /vercel/sandbox/tree-*.tgz /vercel/sandbox/tree-*-out.tgz; echo cleaned`,
'expsnap'
)
return await takeSnapshot(vm, CACHE, key)
} finally {
await rmVm(vm)
}
}
// ---------------------------------------------------------------- stage
// React CI builds every upstream commit and PR; reuse those artifacts
// instead of building. Requires gh auth. Returns false -> build remotely.
async function tryCiArtifactArm(sha, cached, pack) {
try {
const runs = JSON.parse(
(
await execFileP(
'gh',
[
'api',
`repos/${REACT_GH_REPO}/actions/workflows/runtime_build_and_test.yml/runs?head_sha=${sha}&per_page=5`,
],
{ maxBuffer: 1 << 24 }
)
).stdout
)
const id = runs.workflow_runs?.find((r) => r.head_sha === sha)?.id
if (!id) return false
const arts = JSON.parse(
(
await execFileP(
'gh',
[
'api',
`repos/${REACT_GH_REPO}/actions/runs/${id}/artifacts?name=artifacts_combined`,
],
{ maxBuffer: 1 << 24 }
)
).stdout
)
const art = arts.artifacts?.find(
(a) => a.name === 'artifacts_combined' && !a.expired
)
if (!art) return false
console.error(
`downloading CI artifacts for ${sha.slice(0, 12)} (run ${id})...`
)
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-arm-'))
try {
await execFileP(
'bash',
[
'-c',
`cd ${work} && gh api repos/${REACT_GH_REPO}/actions/artifacts/${art.id}/zip > a.zip && ` +
`unzip -q a.zip && tar -xzf build.tgz && ${pack}`,
],
{ maxBuffer: 1 << 24 }
)
} finally {
fs.rmSync(work, { recursive: true, force: true })
}
return fs.existsSync(cached) && fs.statSync(cached).size > 1 << 20
} catch (e) {
console.error(
`CI artifact lookup failed (${e.message.slice(0, 100)}); building remotely`
)
return false
}
}
// Ref arms build remotely (dual-channel yarn build) and cache by sha.
async function ensureRefArm(arm) {
const sha = (
await execFileP('git', ['-C', REACT_REPO_LAZY(), 'rev-parse', arm.ref])
).stdout.trim()
arm.sha = sha
// Key on the build recipe too: a changed target list must not serve
// stale artifacts from the shared cache.
const recipe = crypto
.createHash('sha256')
.update(E2E_BUILD_TARGETS)
.digest('hex')
.slice(0, 6)
const cached = path.join(CACHE, `arm-${sha.slice(0, 12)}-${recipe}.tgz`)
arm.tgz = cached
if (fs.existsSync(cached)) {
console.error(`arm ${arm.name}=${sha.slice(0, 12)} (cached build)`)
return
}
fs.mkdirSync(CACHE, { recursive: true })
if (
await tryCiArtifactArm(
sha,
cached,
`tar -czf ${cached} build/oss-stable build/oss-experimental`
)
) {
console.error(`arm ${arm.name}=${sha.slice(0, 12)} from CI artifacts`)
return
}
const snap = await ensureReactBuildSnapshot(sha)
const vm = `sbench-armbuild-${Date.now().toString(36)}`
console.error(`building e2e arm ${arm.name}=${sha.slice(0, 12)} remotely...`)
await sb([
'create',
'--name',
vm,
'--snapshot',
snap,
'--vcpus',
'8',
'--timeout',
'45m',
'--non-persistent',
'--network-policy',
'allow-all',
'--tag',
'purpose=sandbox-bench',
'--silent',
])
try {
// build-all-release-channels stamps versions from git (sha + commit
// date), so the upload must be a real shallow checkout, not a bare
// archive.
const src = path.join(os.tmpdir(), `arm-src-${sha.slice(0, 12)}.tgz`)
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'arm-git-'))
const tmpRef = `refs/bench-tmp/${sha.slice(0, 12)}`
await execFileP('git', ['-C', REACT_REPO_LAZY(), 'update-ref', tmpRef, sha])
try {
await execFileP('git', ['init', '-q', work])
await execFileP('git', [
'-C',
work,
'fetch',
'-q',
'--depth',
'1',
REACT_REPO_LAZY(),
tmpRef,
])
await execFileP('git', ['-C', work, 'checkout', '-q', 'FETCH_HEAD'])
await execFileP('bash', [
'-c',
`cd ${work} && COPYFILE_DISABLE=1 tar --no-xattrs -czf ${src} .`,
])
} finally {
await execFileP('git', [
'-C',
REACT_REPO_LAZY(),
'update-ref',
'-d',
tmpRef,
])
fs.rmSync(work, { recursive: true, force: true })
}
await sb(['cp', src, `${vm}:/vercel/sandbox/src.tgz`])
fs.rmSync(src, { force: true })
// The exec stream drops on long silent commands; heartbeat keeps it
// alive during the ~10min dual-channel build. Newline before the
// backgrounded heartbeat: a trailing & after && backgrounds the
// whole chain.
await sbExec(
vm,
'40m',
`set -e
ls /vercel/sandbox/react/node_modules >/dev/null
cd /vercel/sandbox/react
find . -mindepth 1 -maxdepth 1 ! -name node_modules -exec rm -rf {} +
tar -xzf ../src.tgz
git rev-parse HEAD
(while true; do echo "hb mem=$(free -m | awk '/^Mem/{print $3}')MB $(tail -1 /tmp/build.log 2>/dev/null | cut -c1-60)"; sleep 30; done) & HB=$!
yarn build "${E2E_BUILD_TARGETS}" >/tmp/build.log 2>&1 || (kill $HB; tail -20 /tmp/build.log; exit 1)
kill $HB
tar -czf /vercel/sandbox/arm.tgz build/oss-stable build/oss-experimental
echo arm built`,
`armbuild:${arm.name}`
)
fs.mkdirSync(CACHE, { recursive: true })
const armTmp = `${cached}.tmp-${process.pid}`
await sb(['cp', `${vm}:/vercel/sandbox/arm.tgz`, armTmp])
if (fs.existsSync(armTmp)) fs.renameSync(armTmp, cached)
// sandbox cp does not reliably fail on missing remote files.
if (!fs.existsSync(cached) || fs.statSync(cached).size < 1 << 20) {
fs.rmSync(cached, { force: true })
throw new Error(
`arm ${arm.name}: downloaded artifact missing or too small`
)
}
console.error(`cached ${cached}`)
} finally {
await rmVm(vm)
}
}
async function stage(cfg, tmp) {
const nextTgzBySha = new Map()
for (const arm of cfg.arms) {
if (!nextTgzBySha.has(arm.nextSha)) {
const tgz = path.join(tmp, `next-src-${arm.nextSha.slice(0, 12)}.tgz`)
await execFileP('bash', [
'-c',
`git -C ${NEXT_REPO_LAZY()} archive ${arm.nextSha} | gzip -1 > ${tgz}`,
])
nextTgzBySha.set(arm.nextSha, tgz)
}
arm.nextTgz = nextTgzBySha.get(arm.nextSha)
if (arm.ref) {
arm.sha = (
await execFileP('git', ['-C', REACT_REPO_LAZY(), 'rev-parse', arm.ref])
).stdout.trim()
await assertCiGreen(arm.sha, arm.name, cfg.allowUngated)
await ensureRefArm(arm)
}
}
}
// ------------------------------------------------------------------ run
async function runVm(index, cfg, expSnap, outDir) {
const vm = `sbench-${cfg.label}-${index}-${Date.now().toString(36)}`
const tag = `vm${index}`
console.error(`${tag}: creating ${vm} from experiment snapshot`)
writeStatus({
vms: { ...statusState.vms, [vm]: { state: 'booting', rows: 0 } },
})
await sb([
'create',
'--name',
vm,
'--snapshot',
expSnap,
'--vcpus',
'8',
'--timeout',
'5h',
'--non-persistent',
'--network-policy',
'allow-all',
'--tag',
'purpose=sandbox-bench',
'--silent',
])
try {
const [base, cand] = cfg.arms.map((a) => a.name)
const total = cfg.blocks * cfg.runs
const benchArgs = (extra) =>
`--scenario=e2e --stream-mode=node --build=false --port=$PORT ` +
`--routes=${cfg.routes} --warmup-requests=${cfg.warmup} --serial-requests=${cfg.serial} ` +
`--load-requests=${cfg.loadRequests} --load-concurrency=${cfg.loadConcurrency} ` +
`${cfg.isolateRoutes ? '--isolate-routes=true ' : ''}${extra}`
// Both trees are pre-built in the snapshot; a run is pure
// measurement. ABBA: alternate which arm goes first per run so
// linear drift cancels within pairs, not just across them.
const loop = `set -e
VMINDEX=${index}
CPU=$(grep -m1 'model name' /proc/cpuinfo | cut -d: -f2- | sed 's/^ //')
: > /vercel/sandbox/results.jsonl
for arm in ${base} ${cand}; do
V=$(grep -aom1 "[0-9.]*-\\(canary\\|experimental\\)-[0-9a-f]*-[0-9]*" /vercel/sandbox/next-$arm/packages/next/dist/compiled/react-experimental/cjs/react.development.js || echo MISSING)
for f in ${FP_FILES.map((f) => `/vercel/sandbox/next-$arm/${f}`).join(' ')}; do [ -s "$f" ] || { echo "FP file missing: $f"; exit 1; }; done
F=$(cat ${FP_FILES.map((f) => `/vercel/sandbox/next-$arm/${f}`).join(' ')} | sha256sum | cut -c1-12)
echo "tree $arm ver=$V fp=$F"; [ "$V" != MISSING ]
eval "VER_$arm=$V; FP_$arm=$F"
done
for run in $(seq 1 ${total}); do
# Alternate within the boot AND stagger by VM index: with an odd run
# count, otherwise every boot gives the same arm the cold first slot
# and boot-level inference reads that shared bias as signal.
if [ $(((run + VMINDEX) % 2)) = 1 ]; then ORDER="${cand} ${base}"; else ORDER="${base} ${cand}"; fi
for arm in $ORDER; do
# One port per arm: a not-quite-dead server from a previous run can
# then never be measured as the other arm.
if [ "$arm" = "${base}" ]; then PORT=3720; else PORT=3721; fi
cd /vercel/sandbox/next-$arm
${cfg.benchEnv ? `export ${cfg.benchEnv}` : ':'}
pnpm bench:render-pipeline ${benchArgs('')} \
--json-out=/tmp/r.json --artifact-dir=/tmp/art-$arm-r$run >/tmp/bench.log 2>&1 \
|| (tail -20 /tmp/bench.log; exit 1)
rm -rf /tmp/art-$arm-r$run
eval "FP=\\$FP_$arm; VER=\\$VER_$arm"
node -e '
const [,run,arm,fp,ver,cpu]=process.argv;
const j=require("/tmp/r.json");
const docs=new Map((j.fullResults[0].routeDocuments??[]).map(d=>[d.route,d]));
for (const rr of j.fullResults[0].routeResults) {
if (!rr.latency) continue;
const row={block:+run,arm,run:1,fp,ver,cpu,route:rr.route,
phase:rr.phase,rps:rr.throughputRps,median:rr.latency.median,
mean:rr.latency.mean,p95:rr.latency.p95};
// Optional metrics are OMITTED when absent — a zero would pair
// against a real value as a fabricated -100% claim.
if (rr.latency.p99>0) row.p99=rr.latency.p99;
if (rr.ttfb&&rr.ttfb.median>0) row.ttfb=rr.ttfb.median;
if (rr.serverRssMb>0) row.rss=rr.serverRssMb;
if (rr.serverRssHwMb>0) row.rssHw=rr.serverRssHwMb;
const d=docs.get(rr.route)??{};
if (d.bytes>0) row.docKb=d.bytes/1024;
if (d.gzipBytes>0) row.gzipKb=d.gzipBytes/1024;
if (d.inlineFlightBytes>0) row.flightKb=d.inlineFlightBytes/1024;
// Failed requests inflate rps and vanish from latency: surface.
if (rr.errors>0) row.errors=rr.errors;
console.log(JSON.stringify(row));
}
' "$run" "$arm" "$FP" "$VER" "$CPU" > /tmp/rows.txt
cat /tmp/rows.txt >> /vercel/sandbox/results.jsonl
sed 's/^/ROW /' /tmp/rows.txt
echo "run $run $arm done"
done
done
wc -l /vercel/sandbox/results.jsonl`
let vmRows = 0
const out = await runDetached(
vm,
tag,
loop,
(row) => {
vmRows++
interimRows.push({ vm: index, ...row })
writeStatus({
vms: {
...statusState.vms,
[vm]: { state: 'measuring', rows: vmRows },
},
})
cfg.live(index, row)
},
110
)
writeStatus({
vms: {
...statusState.vms,
[vm]: { ...statusState.vms[vm], state: 'collecting' },
},
})
const local = path.join(outDir, `results-vm${index}.jsonl`)
await sb(['cp', `${vm}:/vercel/sandbox/results.jsonl`, local])
const remoteCount = Number(
out.match(/(\d+) \/vercel\/sandbox\/results\.jsonl/)?.[1] ?? NaN
)
const localCount = fs
.readFileSync(local, 'utf8')
.trim()
.split('\n')
.filter(Boolean).length
if (!Number.isFinite(remoteCount) || localCount !== remoteCount) {
throw new Error(
`${tag}: downloaded ${localCount} rows, remote reported ${remoteCount} — truncated transfer`
)
}
if (cfg.profile) {
writeStatus({
vms: {
...statusState.vms,
[vm]: { ...statusState.vms[vm], state: 'profiling' },
},
})
// Profiled passes run strictly AFTER the timed runs — profiling
// overhead must never touch the numbers.
const prof = `set -e
for arm in ${base} ${cand}; do
if [ "$arm" = "${base}" ]; then PORT=3720; else PORT=3721; fi
cd /vercel/sandbox/next-$arm
pnpm bench:render-pipeline ${benchArgs('--capture-cpu')} \
--json-out=/tmp/pr.json --artifact-dir=/vercel/sandbox/prof-$arm >/tmp/prof.log 2>&1 \
|| (tail -10 /tmp/prof.log; exit 1)
echo "profiled $arm"
done
cd /vercel/sandbox && tar -czf profiles.tgz prof-*`
// Profile capture is best-effort: a failed pass or transfer on one VM
// must not kill collection for the whole run (the timed results are
// already on disk at this point). Each VM extracts into its own
// subdirectory so VMs don't overwrite each other's prof-<arm> dirs.
try {
await sbExec(vm, '30m', prof, `${tag}:prof`)
const profTgz = path.join(outDir, `profiles-vm${index}.tgz`)
await sb(['cp', `${vm}:/vercel/sandbox/profiles.tgz`, profTgz])
if (!fs.existsSync(profTgz) || fs.statSync(profTgz).size === 0) {
throw new Error('profile tarball missing or empty after cp')
}
const vmProfDir = path.join(outDir, `prof-vm${index}`)
fs.mkdirSync(vmProfDir, { recursive: true })
await execFileP('tar', ['-xzf', profTgz, '-C', vmProfDir])
console.error(`${tag}: profiles in ${vmProfDir}`)
} catch (profErr) {
console.error(
`${tag}: profile capture failed (timed results unaffected): ${profErr.message}`
)
}
}
writeStatus({
vms: {
...statusState.vms,
[vm]: { ...statusState.vms[vm], state: 'done' },
},
})
return local
} finally {
if (!cfg.keep) await rmVm(vm)
else console.error(`${tag}: kept ${vm}`)
}
}
// -------------------------------------------------------------- analyze
function pairedP(deltas) {
const n = deltas.length
if (n < 2) return 1
const mean = deltas.reduce((a, b) => a + b, 0) / n
const sd = Math.sqrt(
deltas.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1)
)
if (sd === 0) return mean === 0 ? 1 : 0
const t = Math.abs(mean / (sd / Math.sqrt(n)))
const df = n - 1
const pdf = (x) => Math.exp(-((df + 1) / 2) * Math.log(1 + (x * x) / df))
let integral = 0
const STEP = 0.001
for (let x = t; x < t + 60; x += STEP) integral += pdf(x + STEP / 2) * STEP
let norm = 0
for (let x = 0; x < 80; x += STEP) norm += pdf(x + STEP / 2) * STEP
return Math.min(1, integral / norm)
}
function analyze(cfg) {
// Collected JSONL + profiles land in the results db first; the stats
// read the db and nothing else. Import is idempotent, verify is
// mechanical (sqlite integrity, pairing shape, artifact hashes).
const db = openDb()
const { runId, samples, artifacts } = importRun(db, outDir)
const { failures, notes } = verifyDb(db, runId)
for (const n of notes) console.log(`note: ${n}`)
if (failures.length) {
throw new Error(`results db verify FAILED:\n ${failures.join('\n ')}`)
}
console.error(
`results db: ${samples} samples, ${artifacts} artifacts as ${runId} (verify ok)`
)
const rows = loadRows(db, runId)
const [base, cand] = cfg.arms.map((a) => a.name)
printRunContext((m) => console.log(m), cfg.runContext)
// ttfb/rss/rssHw only exist on next refs carrying the bench-client-
// metrics harness; metrics without data are skipped.
analyzeE2eRows(rows, base, cand, [
'rps',
'median',
'mean',
'p95',
'ttfb',
'docKb',
'gzipKb',
'flightKb',
'rss',
'rssHw',
])
}
// ----------------------------------------------------------------- main
// Dry-run: resolve as much as possible with local-only operations and
// print the execution plan instead of touching the sandbox. Degrades
// gracefully when prerequisites (clones, config) are missing so it can
// be used to sanity-check a setup before committing to a real run.
async function plan(cfg) {
const lines = []
lines.push(
`mode: ${cfg.pr ? `react PR ${cfg.pr}` : cfg.arms.length ? 'react A/B' : cfg.nextPr ? `next PR ${cfg.nextPr}` : 'next A/B'}`
)
lines.push(
`scope: team=${CONFIG.team ?? '<UNSET — ask user, then: node config.mjs set team=... project=...>'} project=${CONFIG.project ?? '<UNSET>'}`
)
lines.push(`next repo: ${NEXT_REPO_LAZY()}`)
if (cfg.pr || cfg.arms.length || cfg.reactRef) {
const rr = REACT_REPO_LAZY()
lines.push(
`react repo: ${rr}${fs.existsSync(path.join(rr, '.git')) ? '' : ' (would clone on real run)'}`
)
} else {
lines.push('react: vendored in each Next ref (no react checkout needed)')
}
let arms
try {
arms = await resolveArms(cfg)
for (const arm of arms) {
if (!arm.ref) {
lines.push(
`arm ${arm.name}: react=vendored next=${arm.nextSha.slice(0, 12)}`
)
continue
}
let reactSha = '?'
try {
reactSha = (
await execFileP('git', [
'-C',
REACT_REPO_LAZY(),
'rev-parse',
arm.ref,
])
).stdout
.trim()
.slice(0, 12)
} catch {}
const cached =
reactSha !== '?' &&
fs.existsSync(path.join(CACHE, `arm-${reactSha}.tgz`))
lines.push(
`arm ${arm.name}: react=${arm.ref} (${reactSha}${cached ? ', build cached' : ', would build remotely ~15m'}) next=${arm.nextSha.slice(0, 12)}`
)
lines.push(
` CI gate: react commit must be CI-green (or --allow-ungated after sandbox-gate.mjs)`
)
}
} catch (e) {
lines.push(
`arms: unresolved in dry-run (${e.message.split('\n')[0].slice(0, 120)})`
)
}
lines.push(
`then: experiment snapshot (cached by content key; ~45m if cold) -> ` +
`${cfg.vms} VMs x ${cfg.blocks * cfg.runs} paired ABBA runs, routes ${cfg.routes}` +
`${cfg.isolateRoutes ? ' (isolated)' : ''}${cfg.benchEnv ? ` env ${cfg.benchEnv}` : ''}`
)
lines.push(
`then: boot-level analysis (n=${cfg.vms} boots) -> claims at p<0.01 on A/A-validated infra`
)
console.log(lines.map((l) => `[dry-run] ${l}`).join('\n'))
}
const cfg = parseArgs()
if (cfg.dryRun) {
await plan(cfg)
process.exit(0)
}
fs.mkdirSync(CACHE, { recursive: true })
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sandbox-e2e-'))
const outDir = path.join(CACHE, `run-${cfg.label}-${Date.now().toString(36)}`)
fs.mkdirSync(outDir, { recursive: true })
// stdout, not stderr: task UIs preview stdout, and these are the lines
// a human watching the task needs.
console.log(`run dir: ${outDir}`)
statusFile = path.join(outDir, 'status.json')
// Long phases (remote builds, snapshot assembly) are otherwise silent
// on stdout, which reads as a hung task in any UI that previews output.
// A periodic one-line digest keeps the task legible without spam.
const interimRows = []
// Interim display: running effect + directional confidence
// P(effect > 0 | boots so far) — the Student-t posterior under a flat
// prior, i.e. "how sure are we the candidate is actually faster".
// Display only; runs complete their allocation and claims come from
// the final analysis.
function interimSummary() {
if (interimRows.length === 0 || !statusState.arms) return ''
const [base, cand] = cfg.arms.map((a) => a.name)
const cells = []
for (const route of cfg.routes.split(',')) {
for (const phase of ['single-client', 'under-load']) {
const perBoot = []
for (const vmIdx of new Set(interimRows.map((r) => r.vm))) {
const deltas = []
for (const r of interimRows.filter(
(x) =>
x.vm === vmIdx &&
x.route === route &&
x.phase === phase &&
x.arm === cand
)) {
const b = interimRows.find(
(x) =>
x.vm === vmIdx &&
x.route === route &&
x.phase === phase &&
x.arm === base &&
x.block === r.block &&
x.run === r.run
)
if (b && b.rps > 0 && r.rps > 0) deltas.push((r.rps - b.rps) / b.rps)
}
if (deltas.length)
perBoot.push(deltas.reduce((a, b) => a + b, 0) / deltas.length)
}
if (perBoot.length < 4) continue
const mean = perBoot.reduce((a, b) => a + b, 0) / perBoot.length
const pTwo = tTestP(perBoot)
const conf = Math.max(1 - pTwo / 2, pTwo / 2)
const confStr = conf >= 0.999 ? '>99.9%' : `${(conf * 100).toFixed(0)}%`
const label = `${route} ${phase === 'single-client' ? 'serial' : 'load'}:`
cells.push(
` ${label.padEnd(19)} ${mean > 0 ? '+' : ''}${(mean * 100).toFixed(1)}% rps (${confStr} confidence)`
)
}
}
if (cells.length === 0) return ''
const boots = new Set(interimRows.map((r) => r.vm)).size
return `\n interim vs base (${boots} boots):\n` + cells.join('\n')
}
const digest = setInterval(() => {
const vms = Object.values(statusState.vms ?? {})
const rows = vms.reduce((a, v) => a + (v.rows ?? 0), 0)
const states = {}
for (const v of vms) states[v.state] = (states[v.state] ?? 0) + 1
const vmSummary = Object.entries(states)
.map(([k, n]) => `${n} ${k}`)
.join(', ')
console.log(
`progress: ${statusState.phase}` +
(statusState.rowsExpected
? ` — rows ${rows}/${statusState.rowsExpected}`
: '') +
(vmSummary ? ` (${vmSummary})` : '') +
interimSummary()
)
}, 120_000)
digest.unref?.()
writeStatus({
label: cfg.label,
phase: 'resolving arms',
pid: process.pid,
startedAt: new Date().toISOString(),
vms: {},
rowsExpected: null,
})
try {
cfg.arms = await resolveArms(cfg)
cfg.runContext = await describeRun(cfg)
printRunContext((m) => console.error(m), cfg.runContext)
fs.writeFileSync(
path.join(outDir, 'meta.json'),
JSON.stringify(
{
base: cfg.arms[0].name,
cand: cfg.arms[1].name,
label: cfg.label,
nextRef: cfg.nextRef,
vms: cfg.vms,
blocks: cfg.blocks,
runs: cfg.runs,
routes: cfg.routes,
pr: cfg.runContext.pr,
arms: cfg.runContext.arms,
benchEnv: cfg.benchEnv || undefined,
},
null,
2
)
)
cfg.live = makeLive(
cfg.arms[0].name,
['rps', 'median', 'p95'],
(r) => `${r.route} ${r.phase}`
)
await stage(cfg, tmp)
console.error(
`arms: ${cfg.arms.map((a) => `${a.name}=${armId(a)}`).join(' ')}`
)
writeStatus({
phase: 'building arms + experiment snapshot',
arms: cfg.arms.map((a) => `${a.name}=${armId(a)}`),
})
const expSnap = await ensureExperimentSnapshot(cfg)
writeStatus({
phase: 'measuring',
expSnap,
rowsExpected:
cfg.vms * cfg.blocks * cfg.runs * 2 * cfg.routes.split(',').length * 2,
})
if (cfg.prepare) {
console.error(
`prepared: arms + experiment snapshot ${expSnap}; exiting (--prepare)`
)
process.exit(0)
}
await Promise.all(
Array.from({ length: cfg.vms }, (_, i) => runVm(i, cfg, expSnap, outDir))
)
writeStatus({ phase: 'analyzing' })
console.error(`results in ${outDir}`)
analyze(cfg)
writeStatus({ phase: 'done' })
} catch (err) {
// Leave a machine-readable trace: bench-status.mjs reports dead runs
// and the right recovery action from this.
writeStatus({
phase: 'failed',
error: String((err && err.message) || err).slice(0, 500),
})
throw err
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}