[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]>
This commit is contained in:
dan
2026-07-25 17:46:38 +01:00
committed by GitHub
co-authored by Claude Fable 5
parent e1fbb1800f
commit 1a028f7f03
11 changed files with 3884 additions and 0 deletions
+298
View File
@@ -0,0 +1,298 @@
---
name: sandbox-bench
description: >
Benchmark React or Next.js changes on Vercel Sandbox VMs with paired
A/B statistics: react PR/commit vs base, or Next.js PR/commit vs base,
measured end-to-end through the bench/render-pipeline app (rps,
latency, p95; TTFB, RSS and document/Flight bytes when the Next
side captures them). Use whenever the user asks to bench, perf
test, or A/B a React PR, a react-server-dom / Flight / vendored React
change, or a Next.js PR ("is this PR faster", "does this regress
RSC?", "measure the perf impact of <commit>"), even if they don't say
"benchmark" — any request to quantify a server-side performance
difference between two revisions belongs here. Runs remotely
(laptop-free), applies correctness gates before measuring, and
reports boot-level confidence intervals.
metadata:
internal: true
---
# Sandbox bench: paired A/B perf runs for React and Next.js changes
Measures what a change is actually worth, end to end: two revisions
("arms") built into otherwise-identical Next.js apps, exercised by the
`bench/render-pipeline` harness on Vercel Sandbox VMs, compared with
paired statistics that treat the VM boot as the unit of replication.
All heavy work happens on sandbox VMs; the laptop only orchestrates.
Scripts live in `scripts/` next to this file and run from anywhere.
Arms are git refs, resolved in cached clones of react and next.js;
the Next side defaults to canary. Everything is cached
content-addressed: first use of a new pair builds caches (~45-60 min
extra, once); later runs boot straight into measurement.
## One-time setup
1. `node scripts/config.mjs show` — if it reports NOT CONFIGURED, ask
the user which Vercel **team** and **project** the sandbox VMs
should run under (these are billed resources; never guess, never
default), then `node scripts/config.mjs set team=<slug> project=<name>`.
Config lives in `~/.config/sandbox-bench/config.json` — never commit
team/project names into the repo.
2. The Vercel CLI session must have access to that team. On a 403,
stop launching (don't retry through it) and check whether access is
already back: `vercel whoami --scope <team-slug>` plus one scoped
read call (e.g. `vercel sandbox ls`) — grants drop and recover on
their own, and a transient 403 needs no login at all. If
verification still fails, run `vercel login <team-slug>` yourself
as a background task (the token lives with the CLI session, not
with the user). It opens a browser/device confirmation — relay the
URL if one is printed — but keep re-running the verification pair
every minute or two while it waits: access often returns before
the login flow reports success, and once verification passes, kill
the pending login and resume. After a 403 outage, expect in-flight runs to have died:
run `node scripts/bench-status.mjs` and follow its recovery
actions (measurement VMs will have hit their ~5h timeout if the
outage was long — those cells need relaunching, not collecting).
3. react and next.js clones land in the cache on first use (or point
`reactRepo`/`nextRepo` in the config at existing checkouts).
Before the first real run with a new configuration, sanity-check the
plan with `--dry-run` (prints what would happen, touches nothing).
## Workflow
### 1. Resolve what's being compared
- **React PR**: `--pr <url|number>` — base is computed automatically
(merge-base of the PR head with react main).
- **React refs**: `--arms base=<ref>,cand=<ref>` — base FIRST. For a
multi-commit branch, base is the merge-base with main, not `cand^`.
- **Next.js PR**: `--next-pr <url|number>`. The React side defaults to
whatever each Next ref vendors (that's what would ship); pass
`--react-ref` only to pin both arms to one specific React build.
- **Next refs**: `--next-arms base=<ref>,cand=<ref>`.
Exactly one side varies; the other is identical in both arms. That
isolation is what makes the numbers attributable — never vary both.
### 2. Gate correctness before spending bench compute
A bench number from an arm that fails its own tests is meaningless.
For any arm that is not already CI-green upstream (hand-assembled
branches, cherry-picks with resolved conflicts, local commits):
```sh
node scripts/sandbox-gate.mjs --arms cand=<ref>
```
The bench itself enforces the primary gate: every react arm's commit
must have green CI on the react repo, checked automatically before any
build or VM is spent. PRs and main-history commits normally satisfy
this with no extra work. For local or unpushed refs (no CI exists),
gate on a VM with sandbox-gate.mjs and then pass --allow-ungated to
the bench. The VM gate runs the full test suite in prod mode (the
channel that gets benched). PASS requires seeing the actual test
counts in the output. If a gate fails, report the failures and stop —
do not bench a broken arm. Each arm is gated in its own
lockfile's environment. Bench the exact sha the gate prints (a branch
ref can move between gate and bench).
### 3. Launch the bench (background, non-blocking)
```sh
bash -c 'node scripts/sandbox-e2e.mjs --pr <url> --label <slug> \
2>&1 | grep --line-buffered -v "^live "; exit ${PIPESTATUS[0]}'
```
- Run it as a background task and proceed on its completion
notification. Never hold a foreground wait; never poll in a loop —
the rule is about control flow, not status relay: reading the
output tail to answer "how's it going" is always fine.
- The harness handles the invariants internally: both arms in the same
VM, interleaved ABBA, paired per (vm, run); detached remote
execution (transport drops don't kill runs); build fingerprints
recorded in every result row.
- `live ...` lines are streaming estimates for progress display only.
Never stop a run early because a live p-value looks good, and never
report a live number — sequential peeking manufactures false
positives. Only the final analysis counts.
- Defaults (16 VMs × 2 paired runs) implement the methodology; don't
reduce VM count to save time — boots are the unit of inference, and
fewer boots means wider intervals, not faster answers.
- Sandbox compute is internal capacity, not a budget: launch, relaunch,
and confirm runs without asking about cost or shrinking them to
save it.
- The Next side's default, `canary`, is the latest published canary
release (the launcher prints its version and sha), so repeat benches
reuse the built snapshot until a new canary ships.
- Useful flags: `--bench-env KEY=VALUE` (runtime-only env for the
bench process — it does NOT affect the snapshot's app build), `--isolate-routes`
(tail investigations), `--no-profile` (skip the CPU capture that
runs by default after the timed runs), `--prepare` (build caches
only — use when two cells will share an arm, to avoid duplicate
builds racing).
- CPU profiles are captured by default: one profile pass per arm runs
strictly AFTER the timed runs (it cannot touch the numbers), costs
~45-60 min extra VM wall-clock, and lands in `<runDir>/prof-vm<N>/`
as standard V8 `.cpuprofile` files. Cross-VM profile diffs are
highly stable (observed 16/16 sign agreement on real movers), so one
profiled cell suffices to rank hot paths. Analysis caveats:
aggregate by (functionName, line, column) — bare minified names
collide across the bundle — and never diff arms by minified name
(the minifier renames between builds); match positions or code
snippets instead.
- The bench exercises Next's node-streams path
(`__NEXT_USE_NODE_STREAMS` is inlined as true for the node runtime
at build time). React changes that only touch the EDGE stream
configs are not exercised end-to-end and will (correctly) bench as
no detected difference.
### 4. Read the result like a skeptical data scientist
The goal is the truth about the change, not making its author feel
good. The final analysis prints, per route/phase/metric, the
boot-level mean, ±95% CI, and p across boots. Apply the policy in
[references/methodology.md](references/methodology.md):
- Claim only boot-level p < 0.01, with the CI, on an A/A-validated
team/config (see methodology).
- The PR is a hypothesis, not an explanation. Claims come from the
analysis output alone. When the numbers agree with the PR's story,
check whether the captured data actually discriminates that
mechanism from alternatives — a latency win attributed to smaller
payloads should come with a document-bytes delta; if the bytes
didn't move, the story doesn't hold and the report says so.
- Use every captured metric, and voice anything that does not add up:
one metric family moving against the others, effects with no
byte-level or RSS trace, throughput moving without latency,
sign flips across boots. An inconsistency you cannot explain
belongs in the report, not in the drawer.
- The `within-run p` shown in brackets is a diagnostic, never a claim.
- Check the fingerprint header first: two distinct fingerprints = valid
A/B; "inconsistent fingerprints" = invalid, report no numbers. The
fingerprint hashes both bundlers' compiled server files — arms
touching only client files can still legitimately show identical
fingerprints with different version strings.
- Per-boot values are printed; if boots disagree in sign, say so.
- Any claim that will drive a decision gets one independent
confirmation run before it's stated as fact.
Re-analyze any past run without re-running it:
`node scripts/bench-analyze.mjs <runDir>`.
### 5. Report
Name what was measured with links: the PR title (printed in the
analysis header, stored in meta.json) linking to the PR; for ref
arms, the commit title. Lead with a table of the significant cells,
each row carrying the effect with its unit, the CI, and p:
```
## [<PR title>](<PR url>) — e2e, Vercel Sandbox (x86 Xeon), <n> boots
Significant (boot-level p < 0.01, A/A-validated):
| cell | effect | 95% CI | p |
|---|---|---|---|
| /dashboard under load | +14.4% throughput (req/s) | ±3.2% | <0.0001 |
| /dashboard serial | −10.7% median latency (ms) | ±0.6% | <0.0001 |
No detected difference: <every cell not in the table, by name>.
Flags: <cells at 0.01 ≤ p < 0.05, sign disagreements across boots,
fingerprint caveats, anything that does not add up>
```
One row per cell: rps and median restate each other, so report the
throughput number (add a p95 row only when the tail moves differently
from the median). Document metrics (raw/gzip/Flight KB) get their own
rows when they differ — they are the mechanism evidence. When the
Next side predates the document-metrics harness (vercel/next.js#95828)
those cells are absent; say so instead of silently reporting less. State the platform next to the numbers. Magnitudes
are platform-dependent (GC share differs by CPU); direction and
mechanism transfer, percentages do not. Never present a
noise-compatible delta as a small win or loss — it is "no detected
difference".
## Results database
Every collected run lands in one SQLite file,
`~/.cache/sandbox-bench/results.db` — raw measurements and artifacts
(CPU profiles, logs) only, written exclusively by the importer, never
by hand. The launcher imports and verifies automatically at
collection; `bench-analyze` reads the db and nothing else, so every
statistic is a pure function of it. Numbers in reports come from the
analysis output verbatim — never retype, recompute, or aggregate them
yourself.
- `node scripts/bench-db.mjs ls` — all runs with sample/artifact counts.
- `node scripts/bench-db.mjs verify [runId]` — integrity checks:
sqlite-level, referential, one fingerprint per arm, paired sample
counts, artifact sha256. Run it before drawing on old data.
- `node scripts/bench-db.mjs export out.db <runId...>` — cut a
self-contained db of specific runs (with their profiles) to send to
someone. It opens in any SQLite tool.
- `node scripts/bench-analyze.mjs <runId>` — re-analyze anything in
the db; a run-dir argument imports it first.
## Keeping the user informed
The launcher narrates itself on stdout: launch facts first (run dir,
arms, CI verdicts), then a progress line every ~2 minutes with rows
collected and interim per-route effects with confidence. Relay to the
user: the run dir and expected duration right after launching,
notable interim shifts if they ask how it's going, and the full
verdict from the final analysis when the completion notification
arrives. The analysis names metrics that were `not captured on this
run` — repeat that in the verdict when it limits what the data can
say (document metrics absent means the payload mechanism is
unverified, not verified-identical).
While a run is active, open any reply with a one-line status per run:
read the tail of the launcher's output and quote its latest progress
line. If the session supports timed wakeups or reminders, schedule a
check at each expected transition (arm builds -> experiment snapshot
-> measuring, then every ~15 minutes of measurement) and post the
progress line; if not, say when the next update will arrive so
silence is never ambiguous. Interim effects in progress lines are
streaming estimates — share them as progress, never as claims.
If a launcher process dies (session teardown, crash), the remote VMs
keep executing their measurement loops — the data is not lost. `node
scripts/bench-collect.mjs <runDir>` reconnects, waits for the loops,
downloads the results, cleans up, and analyzes. Run it before the VMs
hit their ~5h timeout.
## Failure recovery
- **First move, always: `node scripts/bench-status.mjs`.** Session
restarts silently kill background launchers while their detached VMs
keep measuring, and a dead launcher's log still ends with a
healthy-looking progress line — never infer liveness from log tails
or task output files. bench-status checks each run's recorded
launcher pid and prints the per-run recovery action (running /
collect now / relaunch). Run it at the start of any session that
expects work in flight, after any crash, and before telling the user
what is or isn't running. Launcher crashes are also recorded in the
run's status.json (`phase: "failed"` plus the error).
- **Interrupted local process**: remote VMs keep running detached.
`vercel sandbox list` (with the configured team/project) to find
them; poll each VM's `/vercel/sandbox/loop.done`, `cp` its
`results.jsonl` down when done, then remove the VM and analyze with
`bench-analyze.mjs`.
- **Leaked VMs** after any crash: `node scripts/sandbox-sweep.mjs`
lists this skill's VMs (matched by sbench-\* name AND the
purpose=sandbox-bench tag, and only when older than --min-age-hours,
default 3, so healthy in-flight runs are never touched); `--yes`
removes them by exact listed name.
- **Flaky uploads/transports**: the harnesses size-check artifacts and
abort on truncation. A failed cell is safe to relaunch; caches make
the retry cheap. Don't relaunch two cells that need the same uncached
arm at the same moment — they'll race to build it; use `--prepare`
first instead.
## Cost expectations (set these with the user before big runs)
Per cell at defaults: ~18 VMs (8 measurement + build/snapshot VMs),
~1-2h wall-clock cold, ~30-60 min warm. A/A calibration and
confirmation runs are extra cells. VMs are billed to the configured
team — for anything beyond a single PR check, confirm scope first.
@@ -0,0 +1,103 @@
# Statistical methodology
The design decisions below are what make the numbers trustworthy.
They follow standard rigorous-benchmarking practice (Kalibera & Jones,
"Rigorous Benchmarking in Reasonable Time"; Georges et al.,
"Statistically Rigorous Java Performance Evaluation"; JMH's forks
model) — don't relax them to save wall-clock without understanding
what they protect against.
## The VM boot is the unit of replication
JIT state, code layout, GC rhythm, host hardware, and phase order are
random effects fixed at boot time. Iterations within a boot share all
of them, so they are not independent samples — treating them as
independent produces confident nonsense (a p<1e-4 effect in one run
that vanishes in the next). The harness therefore:
- computes each metric's delta per boot (mean of that boot's paired
within-boot deltas), and
- runs t-based inference across boots (`bench-stats.mjs`); the CI and
p-value you see are boot-level.
The `within-run p` printed in brackets is a diagnostic only. It tells
you the pairing worked; it cannot support a claim.
Allocation follows from this: more boots × fewer runs beats fewer
boots × more runs at fixed budget, and boots run in parallel — adding
them costs money, not wall-clock, so don't be shy with them when
sensitivity matters (CI shrinks with sqrt of boots). Defaults: e2e 16
VMs × 1 block × 2
runs. Both arms always run in the SAME VM, interleaved ABBA, paired
per (vm, run) — hosts differ by up to ~20%, so cross-VM comparisons
are meaningless; VMs exist to replicate, not to compare.
## Claims
- A claim requires boot-level p < 0.01, reported with its CI, on a
team/config whose boot-level inference an A/A run has validated.
- Anything that will drive a decision (merge, revert, ship) gets one
independent confirmation cell first (standard perf-CI practice —
Mozilla/Chrome retrigger, MongoDB change-point detection — nobody
alerts off one detection).
- Everything else is "noise-compatible", reported as no detected
difference — not as a small effect.
## Percentiles
A per-run p95/p99 from a small load phase is a near-max order
statistic, not a distribution estimate. Compare percentiles only at
boot level; p99 needs ~1k+ samples per boot to mean anything at all.
Tail effects are also where phase carryover lives (a route's serial
tail depends on the previous route's load phase) — investigate tails
with `--isolate-routes`, which restarts the server between routes.
## A/A validation
Before claiming anything on a new team/config, run an A/A cell (same
ref as both arms) at default allocation:
```sh
node scripts/sandbox-e2e.mjs --arms base=<ref>,cand=<ref> --label aa
```
The A/A validates the inference itself: with identical arms, no
route/phase/metric cell should reach p < 0.01 (beyond the nominal
false-positive rate). Cells that do mean boots are sharing conditions
and the CIs are too narrow — fix the allocation (more spread-out
boots) before claiming; a magnitude gate is not a substitute. The A/A
CIs also tell you what effect sizes this config can resolve. Re-run
the A/A when the platform changes (VM hardware generation, node
version, bench app changes).
## Interim progress display
The launcher's progress digest shows, per route/phase, the running rps
effect and a directional confidence — P(candidate is actually
faster/slower | boots so far), the Student-t posterior under a flat
prior (not a p-value). It appears from 4 completed boots. Display
only: runs complete their planned allocation, and claims come only
from the final fixed-n analysis.
## Fingerprints
Every result row records a build fingerprint (dist-file hash + version
string) read from the tree that actually served requests. The analysis
header verifies them: inconsistent fingerprints within an arm
invalidate the run (a VM measured the wrong build). Identical
fingerprints across arms with different version strings can be
legitimate — the hash covers the compiled server files, so client-only
changes don't move it; check the version strings before declaring an
accidental A/A.
## Honest reporting
- State the platform (e.g. "x86 Xeon sandbox VMs") next to any number.
Magnitudes are platform-dependent — GC's share of request time
scales with CPU speed — so direction and mechanism transfer between
platforms, percentages do not.
- Print per-boot values; if boots disagree in sign, the mean is not a
finding, and say so.
- Never conclude anything from the live streaming estimates. Peeking
at sequential results and stopping when p looks good is a classic
way to manufacture false positives.
@@ -0,0 +1,83 @@
#!/usr/bin/env node
// Post-hoc (re-)analysis of runs at the boot level. Reads ONLY the
// results db: a run-dir argument is imported (idempotently) and
// verified first, a bare run id analyzes what the db already holds.
// node bench-analyze.mjs <runDir|runId> [--base <arm>] [--cand <arm>]
import fs from 'node:fs'
import path from 'node:path'
import { analyzeE2eRows } from './bench-stats.mjs'
import { openDb, importRun, loadRows, runMeta, verify } from './bench-db.mjs'
const argv = process.argv.slice(2)
const target = argv.find((a) => !a.startsWith('--'))
const get = (name) => {
const i = argv.indexOf(name)
return i >= 0 ? argv[i + 1] : undefined
}
if (!target) {
console.error(
'usage: node bench-analyze.mjs <runDir|runId> [--base arm] [--cand arm]'
)
process.exit(1)
}
const db = openDb()
let runId
if (fs.existsSync(target)) {
runId = importRun(db, target).runId
} else {
runId = path.basename(target)
}
const { failures, notes } = verify(db, runId)
for (const n of notes) console.log(`note: ${n}`)
if (failures.length) {
console.error(`RESULTS DB VERIFY FAILED:\n ${failures.join('\n ')}`)
process.exit(1)
}
const rows = loadRows(db, runId)
const arms = [...new Set(rows.map((r) => r.arm))]
if (arms.length !== 2) {
console.error(`expected exactly 2 arms, found: ${arms.join(', ')}`)
process.exit(1)
}
// Which arm is base? Row order cannot answer this (ABBA runs the
// candidate first), so require an explicit source: the run's meta,
// a conventional name, or the --base flag.
const meta = runMeta(db, runId)?.meta
const base =
get('--base') ??
(meta && arms.includes(meta.base) ? meta.base : undefined) ??
['base', 'synced'].find((n) => arms.includes(n))
if (!base) {
console.error(
`cannot determine the base arm from {${arms.join(', ')}}: ` +
'no meta, no arm named "base"/"synced" — pass --base <arm>'
)
process.exit(1)
}
const cand = get('--cand') ?? arms.find((a) => a !== base)
const boots = new Set(rows.map((r) => r.vm)).size
console.log(`${runId} boots=${boots} arms: ${base} (base) vs ${cand}`)
if (meta?.pr)
console.log(
`PR: ${meta.pr.title ? `"${meta.pr.title}" — ` : ''}${meta.pr.url}`
)
for (const a of meta?.arms ?? [])
if (a.title) console.log(` ${a.name}: "${a.title}"`)
if (rows[0].route === '') {
// Micro (sandbox-ab) samples: phase holds the payload name.
analyzeE2eRows(rows, base, cand, ['mean', 'p50', 'p95', 'p99', 'gcMs'])
} else {
// No p99 for e2e: the load phases are far too small for it.
analyzeE2eRows(rows, base, cand, [
'rps',
'median',
'mean',
'p95',
'ttfb',
'docKb',
'gzipKb',
'flightKb',
'rss',
'rssHw',
])
}
@@ -0,0 +1,142 @@
#!/usr/bin/env node
// Recover a run whose local launcher died (session teardown, crash):
// the remote VMs keep executing their detached loops regardless, so the
// data is usually still there. Reads the run's status.json for the VM
// names, waits for each VM's loop to finish, downloads its results,
// removes the VM, and runs the boot-level analysis.
//
// node bench-collect.mjs <runDir> [--deadline-min 280]
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import fs from 'node:fs'
import path from 'node:path'
import { loadConfig, sandboxScope } from './config.mjs'
const execFileP = promisify(execFile)
const CONFIG = loadConfig()
const SCOPE = sandboxScope(CONFIG)
const argv = process.argv.slice(2)
const dir = argv.find((a) => !a.startsWith('--'))
const deadlineMin = argv.includes('--deadline-min')
? Number(argv[argv.indexOf('--deadline-min') + 1])
: 280
if (!dir || !fs.existsSync(path.join(dir, 'status.json'))) {
console.error('usage: node bench-collect.mjs <runDir with status.json>')
process.exit(1)
}
const statusPath = path.join(dir, 'status.json')
const st = JSON.parse(fs.readFileSync(statusPath, 'utf8'))
const vms = Object.keys(st.vms ?? {})
function writeStatus(patch) {
Object.assign(st, patch, { updatedAt: new Date().toISOString() })
fs.writeFileSync(statusPath, JSON.stringify(st, null, 2))
}
writeStatus({ phase: 'collecting (recovery)', pid: process.pid })
if (vms.length === 0) {
console.error(
'status.json lists no VMs — the run died before measurement; nothing to collect. ' +
'Check for a leaked snapshot builder with sandbox-sweep.mjs and relaunch the run.'
)
process.exit(1)
}
async function sb(args) {
const scoped = ['sandbox', ...args]
const sep = scoped.indexOf('--')
scoped.splice(sep < 0 ? scoped.length : sep, 0, ...SCOPE)
const { stdout } = await execFileP(CONFIG.vercelBin, scoped, {
maxBuffer: 1 << 26,
})
return stdout
}
const pending = new Map(vms.map((vm, i) => [vm, i]))
const collected = []
const deadline = Date.now() + deadlineMin * 60000
while (pending.size > 0 && Date.now() < deadline) {
for (const [vm, idx] of [...pending]) {
let out
try {
out = await sb([
'exec',
vm,
'--timeout',
'2m',
'--',
'bash',
'-c',
'cat /vercel/sandbox/loop.done 2>/dev/null || echo RUNNING',
])
} catch (e) {
// VM gone (timed out or removed): its data is lost; say so and move on.
console.error(
`${vm}: unreachable (${e.message.split('\n')[0].slice(0, 100)}) — boot lost`
)
st.vms[vm] = { ...st.vms[vm], state: 'lost' }
writeStatus({})
pending.delete(vm)
continue
}
const state = out.trim().split('\n').pop()
if (state === 'RUNNING') continue
if (state === 'LOOPOK') {
const local = path.join(dir, `results-vm${idx}.jsonl`)
await sb(['cp', `${vm}:/vercel/sandbox/results.jsonl`, local])
if (fs.existsSync(local) && fs.statSync(local).size > 500) {
collected.push(local)
st.vms[vm] = { ...st.vms[vm], state: 'done' }
writeStatus({})
console.error(`${vm}: collected -> ${local}`)
} else {
console.error(`${vm}: results too small; keeping VM for inspection`)
pending.delete(vm)
continue
}
} else {
const tail = await sb([
'exec',
vm,
'--timeout',
'2m',
'--',
'bash',
'-c',
'tail -c 1500 /vercel/sandbox/loop.log',
])
console.error(`${vm}: loop ended ${state}; log tail:\n${tail}`)
}
try {
await sb(['rm', vm])
console.error(`${vm}: removed`)
} catch (e) {
console.error(
`${vm}: rm failed: ${e.message.split('\n')[0].slice(0, 100)}`
)
}
pending.delete(vm)
}
if (pending.size > 0) await new Promise((r) => setTimeout(r, 60000))
}
for (const [vm] of pending)
console.error(`${vm}: deadline exceeded, left running`)
if (collected.length === 0) {
console.error('nothing collected')
process.exit(1)
}
writeStatus({
phase: `done (recovered ${collected.length}/${vms.length} boots)`,
})
console.log(`\ncollected ${collected.length}/${vms.length} boots; analysis:`)
const { stdout } = await execFileP(
'node',
[
path.join(
path.dirname(new URL(import.meta.url).pathname),
'bench-analyze.mjs'
),
dir,
],
{ maxBuffer: 1 << 24 }
)
console.log(stdout)
@@ -0,0 +1,574 @@
#!/usr/bin/env node
// Canonical store for all bench data: one SQLite file holding raw
// measurements and artifacts, written only by this importer — no
// derived statistics, no hand-entered numbers. The per-VM JSONL files
// in a run dir are the wire format; importing is idempotent (a run
// re-imports as a whole). Stats are a pure function of this DB
// (bench-analyze.mjs reads it and nothing else).
//
// node bench-db.mjs import <runDir...>
// node bench-db.mjs verify [runId]
// node bench-db.mjs export <out.db> <runId...>
// node bench-db.mjs ls
import fs from 'node:fs'
import path from 'node:path'
import zlib from 'node:zlib'
import crypto from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { DatabaseSync } from 'node:sqlite'
import { loadConfig } from './config.mjs'
const SCHEMA = `
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS runs(
run_id TEXT PRIMARY KEY,
label TEXT,
kind TEXT,
started_at TEXT,
imported_at TEXT NOT NULL,
harness_sha TEXT,
meta TEXT
);
CREATE TABLE IF NOT EXISTS samples(
sample_id INTEGER PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
boot INTEGER NOT NULL,
arm TEXT NOT NULL,
block INTEGER NOT NULL,
run INTEGER NOT NULL,
route TEXT NOT NULL,
phase TEXT NOT NULL,
fingerprint TEXT,
version TEXT,
cpu TEXT,
errors INTEGER NOT NULL DEFAULT 0,
UNIQUE(run_id, boot, arm, block, run, route, phase)
);
CREATE TABLE IF NOT EXISTS measurements(
sample_id INTEGER NOT NULL REFERENCES samples(sample_id) ON DELETE CASCADE,
metric TEXT NOT NULL,
value REAL NOT NULL,
UNIQUE(sample_id, metric)
);
CREATE TABLE IF NOT EXISTS artifacts(
artifact_id INTEGER PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
boot INTEGER,
arm TEXT,
name TEXT NOT NULL,
kind TEXT,
sha256 TEXT NOT NULL,
bytes INTEGER NOT NULL,
data BLOB NOT NULL,
UNIQUE(run_id, boot, arm, name)
);
CREATE TABLE IF NOT EXISTS boots(
run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
boot INTEGER NOT NULL,
vm_name TEXT,
UNIQUE(run_id, boot)
);
CREATE TABLE IF NOT EXISTS source_files(
run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
name TEXT NOT NULL,
sha256 TEXT NOT NULL,
rows INTEGER NOT NULL,
UNIQUE(run_id, name)
);
CREATE INDEX IF NOT EXISTS idx_samples_run ON samples(run_id);
CREATE INDEX IF NOT EXISTS idx_measurements_sample ON measurements(sample_id);
CREATE INDEX IF NOT EXISTS idx_artifacts_run ON artifacts(run_id);
`
// Row fields that identify a sample; every OTHER numeric field in a
// JSONL row is a measurement. New metrics therefore need no schema or
// importer change.
const IDENT = new Set([
'vm',
'arm',
'block',
'run',
'route',
'phase',
'fp',
'ver',
'cpu',
'errors',
'payload',
'round',
])
export function dbPath() {
return path.join(loadConfig({ requireScope: false }).cacheDir, 'results.db')
}
export function openDb(file = dbPath()) {
fs.mkdirSync(path.dirname(file), { recursive: true })
const db = new DatabaseSync(file)
db.exec(SCHEMA)
return db
}
function harnessSha() {
try {
return execFileSync(
'git',
[
'-C',
path.dirname(new URL(import.meta.url).pathname),
'rev-parse',
'HEAD',
],
{ encoding: 'utf8' }
).trim()
} catch {
return null
}
}
function artifactKind(name) {
if (name.endsWith('.cpuprofile')) return 'cpuprofile'
if (name.endsWith('.json')) return 'json'
if (name.endsWith('.log') || name.endsWith('.txt')) return 'log'
return 'file'
}
function walk(dir) {
const out = []
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name)
if (e.isDirectory()) out.push(...walk(p))
else if (e.isFile()) out.push(p)
}
return out
}
// Import one run dir: replaces the run's rows wholesale so re-imports
// (recovery, added boots, new artifacts) converge on the same state.
// A re-import that would SHRINK a run (fewer boots or samples than the
// db already holds — e.g. a cleaned-up run dir) is refused without
// force: claims must never silently lose data underneath them.
export function importRun(db, dir, { force = false } = {}) {
const runId = path.basename(path.resolve(dir))
const files = fs
.readdirSync(dir)
.filter((f) => /^results-vm\d+\.jsonl$/.test(f))
.sort()
.map((f) => path.join(dir, f))
if (files.length === 0) throw new Error(`no results-vm*.jsonl in ${dir}`)
const readJson = (f) => {
try {
return JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))
} catch {
return null
}
}
const meta = readJson('meta.json')
const status = readJson('status.json')
const prev = db
.prepare(
'SELECT COUNT(DISTINCT boot) boots, COUNT(*) n FROM samples WHERE run_id = ?'
)
.get(runId)
db.exec('BEGIN')
try {
db.prepare('DELETE FROM runs WHERE run_id = ?').run(runId)
let kind = 'e2e'
const insSample = db.prepare(`INSERT INTO samples
(run_id, boot, arm, block, run, route, phase, fingerprint, version, cpu, errors)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
const insMeas = db.prepare(
'INSERT INTO measurements (sample_id, metric, value) VALUES (?,?,?)'
)
let samples = 0
let pendingRows = []
const fileInfos = []
for (const [boot, file] of files.entries()) {
const content = fs.readFileSync(file, 'utf8')
let fileRows = 0
for (const line of content.trim().split('\n')) {
if (!line) continue
const row = JSON.parse(line)
const micro = row.payload !== undefined
if (micro) kind = 'micro'
pendingRows.push({ boot, row, micro })
fileRows++
}
fileInfos.push({
boot,
name: path.basename(file),
sha256: crypto.createHash('sha256').update(content).digest('hex'),
rows: fileRows,
})
}
if (
!force &&
prev.n > 0 &&
(files.length < prev.boots || pendingRows.length < prev.n)
) {
throw new Error(
`re-import of ${runId} would shrink it ` +
`(${prev.boots} boots/${prev.n} samples in db, ` +
`${files.length} boots/${pendingRows.length} samples in ${dir}) — ` +
'pass --force only if the db copy is known bad'
)
}
db.prepare(
`INSERT INTO runs
(run_id, label, kind, started_at, imported_at, harness_sha, meta)
VALUES (?,?,?,?,?,?,?)`
).run(
runId,
meta?.label ?? runId.replace(/^run-/, '').replace(/-[a-z0-9]+$/, ''),
kind,
status?.startedAt ?? null,
new Date().toISOString(),
harnessSha(),
meta ? JSON.stringify(meta) : null
)
// Provenance: which sandbox VM produced each boot (from
// status.json, matched by the index in the VM name), and the exact
// bytes each boot's JSONL contributed.
const vmByIndex = new Map()
for (const name of Object.keys(status?.vms ?? {})) {
const idx = name.match(/-(\d+)-[a-z0-9]+$/)?.[1]
if (idx !== undefined) vmByIndex.set(Number(idx), name)
}
const insBoot = db.prepare(
'INSERT INTO boots (run_id, boot, vm_name) VALUES (?,?,?)'
)
const insFile = db.prepare(
'INSERT INTO source_files (run_id, name, sha256, rows) VALUES (?,?,?,?)'
)
for (const f of fileInfos) {
const n = Number(f.name.match(/^results-vm(\d+)\.jsonl$/)?.[1])
insBoot.run(runId, f.boot, vmByIndex.get(n) ?? null)
insFile.run(runId, f.name, f.sha256, f.rows)
}
for (const { boot, row, micro } of pendingRows) {
const { lastInsertRowid: sid } = insSample.run(
runId,
boot,
row.arm,
micro ? 0 : (row.block ?? 0),
micro ? (row.round ?? 0) : (row.run ?? 0),
micro ? '' : (row.route ?? ''),
micro ? row.payload : row.phase,
row.fp ?? null,
row.ver ?? null,
row.cpu ?? null,
row.errors ?? 0
)
samples++
for (const [k, v] of Object.entries(row)) {
if (IDENT.has(k)) continue
if (typeof v !== 'number' || !Number.isFinite(v)) continue
insMeas.run(sid, k, v)
}
}
// Artifacts: everything under prof-vm<N>/ (CPU profiles etc.),
// stored gzipped with the sha256 of the RAW content.
const insArt = db.prepare(`INSERT INTO artifacts
(run_id, boot, arm, name, kind, sha256, bytes, data) VALUES (?,?,?,?,?,?,?,?)`)
let artifacts = 0
for (const e of fs.readdirSync(dir)) {
const m = e.match(/^prof-vm(\d+)$/)
if (!m) continue
const boot = Number(m[1])
for (const f of walk(path.join(dir, e))) {
const rel = path.relative(path.join(dir, e), f)
const arm = rel.match(/^prof-([^/]+)\//)?.[1] ?? null
const raw = fs.readFileSync(f)
insArt.run(
runId,
boot,
arm,
rel,
artifactKind(rel),
crypto.createHash('sha256').update(raw).digest('hex'),
raw.length,
zlib.gzipSync(raw)
)
artifacts++
}
}
db.exec('COMMIT')
return { runId, boots: files.length, samples, artifacts }
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
// Rows for bench-stats.analyzeE2eRows, reconstructed from the DB —
// the stats layer never reads run dirs.
export function loadRows(db, runId) {
const samples = db
.prepare('SELECT * FROM samples WHERE run_id = ?')
.all(runId)
if (samples.length === 0) throw new Error(`no samples for run ${runId}`)
const meas = db
.prepare(
`SELECT m.sample_id, m.metric, m.value FROM measurements m
JOIN samples s ON s.sample_id = m.sample_id WHERE s.run_id = ?`
)
.all(runId)
const byId = new Map()
for (const s of samples) {
byId.set(s.sample_id, {
vm: Number(s.boot),
arm: s.arm,
block: Number(s.block),
run: Number(s.run),
route: s.route,
phase: s.phase,
fp: s.fingerprint,
ver: s.version,
errors: Number(s.errors),
})
}
for (const m of meas) byId.get(m.sample_id)[m.metric] = m.value
return [...byId.values()]
}
export function runMeta(db, runId) {
const r = db.prepare('SELECT * FROM runs WHERE run_id = ?').get(runId)
return r ? { ...r, meta: r.meta ? JSON.parse(r.meta) : null } : null
}
// Integrity checks. Everything here is mechanical: SQLite-level
// integrity, referential health, per-run shape (one fingerprint per
// arm, paired sample counts), and artifact hashes. Failures are
// data-integrity violations; notes are true facts a reader must not
// gloss over (e.g. a recovered run that is legitimately partial).
export function verify(db, runId) {
const problems = []
const notes = []
const ic = db.prepare('PRAGMA integrity_check').all()
if (!(ic.length === 1 && ic[0].integrity_check === 'ok')) {
problems.push(`sqlite integrity_check: ${JSON.stringify(ic)}`)
}
const fk = db.prepare('PRAGMA foreign_key_check').all()
if (fk.length > 0) problems.push(`foreign_key_check: ${fk.length} violations`)
const runs = runId
? db.prepare('SELECT run_id FROM runs WHERE run_id = ?').all(runId)
: db.prepare('SELECT run_id FROM runs').all()
if (runId && runs.length === 0) problems.push(`run ${runId} not in db`)
for (const { run_id } of runs) {
const orphanSamples = db
.prepare(
`SELECT COUNT(*) c FROM samples s
WHERE s.run_id = ? AND NOT EXISTS
(SELECT 1 FROM measurements m WHERE m.sample_id = s.sample_id)`
)
.get(run_id).c
if (orphanSamples > 0) {
problems.push(`${run_id}: ${orphanSamples} samples with no measurements`)
}
const arms = db
.prepare(
`SELECT arm, COUNT(DISTINCT COALESCE(fingerprint,'') ||
'/' || COALESCE(version,'')) fps, COUNT(*) n
FROM samples WHERE run_id = ? GROUP BY arm`
)
.all(run_id)
if (arms.length !== 2) {
problems.push(`${run_id}: expected 2 arms, found ${arms.length}`)
}
for (const a of arms) {
if (a.fps > 1) {
problems.push(
`${run_id}: arm ${a.arm} has ${a.fps} distinct fingerprints`
)
}
}
const planned = (() => {
try {
return JSON.parse(
db.prepare('SELECT meta FROM runs WHERE run_id = ?').get(run_id).meta
).vms
} catch {
return undefined
}
})()
const gotBoots = db
.prepare('SELECT COUNT(DISTINCT boot) c FROM samples WHERE run_id = ?')
.get(run_id).c
if (planned && gotBoots < planned) {
notes.push(`${run_id}: partial run (${gotBoots}/${planned} boots)`)
}
if (arms.length === 2 && arms[0].n !== arms[1].n) {
problems.push(
`${run_id}: unpaired sample counts ` +
`(${arms[0].arm}=${arms[0].n} vs ${arms[1].arm}=${arms[1].n})`
)
}
for (const art of db
.prepare(
'SELECT artifact_id, name, sha256, bytes, data FROM artifacts WHERE run_id = ?'
)
.all(run_id)) {
const raw = zlib.gunzipSync(art.data)
if (
raw.length !== Number(art.bytes) ||
crypto.createHash('sha256').update(raw).digest('hex') !== art.sha256
) {
problems.push(`${run_id}: artifact ${art.name} fails hash/size check`)
}
}
}
return { failures: problems, notes }
}
export function exportRuns(db, outFile, runIds) {
fs.rmSync(outFile, { force: true })
const out = openDb(outFile)
const copy = (table, cols) => {
const ins = out.prepare(`INSERT INTO ${table} (${cols.join(',')})
VALUES (${cols.map(() => '?').join(',')})`)
return (row) => ins.run(...cols.map((c) => row[c]))
}
out.exec('BEGIN')
const copyRun = copy('runs', [
'run_id',
'label',
'kind',
'started_at',
'imported_at',
'harness_sha',
'meta',
])
const copySample = copy('samples', [
'sample_id',
'run_id',
'boot',
'arm',
'block',
'run',
'route',
'phase',
'fingerprint',
'version',
'cpu',
'errors',
])
const copyMeas = copy('measurements', ['sample_id', 'metric', 'value'])
const copyBoot = copy('boots', ['run_id', 'boot', 'vm_name'])
const copyFile = copy('source_files', ['run_id', 'name', 'sha256', 'rows'])
const copyArt = copy('artifacts', [
'run_id',
'boot',
'arm',
'name',
'kind',
'sha256',
'bytes',
'data',
])
for (const runId of runIds) {
const run = db.prepare('SELECT * FROM runs WHERE run_id = ?').get(runId)
if (!run) throw new Error(`run ${runId} not in db`)
copyRun(run)
for (const s of db
.prepare('SELECT * FROM samples WHERE run_id = ?')
.all(runId)) {
copySample(s)
for (const m of db
.prepare('SELECT * FROM measurements WHERE sample_id = ?')
.all(s.sample_id)) {
copyMeas(m)
}
}
for (const a of db
.prepare('SELECT * FROM artifacts WHERE run_id = ?')
.all(runId)) {
copyArt(a)
}
for (const b of db
.prepare('SELECT * FROM boots WHERE run_id = ?')
.all(runId)) {
copyBoot(b)
}
for (const f of db
.prepare('SELECT * FROM source_files WHERE run_id = ?')
.all(runId)) {
copyFile(f)
}
}
out.exec('COMMIT')
out.close()
}
// ------------------------------------------------------------------ CLI
function isMain() {
if (!process.argv[1]) return false
try {
return (
fs.realpathSync(process.argv[1]) ===
fs.realpathSync(new URL(import.meta.url).pathname)
)
} catch {
return false
}
}
if (isMain()) {
const [cmd, ...rest] = process.argv.slice(2)
const db = openDb()
const report = ({ failures, notes }, label) => {
for (const n of notes) console.log(`note: ${n}`)
if (failures.length) {
console.error(`VERIFY FAILED:\n ${failures.join('\n ')}`)
process.exit(1)
}
console.log(`verify: ok${label ? ` (${label})` : ''}`)
}
if (cmd === 'import') {
const force = rest.includes('--force')
const dirs = rest.filter((a) => a !== '--force')
if (dirs.length === 0) {
console.error('usage: bench-db.mjs import [--force] <runDir...>')
process.exit(1)
}
for (const dir of dirs) {
let r
try {
r = importRun(db, dir, { force })
} catch (e) {
console.error(`IMPORT REFUSED: ${e.message}`)
process.exit(1)
}
console.log(
`${r.runId}: ${r.boots} boots, ${r.samples} samples, ${r.artifacts} artifacts`
)
}
report(verify(db))
} else if (cmd === 'verify') {
report(verify(db, rest[0]), rest[0] ?? 'all runs')
} else if (cmd === 'export') {
const [out, ...ids] = rest
if (!out || ids.length === 0) {
console.error('usage: bench-db.mjs export <out.db> <runId...>')
process.exit(1)
}
exportRuns(db, out, ids)
console.log(`${out}: ${ids.length} runs`)
} else if (cmd === 'ls') {
for (const r of db
.prepare(
`SELECT r.run_id, r.kind, r.started_at,
(SELECT COUNT(DISTINCT boot) FROM samples s WHERE s.run_id = r.run_id) boots,
(SELECT COUNT(*) FROM samples s WHERE s.run_id = r.run_id) samples,
(SELECT COUNT(*) FROM artifacts a WHERE a.run_id = r.run_id) artifacts
FROM runs r ORDER BY r.started_at`
)
.all()) {
console.log(
`${r.run_id} ${r.kind} boots=${r.boots} samples=${r.samples} artifacts=${r.artifacts} ${r.started_at ?? ''}`
)
}
} else {
console.error('usage: bench-db.mjs import|verify|export|ls')
process.exit(1)
}
}
@@ -0,0 +1,229 @@
// Shared statistics for the sandbox bench harnesses.
//
// Unit of analysis: the VM BOOT. Iterations within a boot share JIT
// state, heap layout, host, and phase-sequence position, so they are
// not independent samples (Kalibera & Jones, "Rigorous Benchmarking in
// Reasonable Time"; JMH forks). Each boot contributes ONE delta per
// metric (the mean of its paired within-boot deltas); confidence
// intervals and p-values are computed across boots. Within-boot
// statistics are printed as a diagnostic only — never as the claim.
// Two-sided critical values of Student's t at 97.5% (df 1..30).
const T975 = [
12.706, 4.303, 3.182, 2.776, 2.571, 2.447, 2.365, 2.306, 2.262, 2.228, 2.201,
2.179, 2.16, 2.145, 2.131, 2.12, 2.11, 2.101, 2.093, 2.086, 2.08, 2.074,
2.069, 2.064, 2.06, 2.056, 2.052, 2.048, 2.045, 2.042,
]
function tCritical975(df) {
if (df < 1) return Infinity
if (df <= 30) return T975[df - 1]
return 1.96 + 2.4 / df // adequate approximation past df=30
}
// Two-sided p for a one-sample t test against zero, via numerical
// integration of the t pdf (small-df accuracy is what matters here —
// boots are few).
export function tTestP(values) {
const n = values.length
if (n < 2) return 1
if (values.some((v) => !Number.isFinite(v))) return 1
const mean = values.reduce((a, b) => a + b, 0) / n
const sd = Math.sqrt(
values.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
if (df === 1) {
// Student t with df=1 is Cauchy; the closed form avoids the fat
// tail truncating a numerical integration.
return Math.min(1, Math.max(0, 1 - (2 / Math.PI) * Math.atan(t)))
}
if (!Number.isFinite(t) || t > 45) {
// p underflows well past any claim threshold; also guards the
// integration loop below, whose step size vanishes against huge t.
return 0
}
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)
}
// Anytime-valid confidence sequence for a running mean (asymptotic CS,
// Waudby-Smith & Ramdas). Unlike a t-CI, this interval is valid at
// EVERY peek simultaneously, so interim displays built on it cannot
// manufacture significance through repeated looking. Tuned to be
// tightest around ~12 boots.
export function confidenceSeq(values, alpha = 0.05) {
const n = values.length
// Below 6 samples the estimated variance is too unstable for the
// asymptotic guarantee; show nothing rather than something wrong.
if (n < 6) return null
const mean = values.reduce((a, b) => a + b, 0) / n
let sd = Math.sqrt(values.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1))
if (!Number.isFinite(sd)) return null
// Small-sample variance inflation (t-style): keeps the sequence
// honest at the n this harness actually runs (6..32 boots).
sd *= Math.sqrt((n - 1) / Math.max(1, n - 3))
const nOpt = 16
const rho2 = (2 * Math.log(2 / alpha)) / nOpt
const width =
sd *
Math.sqrt(
((2 * (n * rho2 + 1)) / (n * n * rho2)) *
Math.log((Math.sqrt(n * rho2 + 1) * 2) / alpha)
)
return { mean, lo: mean - width, hi: mean + width, n }
}
// What each metric measures and which direction is an improvement.
// Deltas are always relative (%), so the unit answers "% of what".
export const METRICS = {
rps: { unit: 'req/s', better: 'higher' },
docKb: { unit: 'KB', better: 'lower' },
gzipKb: { unit: 'KB', better: 'lower' },
flightKb: { unit: 'KB', better: 'lower' },
median: { unit: 'ms', better: 'lower' },
mean: { unit: 'ms', better: 'lower' },
p95: { unit: 'ms', better: 'lower' },
p99: { unit: 'ms', better: 'lower' },
ttfb: { unit: 'ms', better: 'lower' },
gcMs: { unit: 'ms', better: 'lower' },
p50: { unit: 'ms', better: 'lower' },
rss: { unit: 'MB', better: 'lower' },
rssHw: { unit: 'MB peak', better: 'lower' },
}
// One metric in one phase: per-boot arrays of paired deltas in, verdict out.
export function bootLevelStats(perBootDeltas) {
const boots = perBootDeltas.filter((d) => d.length > 0)
const bootMeans = boots.map((d) => d.reduce((a, b) => a + b, 0) / d.length)
const n = bootMeans.length
if (n === 0) return null
const mean = bootMeans.reduce((a, b) => a + b, 0) / n
const all = boots.flat()
const result = {
mean,
boots: n,
bootMeans,
pairs: all.length,
withinP: tTestP(all),
}
if (n >= 2) {
const sd = Math.sqrt(
bootMeans.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1)
)
result.ci95 = (tCritical975(n - 1) * sd) / Math.sqrt(n)
result.p = tTestP(bootMeans)
} else {
result.ci95 = Infinity
result.p = 1
}
return result
}
export function formatStat(name, candName, baseName, s) {
if (s === null) return ` ${name.padEnd(6)} (no data)`
const pct = (x) => `${(x * 100).toFixed(1)}%`
const ci = s.ci95 === Infinity ? '±∞' : `±${(s.ci95 * 100).toFixed(1)}`
const m = METRICS[name]
const meta = m
? ` [${m.unit}; ${s.mean > 0 === (m.better === 'higher') ? 'IMPROVEMENT' : 'regression'} if real]`
: ''
// Byte metrics are deterministic per build, so a ±0.0 near-zero cell
// with p=0 is normal; the absolute values say whether it matters.
const abs =
s.absBase !== undefined && m?.unit === 'KB'
? ` [${s.absBase.toFixed(1)}KB -> ${s.absCand.toFixed(1)}KB]`
: ''
return (
` ${name.padEnd(6)} ${candName} vs ${baseName}: ${pct(s.mean)} ${ci}${meta}${abs} ` +
`(boots=${s.boots}${s.boots < 3 ? ' — TOO FEW FOR CLAIMS' : ''}, p=${s.p.toFixed(4)})` +
` perBoot ${s.bootMeans.map((m) => pct(m)).join(' ')}` +
` [pairs=${s.pairs} within-run p=${s.withinP.toFixed(4)} — diagnostic only]`
)
}
// E2e rows: {vm, arm, block, run, route, phase, <metrics...>}. Pairs are
// (vm, block, run); the boot is the vm. Returns nothing; prints.
export function analyzeE2eRows(rows, baseName, candName, metrics) {
const fps = {}
for (const r of rows)
(fps[r.arm] ||= new Set()).add(r.ver ? `${r.fp}/${r.ver}` : `${r.fp}`)
console.log(
`\nfingerprints: ${baseName}=${[...(fps[baseName] ?? [])].join(',')} ` +
`${candName}=${[...(fps[candName] ?? [])].join(',')}`
)
if ((fps[baseName]?.size ?? 0) !== 1 || (fps[candName]?.size ?? 0) !== 1) {
// A VM measured the wrong build; numbers would look official and be
// meaningless. Refuse instead of printing stats with a warning.
console.log(
'!! inconsistent fingerprints within an arm — RESULTS INVALID, no stats'
)
process.exitCode = 1
return false
}
const baseFp = [...fps[baseName]][0]?.split('/')[0]
const candFp = [...fps[candName]][0]?.split('/')[0]
if (baseFp !== undefined && candFp !== undefined) {
console.log(
baseFp === candFp
? 'fingerprint files byte-identical between arms (A/A for those files; ' +
'arms may still differ elsewhere — check version strings)'
: 'arms differ (A/B mode)'
)
}
// "Absent" must be distinguishable from "identical": metrics the run
// never captured are named at the end instead of silently missing.
const captured = new Set()
for (const phase of [
...new Set(rows.map((r) => `${r.route ?? ''} ${r.phase}`)),
].sort()) {
console.log(`\n${phase}`)
const inPhase = (r) => `${r.route ?? ''} ${r.phase}` === phase
for (const metric of metrics) {
const perBoot = []
const baseVals = []
const candVals = []
for (const vm of [...new Set(rows.map((r) => r.vm))]) {
const deltas = []
for (const r of rows.filter(
(x) => x.vm === vm && inPhase(x) && x.arm === candName
)) {
const b = rows.find(
(x) =>
x.vm === vm &&
inPhase(x) &&
x.arm === baseName &&
x.block === r.block &&
x.run === r.run
)
if (b && b[metric] > 0 && r[metric] > 0) {
deltas.push((r[metric] - b[metric]) / b[metric])
baseVals.push(b[metric])
candVals.push(r[metric])
}
}
perBoot.push(deltas)
}
const s = bootLevelStats(perBoot)
if (s !== null && s.pairs > 0) {
captured.add(metric)
s.absBase = baseVals.reduce((a, b) => a + b, 0) / baseVals.length
s.absCand = candVals.reduce((a, b) => a + b, 0) / candVals.length
console.log(formatStat(metric, candName, baseName, s))
}
}
}
const absent = metrics.filter((m) => !captured.has(m))
if (absent.length > 0) {
console.log(`\nnot captured on this run: ${absent.join(', ')}`)
}
return true
}
@@ -0,0 +1,92 @@
#!/usr/bin/env node
// Usage: node bench-status.mjs [--all]
//
// The only reliable way to know what's in flight. Session restarts silently
// kill background launchers while their detached VMs keep measuring, and a
// dead launcher's log file still ends with a healthy-looking progress line —
// never infer liveness from log tails. This reads each run's status.json,
// checks whether the recorded launcher pid is alive, and prints the recovery
// action per run.
import fs from 'fs'
import path from 'path'
import { loadConfig } from './config.mjs'
const CONFIG = loadConfig({ requireScope: false })
const E2E = path.join(CONFIG.cacheDir, 'e2e')
const all = process.argv.includes('--all')
const dirs = fs.existsSync(E2E)
? fs
.readdirSync(E2E)
.filter((d) => d.startsWith('run-'))
.map((d) => path.join(E2E, d))
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)
: []
const DAY = 24 * 3600 * 1000
let shown = 0
for (const dir of dirs) {
const sf = path.join(dir, 'status.json')
if (!fs.existsSync(sf)) continue
let st
try {
st = JSON.parse(fs.readFileSync(sf, 'utf8'))
} catch {
continue
}
const fresh = fs.statSync(sf).mtimeMs > Date.now() - DAY
let alive = false
if (st.pid) {
try {
process.kill(st.pid, 0)
alive = true
} catch {}
}
if (!all && !fresh && !alive) {
continue
}
const ageMin = st.updatedAt
? Math.round((Date.now() - new Date(st.updatedAt).getTime()) / 60000)
: null
const vms = Object.values(st.vms || {})
const rows = vms.reduce((s, v) => s + (v.rows || 0), 0)
let action
if (st.phase === 'done') {
action = 'complete — bench-analyze.mjs re-reads it any time'
} else if (alive) {
action = 'RUNNING — leave it alone'
} else if (
(rows > 0 || st.phase === 'measuring') &&
ageMin !== null &&
ageMin > 290
) {
action =
'DEAD and its VMs have expired — whatever was collected is in the ' +
'run dir/results db; relaunch if more data is needed'
} else if (rows > 0 || st.phase === 'measuring') {
action =
`DEAD but VMs may still hold data — node scripts/bench-collect.mjs ${dir}` +
' (measurement VMs time out ~5h after launch; collect before that)'
} else {
action =
'DEAD before measurement — safe to relaunch (caches make it cheap); ' +
'check for leaked builder VMs with sandbox-sweep.mjs'
}
console.log(
`${path.basename(dir)}\n` +
` phase=${st.phase}` +
(st.error
? ` error=${JSON.stringify(String(st.error).slice(0, 140))}`
: '') +
` pid=${st.pid ?? '?'}(${alive ? 'alive' : 'dead'})` +
(ageMin !== null ? ` updated=${ageMin}m ago` : '') +
` rows=${rows}${st.rowsExpected ? `/${st.rowsExpected}` : ''}\n` +
` -> ${action}`
)
shown++
}
if (!shown) {
console.log('no recent runs (use --all to list everything)')
}
@@ -0,0 +1,150 @@
// Configuration for the sandbox-bench scripts.
//
// Everything machine- or team-specific lives in a config file OUTSIDE the
// repo (never commit team/project names into the repo):
// ~/.config/sandbox-bench/config.json
//
// Required fields (no defaults — ask the user once, then save):
// team Vercel team slug the sandboxes bill to
// project Vercel project the sandboxes attach to
// Optional fields:
// cacheDir where clones/artifacts/snapshot-ids live
// (default ~/.cache/sandbox-bench)
// reactRepo existing react checkout to use (default: auto-clone
// into <cacheDir>/react)
// nextRepo existing next.js checkout to use (default: auto-clone
// into <cacheDir>/next)
// reactRepoUrl clone/fetch URL (default https://github.com/react/react.git)
// nextRepoUrl clone/fetch URL (default https://github.com/vercel/next.js.git)
// vercelBin vercel CLI binary (default "vercel")
//
// CLI: node config.mjs show
// node config.mjs set team=<slug> project=<name> [key=value...]
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
const CONFIG_DIR = path.join(os.homedir(), '.config', 'sandbox-bench')
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
export function loadConfig({ requireScope = true } = {}) {
let cfg = {}
if (fs.existsSync(CONFIG_FILE)) {
cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'))
}
// Env overrides for CI or one-off use.
cfg.team = process.env.SANDBOX_BENCH_TEAM ?? cfg.team
cfg.project = process.env.SANDBOX_BENCH_PROJECT ?? cfg.project
cfg.cacheDir = expandHome(
process.env.SANDBOX_BENCH_CACHE ?? cfg.cacheDir ?? '~/.cache/sandbox-bench'
)
cfg.reactRepo = expandHome(
process.env.SANDBOX_BENCH_REACT_REPO ?? cfg.reactRepo ?? ''
)
cfg.nextRepo = expandHome(
process.env.SANDBOX_BENCH_NEXT_REPO ?? cfg.nextRepo ?? ''
)
cfg.reactRepoUrl = cfg.reactRepoUrl ?? 'https://github.com/react/react.git'
cfg.nextRepoUrl = cfg.nextRepoUrl ?? 'https://github.com/vercel/next.js.git'
cfg.vercelBin = cfg.vercelBin ?? 'vercel'
if (requireScope && (!cfg.team || !cfg.project)) {
throw new Error(
'sandbox-bench is not configured: team and project are required.\n' +
'Ask the user which Vercel team + project the sandbox VMs should ' +
'run under, then save them with:\n' +
` node ${path.relative(process.cwd(), new URL(import.meta.url).pathname)} ` +
'set team=<team-slug> project=<project-name>\n' +
'(Stored in ~/.config/sandbox-bench/config.json, not in the repo.)'
)
}
return cfg
}
export function saveConfig(patch) {
fs.mkdirSync(CONFIG_DIR, { recursive: true })
const existing = fs.existsSync(CONFIG_FILE)
? JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'))
: {}
const merged = { ...existing, ...patch }
fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n')
return merged
}
function expandHome(p) {
if (!p) return p
return p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p
}
// Scope flags appended to every `vercel sandbox` call. Never widen scope
// beyond the configured team/project: the project may be shared.
export function sandboxScope(cfg) {
return ['--team', cfg.team, '--project', cfg.project]
}
// Repos: use the configured checkout, else clone lazily into the cache.
function ensureRepo(cfg, checkout, url, name) {
if (checkout && fs.existsSync(path.join(checkout, '.git'))) return checkout
const dest = path.join(cfg.cacheDir, name)
if (fs.existsSync(path.join(dest, '.git'))) return dest
fs.mkdirSync(cfg.cacheDir, { recursive: true })
console.error(`cloning ${url} into ${dest} (one-time)...`)
execFileSync('git', ['clone', '--filter=blob:none', url, dest], {
stdio: 'inherit',
})
return dest
}
export function ensureReactRepo(cfg) {
return ensureRepo(cfg, cfg.reactRepo, cfg.reactRepoUrl, 'react')
}
export function ensureNextRepo(cfg) {
return ensureRepo(cfg, cfg.nextRepo, cfg.nextRepoUrl, 'next')
}
// ------------------------------------------------------------------ CLI
// realpath both sides: /tmp vs /private/tmp (macOS) or any symlinked
// invocation path must not silently skip the CLI (a no-op "config set"
// would leave stale team/project in effect with exit 0).
function isMain() {
if (!process.argv[1]) return false
try {
return (
fs.realpathSync(process.argv[1]) ===
fs.realpathSync(new URL(import.meta.url).pathname)
)
} catch {
return false
}
}
if (isMain()) {
const [cmd, ...rest] = process.argv.slice(2)
if (cmd === 'show') {
let cfg
try {
cfg = loadConfig({ requireScope: false })
} catch (e) {
console.error(e.message)
process.exit(1)
}
console.log(JSON.stringify(cfg, null, 2))
if (!cfg.team || !cfg.project) {
console.error('\nNOT CONFIGURED: team/project missing (see file header).')
process.exit(2)
}
} else if (cmd === 'set') {
const patch = {}
for (const kv of rest) {
const i = kv.indexOf('=')
if (i < 0) {
console.error(`bad argument "${kv}", want key=value`)
process.exit(1)
}
patch[kv.slice(0, i)] = kv.slice(i + 1)
}
const merged = saveConfig(patch)
console.log(`saved: ${JSON.stringify(merged, null, 2)}`)
} else {
console.error('usage: node config.mjs show | set key=value [key=value...]')
process.exit(1)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,400 @@
#!/usr/bin/env node
// Correctness gate for React arms before spending bench compute: each
// arm gets a sandbox VM booted from the shared react build-env snapshot
// (created lazily if missing), extracts the arm's tree over the
// snapshot's node_modules, and runs the jest suite in prod mode (the
// channel the bench measures).
// A bench number from an arm that fails its tests is meaningless —
// always gate hand-assembled or conflict-resolved arms.
//
// Usage: node sandbox-gate.mjs --arms name=ref[,name=ref...]
// [--pattern <jest pattern>, default: full suite] [--extra "yarn flow dom-node"]
// [--snapshot snap_x] [--dry-run]
import { execFile, spawn } from 'node:child_process'
import { promisify } from 'node:util'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import crypto from 'node:crypto'
import { loadConfig, sandboxScope, ensureReactRepo } from './config.mjs'
const execFileP = promisify(execFile)
const DRY_RUN = process.argv.includes('--dry-run')
const CONFIG = loadConfig({ requireScope: !DRY_RUN })
const REACT = ensureReactRepo(CONFIG)
const VERCEL = CONFIG.vercelBin
const SCOPE = CONFIG.team ? sandboxScope(CONFIG) : []
const SETUP_VERSION = 'v1-al2023-node24-jdk21'
const REACT_SNAP_CACHE = path.join(CONFIG.cacheDir, 'react-snap')
function parseArgs() {
const args = { arms: [], snapshot: undefined, extra: [], pattern: '' }
const argv = process.argv.slice(2)
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--arms') {
for (const spec of argv[++i].split(',')) {
const [name, ref] = spec.split('=')
if (!name || !ref) throw new Error(`bad arm spec: ${spec}`)
args.arms.push({ name, ref })
}
} else if (argv[i] === '--snapshot') {
args.snapshot = argv[++i]
} else if (argv[i] === '--extra') {
// Extra gate command(s) run in the repo root after the jest gates,
// e.g. --extra "yarn flow dom-node".
args.extra.push(argv[++i])
} else if (argv[i] === '--pattern') {
args.pattern = argv[++i]
} else if (argv[i] === '--dry-run') {
// handled globally
} else {
throw new Error(`unknown arg: ${argv[i]}`)
}
}
if (args.arms.length === 0) throw new Error('need --arms name=ref[,...]')
if (args.pattern !== '' && !/^[A-Za-z0-9|_.-]+$/.test(args.pattern)) {
// The pattern is interpolated into the remote command line; reject
// anything that could smuggle extra jest flags (--passWithNoTests
// would turn an empty selection into a PASS).
throw new Error(
`--pattern must match [A-Za-z0-9|_.-]+, got: ${args.pattern}`
)
}
return args
}
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; callers
// that parse exact values (size checks) must keep stdout-only.
return opts.withStderr ? `${stdout}\n${stderr}` : stdout
}
async function git(args) {
const { stdout } = await execFileP('git', ['-C', REACT, ...args], {
maxBuffer: 256 * 1024 * 1024,
})
return stdout.trim()
}
async function rmVm(name) {
try {
await sb(['rm', name])
} catch (e) {
process.stderr.write(`warning: could not remove ${name}: ${e.message}\n`)
}
}
// Same detached-nohup-and-poll shape as sandbox-e2e.mjs runDetached:
// exec streams drop flakily on multi-minute silences, so never hold one.
async function runDetached(vm, tag, script, deadlineMin) {
const local = path.join(os.tmpdir(), `gate-${vm}.sh`)
fs.writeFileSync(
local,
// EXIT trap, not ERR: see sandbox-e2e.mjs runDetached.
`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}: 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'))
for (const l of body.split('\n')) {
if (l) process.stderr.write(`[${tag}] ${l}\n`)
}
if (m) {
offset = Math.min(Number(m[1]), offset + 200000)
if (m[2] === 'LOOPOK') return
if (m[2] === 'LOOPFAIL')
throw new Error(
`${tag}: remote gate failed; tail:\n${body.slice(-3000)}`
)
}
}
}
async function gateArm(arm, snapshot, tmp, extra = [], pattern = '') {
const sha = await git(['rev-parse', arm.ref])
const short = sha.slice(0, 12)
const vm = `sbench-gate-${arm.name}-${Date.now().toString(36)}`
process.stderr.write(`[${arm.name}] vm ${vm} gating ${short}\n`)
try {
await sb([
'create',
'--name',
vm,
'--snapshot',
snapshot,
'--vcpus',
'16',
'--timeout',
'2h',
'--non-persistent',
'--network-policy',
'allow-all',
'--tag',
'purpose=sandbox-bench',
'--silent',
])
} catch (e) {
if (!/vcpu/i.test(e.message)) throw e
await sb([
'create',
'--name',
vm,
'--snapshot',
snapshot,
'--vcpus',
'8',
'--timeout',
'2h',
'--non-persistent',
'--network-policy',
'allow-all',
'--tag',
'purpose=sandbox-bench',
'--silent',
])
}
try {
const src = path.join(tmp, `gate-src-${arm.name}.tgz`)
await execFileP('bash', [
'-c',
`git -C ${REACT} archive ${sha} | gzip -1 > ${src}`,
])
await sb(['cp', src, `${vm}:/vercel/sandbox/src.tgz`])
const srcSize = fs.statSync(src).size
fs.rmSync(src, { force: true })
// sandbox cp can succeed on missing remote files: size-check remotely.
const check = await sb([
'exec',
vm,
'--timeout',
'2m',
'--',
'bash',
'-c',
`stat -c %s /vercel/sandbox/src.tgz`,
])
if (Number(check.trim()) !== srcSize) {
throw new Error(
`${arm.name}: uploaded src.tgz size ${check.trim()} != local ${srcSize}`
)
}
await runDetached(
vm,
`gate:${arm.name}`,
// pipefail: every gate command is piped through tail, which would
// otherwise mask its exit status from set -e (debugged 2026-07-19:
// a failing flow run still printed GATE PASS).
`set -o pipefail\n` +
`cd /vercel/sandbox/react\n` +
`find . -mindepth 1 -maxdepth 1 ! -name node_modules -exec rm -rf {} +\n` +
`tar -xzf ../src.tgz && rm -f ../src.tgz\n` +
`echo "== ${arm.name} ${short} prod gate (${pattern || 'full suite'}) =="\n` +
`yarn test --prod ${pattern} --ci 2>&1 | tail -40\n` +
extra
.map(
(cmd) =>
`echo "== ${arm.name} extra: ${cmd} =="\n${cmd} 2>&1 | tail -30\n`
)
.join('') +
`echo "== ${arm.name} GATE PASS =="\n`,
90
)
return { arm: arm.name, sha: short, pass: true }
} finally {
await rmVm(vm)
}
}
// Build-env snapshot: node_modules installed + JDK for the closure
// compiler, keyed on yarn.lock. Created once per lockfile, reused by
// every later gate.
const snapshotPromises = new Map()
async function ensureReactBuildSnapshot(refSha) {
// Raw bytes, exactly as the e2e harness hashes it: a trim here would
// silently fork the cache key and build every snapshot twice.
const lock = (
await execFileP('git', ['-C', REACT, 'show', `${refSha}:yarn.lock`], {
maxBuffer: 256 * 1024 * 1024,
})
).stdout
const memoKey = crypto.createHash('sha256').update(lock).digest('hex')
if (!snapshotPromises.has(memoKey)) {
snapshotPromises.set(memoKey, ensureReactBuildSnapshotForLock(refSha, lock))
}
return snapshotPromises.get(memoKey)
}
async function ensureReactBuildSnapshotForLock(refSha, lock) {
const key = crypto
.createHash('sha256')
.update(SETUP_VERSION + lock)
.digest('hex')
.slice(0, 16)
const file = path.join(REACT_SNAP_CACHE, `snap-${key}`)
if (fs.existsSync(file)) {
const id = fs.readFileSync(file, 'utf8').trim()
try {
await sb(['snapshots', 'get', id])
return id
} catch {}
}
const vm = `sbench-snapbuild-${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} 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 runDetached(
vm,
'react-snap',
`mkdir -p /vercel/sandbox/react && cd /vercel/sandbox/react && tar -xzf ../src.tgz && rm -f ../src.tgz\n` +
`npm i -g yarn >/dev/null 2>&1\n` +
`yarn install --frozen-lockfile --ignore-engines >/dev/null 2>&1\n` +
`echo react env ready\n`,
30
)
const out = await sb(['snapshot', vm, '--stop', '--expiration', '30d'], {
withStderr: true,
})
const snapId = out.match(/snap_[A-Za-z0-9]+/)?.[0]
if (!snapId)
throw new Error(`could not parse snapshot id from: ${out.slice(-500)}`)
fs.mkdirSync(REACT_SNAP_CACHE, { recursive: true })
fs.writeFileSync(file, `${snapId}\n`)
return snapId
} finally {
await rmVm(vm)
}
}
async function main() {
const args = parseArgs()
if (DRY_RUN) {
console.log(
`[dry-run] gate plan: ${args.arms.map((a) => `${a.name}=${a.ref}`).join(', ')}`
)
console.log(
`[dry-run] per arm: 1 VM from react build-env snapshot (lazy-created), ` +
`yarn test --prod ${args.pattern} --ci` +
(args.extra.length ? `, extra: ${args.extra.join(' && ')}` : '')
)
console.log('[dry-run] PASS requires: verified test counts (pipefail on).')
process.exit(0)
}
// Each arm gates against ITS OWN lockfile's environment: arms with
// different yarn.locks must not share node_modules.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sandbox-gate-'))
try {
const results = await Promise.allSettled(
args.arms.map(async (arm) => {
const sha = await git(['rev-parse', arm.ref])
const snapshot = args.snapshot ?? (await ensureReactBuildSnapshot(sha))
return gateArm(arm, snapshot, tmp, args.extra, args.pattern)
})
)
let failed = 0
for (let i = 0; i < results.length; i++) {
const r = results[i]
if (r.status === 'fulfilled') {
console.log(`GATE ${args.arms[i].name}: PASS (${r.value.sha})`)
console.log(
` bench this exact sha: --arms cand=${r.value.sha} (a ref can move between gate and bench)`
)
} else {
failed++
console.log(
`GATE ${args.arms[i].name}: FAIL — ${r.reason.message.slice(0, 2500)}`
)
}
}
process.exit(failed ? 1 : 0)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
@@ -0,0 +1,86 @@
// List/remove leaked bench sandboxes. Every VM the skill's scripts
// create is named sbench-* and tagged purpose=sandbox-bench; the
// harnesses remove their VMs in finally blocks, but a killed local
// process leaks them until their timeout.
//
// The project may be shared, so removal is deliberately conservative:
// a VM is only listed when its name matches, its tag matches, AND it is
// older than --min-age-hours (default 3 — longer than any healthy run),
// and removal is per exact listed name.
//
// node sandbox-sweep.mjs # list what would be removed
// node sandbox-sweep.mjs --yes # remove
// node sandbox-sweep.mjs --min-age-hours 0 # include fresh VMs (danger:
// # only when nothing is active)
import { execFile } from 'child_process'
import { promisify } from 'util'
import { loadConfig, sandboxScope } from './config.mjs'
const execFileP = promisify(execFile)
const CONFIG = loadConfig()
const SCOPE = sandboxScope(CONFIG)
const argv = process.argv.slice(2)
const minAgeHours = argv.includes('--min-age-hours')
? Number(argv[argv.indexOf('--min-age-hours') + 1])
: 3
// Age from the CLI's "CREATED" column ("32 minutes ago", "2 hours ago").
function ageHours(words) {
const i = words.findIndex((w) => w === 'ago')
if (i < 2) return Infinity
const n = Number(words[i - 2])
const unit = words[i - 1]
if (!Number.isFinite(n)) return Infinity
if (unit.startsWith('second')) return n / 3600
if (unit.startsWith('minute')) return n / 60
if (unit.startsWith('hour')) return n
return n * 24
}
// Paginate: the CLI prints a cursor hint line when more pages exist.
const found = []
let cursor
for (let page = 0; page < 50; page++) {
const args = ['sandbox', 'list', '--limit', '100', ...SCOPE]
if (cursor) args.push('--cursor', cursor)
const { stdout, stderr } = await execFileP(CONFIG.vercelBin, args, {
maxBuffer: 1 << 24,
})
for (const line of stdout.split('\n')) {
const words = line.trim().split(/\s+/)
const name = words[0]
if (!/^sbench-/.test(name)) continue
if (!line.includes('purpose:sandbox-bench')) continue
const age = ageHours(words)
if (age < minAgeHours) {
console.log(
`skipping ${name} (${age.toFixed(1)}h old < --min-age-hours ${minAgeHours})`
)
continue
}
found.push(name)
}
cursor = `${stdout}\n${stderr}`.match(/--cursor\s+(\S+)/)?.[1]
if (!cursor) break
}
if (!found.length) {
console.log('no leaked bench sandboxes found')
process.exit(0)
}
const doIt = argv.includes('--yes')
let failures = 0
for (const name of found) {
if (doIt) {
try {
await execFileP(CONFIG.vercelBin, ['sandbox', 'rm', name, ...SCOPE])
console.log(`removed ${name}`)
} catch (e) {
failures++
console.error(`could not remove ${name}: ${e.message.split('\n')[0]}`)
}
} else {
console.log(`would remove ${name} (pass --yes)`)
}
}
process.exit(failures ? 1 : 0)