scripts/sandbox-gate.mjs
scripts/sandbox-gate.mjsBrowse 13 files
3,670 tokens
12,704 bytes
Token encoding: o200k_base
Snapshot bfcf687
← Back to SKILL.md
1#!/usr/bin/env node2// Correctness gate for React arms before spending bench compute: each3// arm gets a sandbox VM booted from the shared react build-env snapshot4// (created lazily if missing), extracts the arm's tree over the5// snapshot's node_modules, and runs the jest suite in prod mode (the6// channel the bench measures).7// A bench number from an arm that fails its tests is meaningless —8// always gate hand-assembled or conflict-resolved arms.9//10// Usage: node sandbox-gate.mjs --arms name=ref[,name=ref...]11// [--pattern <jest pattern>, default: full suite] [--extra "yarn flow dom-node"]12// [--snapshot snap_x] [--dry-run]13import { execFile, spawn } from 'node:child_process'14import { promisify } from 'node:util'15import fs from 'node:fs'16import os from 'node:os'17import path from 'node:path'18import crypto from 'node:crypto'19import { loadConfig, sandboxScope, ensureReactRepo } from './config.mjs'20 21const execFileP = promisify(execFile)22const DRY_RUN = process.argv.includes('--dry-run')23const CONFIG = loadConfig({ requireScope: !DRY_RUN })24const REACT = ensureReactRepo(CONFIG)25const VERCEL = CONFIG.vercelBin26const SCOPE = CONFIG.team ? sandboxScope(CONFIG) : []27const SETUP_VERSION = 'v1-al2023-node24-jdk21'28const REACT_SNAP_CACHE = path.join(CONFIG.cacheDir, 'react-snap')29 30function parseArgs() {31 const args = { arms: [], snapshot: undefined, extra: [], pattern: '' }32 const argv = process.argv.slice(2)33 for (let i = 0; i < argv.length; i++) {34 if (argv[i] === '--arms') {35 for (const spec of argv[++i].split(',')) {36 const [name, ref] = spec.split('=')37 if (!name || !ref) throw new Error(`bad arm spec: ${spec}`)38 args.arms.push({ name, ref })39 }40 } else if (argv[i] === '--snapshot') {41 args.snapshot = argv[++i]42 } else if (argv[i] === '--extra') {43 // Extra gate command(s) run in the repo root after the jest gates,44 // e.g. --extra "yarn flow dom-node".45 args.extra.push(argv[++i])46 } else if (argv[i] === '--pattern') {47 args.pattern = argv[++i]48 } else if (argv[i] === '--dry-run') {49 // handled globally50 } else {51 throw new Error(`unknown arg: ${argv[i]}`)52 }53 }54 if (args.arms.length === 0) throw new Error('need --arms name=ref[,...]')55 if (args.pattern !== '' && !/^[A-Za-z0-9|_.-]+$/.test(args.pattern)) {56 // The pattern is interpolated into the remote command line; reject57 // anything that could smuggle extra jest flags (--passWithNoTests58 // would turn an empty selection into a PASS).59 throw new Error(60 `--pattern must match [A-Za-z0-9|_.-]+, got: ${args.pattern}`61 )62 }63 return args64}65 66async function sb(args, opts = {}) {67 const scoped = ['sandbox', ...args]68 const sep = scoped.indexOf('--')69 scoped.splice(sep < 0 ? scoped.length : sep, 0, ...SCOPE)70 const { stdout, stderr } = await execFileP(VERCEL, scoped, {71 maxBuffer: 64 * 1024 * 1024,72 ...opts,73 })74 // The CLI prints some results (e.g. snapshot ids) on stderr; callers75 // that parse exact values (size checks) must keep stdout-only.76 return opts.withStderr ? `${stdout}\n${stderr}` : stdout77}78 79async function git(args) {80 const { stdout } = await execFileP('git', ['-C', REACT, ...args], {81 maxBuffer: 256 * 1024 * 1024,82 })83 return stdout.trim()84}85 86async function rmVm(name) {87 try {88 await sb(['rm', name])89 } catch (e) {90 process.stderr.write(`warning: could not remove ${name}: ${e.message}\n`)91 }92}93 94// Same detached-nohup-and-poll shape as sandbox-e2e.mjs runDetached:95// exec streams drop flakily on multi-minute silences, so never hold one.96async function runDetached(vm, tag, script, deadlineMin) {97 const local = path.join(os.tmpdir(), `gate-${vm}.sh`)98 fs.writeFileSync(99 local,100 // EXIT trap, not ERR: see sandbox-e2e.mjs runDetached.101 `trap 'code=$?; if [ $code -eq 0 ]; then echo LOOPOK; else echo LOOPFAIL; fi > /vercel/sandbox/loop.done' EXIT\nset -e\n${script}\n`102 )103 await sb(['cp', local, `${vm}:/vercel/sandbox/loop.sh`])104 fs.rmSync(local, { force: true })105 await sb([106 'exec',107 vm,108 '--timeout',109 '2m',110 '--',111 'bash',112 '-c',113 'rm -f /vercel/sandbox/loop.done /vercel/sandbox/loop.log; nohup bash /vercel/sandbox/loop.sh >/vercel/sandbox/loop.log 2>&1 & echo kicked',114 ])115 let offset = 0116 let failures = 0117 const deadline = Date.now() + deadlineMin * 60_000118 while (true) {119 await new Promise((r) => setTimeout(r, 45_000))120 if (Date.now() > deadline) throw new Error(`${tag}: deadline exceeded`)121 let out122 try {123 out = await sb([124 'exec',125 vm,126 '--timeout',127 '2m',128 '--',129 'bash',130 '-c',131 `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)"`,132 ])133 failures = 0134 } catch (e) {135 if (++failures >= 6)136 throw new Error(137 `${tag}: ${failures} consecutive poll failures: ${e.message.slice(0, 200)}`138 )139 continue140 }141 const m = out.match(/@@SIZE (\d+) @@DONE (\S+)/)142 const body = out.slice(0, out.lastIndexOf('\n@@SIZE'))143 for (const l of body.split('\n')) {144 if (l) process.stderr.write(`[${tag}] ${l}\n`)145 }146 if (m) {147 offset = Math.min(Number(m[1]), offset + 200000)148 if (m[2] === 'LOOPOK') return149 if (m[2] === 'LOOPFAIL')150 throw new Error(151 `${tag}: remote gate failed; tail:\n${body.slice(-3000)}`152 )153 }154 }155}156 157async function gateArm(arm, snapshot, tmp, extra = [], pattern = '') {158 const sha = await git(['rev-parse', arm.ref])159 const short = sha.slice(0, 12)160 const vm = `sbench-gate-${arm.name}-${Date.now().toString(36)}`161 process.stderr.write(`[${arm.name}] vm ${vm} gating ${short}\n`)162 try {163 await sb([164 'create',165 '--name',166 vm,167 '--snapshot',168 snapshot,169 '--vcpus',170 '16',171 '--timeout',172 '2h',173 '--non-persistent',174 '--network-policy',175 'allow-all',176 '--tag',177 'purpose=sandbox-bench',178 '--silent',179 ])180 } catch (e) {181 if (!/vcpu/i.test(e.message)) throw e182 await sb([183 'create',184 '--name',185 vm,186 '--snapshot',187 snapshot,188 '--vcpus',189 '8',190 '--timeout',191 '2h',192 '--non-persistent',193 '--network-policy',194 'allow-all',195 '--tag',196 'purpose=sandbox-bench',197 '--silent',198 ])199 }200 try {201 const src = path.join(tmp, `gate-src-${arm.name}.tgz`)202 await execFileP('bash', [203 '-c',204 `git -C ${REACT} archive ${sha} | gzip -1 > ${src}`,205 ])206 await sb(['cp', src, `${vm}:/vercel/sandbox/src.tgz`])207 const srcSize = fs.statSync(src).size208 fs.rmSync(src, { force: true })209 // sandbox cp can succeed on missing remote files: size-check remotely.210 const check = await sb([211 'exec',212 vm,213 '--timeout',214 '2m',215 '--',216 'bash',217 '-c',218 `stat -c %s /vercel/sandbox/src.tgz`,219 ])220 if (Number(check.trim()) !== srcSize) {221 throw new Error(222 `${arm.name}: uploaded src.tgz size ${check.trim()} != local ${srcSize}`223 )224 }225 await runDetached(226 vm,227 `gate:${arm.name}`,228 // pipefail: every gate command is piped through tail, which would229 // otherwise mask its exit status from set -e (debugged 2026-07-19:230 // a failing flow run still printed GATE PASS).231 `set -o pipefail\n` +232 `cd /vercel/sandbox/react\n` +233 `find . -mindepth 1 -maxdepth 1 ! -name node_modules -exec rm -rf {} +\n` +234 `tar -xzf ../src.tgz && rm -f ../src.tgz\n` +235 `echo "== ${arm.name} ${short} prod gate (${pattern || 'full suite'}) =="\n` +236 `yarn test --prod ${pattern} --ci 2>&1 | tail -40\n` +237 extra238 .map(239 (cmd) =>240 `echo "== ${arm.name} extra: ${cmd} =="\n${cmd} 2>&1 | tail -30\n`241 )242 .join('') +243 `echo "== ${arm.name} GATE PASS =="\n`,244 90245 )246 return { arm: arm.name, sha: short, pass: true }247 } finally {248 await rmVm(vm)249 }250}251 252// Build-env snapshot: node_modules installed + JDK for the closure253// compiler, keyed on yarn.lock. Created once per lockfile, reused by254// every later gate.255const snapshotPromises = new Map()256 257async function ensureReactBuildSnapshot(refSha) {258 // Raw bytes, exactly as the e2e harness hashes it: a trim here would259 // silently fork the cache key and build every snapshot twice.260 const lock = (261 await execFileP('git', ['-C', REACT, 'show', `${refSha}:yarn.lock`], {262 maxBuffer: 256 * 1024 * 1024,263 })264 ).stdout265 const memoKey = crypto.createHash('sha256').update(lock).digest('hex')266 if (!snapshotPromises.has(memoKey)) {267 snapshotPromises.set(memoKey, ensureReactBuildSnapshotForLock(refSha, lock))268 }269 return snapshotPromises.get(memoKey)270}271 272async function ensureReactBuildSnapshotForLock(refSha, lock) {273 const key = crypto274 .createHash('sha256')275 .update(SETUP_VERSION + lock)276 .digest('hex')277 .slice(0, 16)278 const file = path.join(REACT_SNAP_CACHE, `snap-${key}`)279 if (fs.existsSync(file)) {280 const id = fs.readFileSync(file, 'utf8').trim()281 try {282 await sb(['snapshots', 'get', id])283 return id284 } catch {}285 }286 const vm = `sbench-snapbuild-${Date.now().toString(36)}`287 console.error(288 'creating react build-env snapshot (one-time for this yarn.lock)...'289 )290 await sb([291 'create',292 '--name',293 vm,294 '--runtime',295 'node24',296 '--vcpus',297 '8',298 '--timeout',299 '45m',300 '--non-persistent',301 '--network-policy',302 'allow-all',303 '--tag',304 'purpose=sandbox-bench',305 '--silent',306 ])307 try {308 const src = path.join(os.tmpdir(), `react-snap-src-${key}.tgz`)309 await execFileP('bash', [310 '-c',311 `git -C ${REACT} archive ${refSha} | gzip -1 > ${src}`,312 ])313 await sb(['cp', src, `${vm}:/vercel/sandbox/src.tgz`])314 fs.rmSync(src, { force: true })315 await sb([316 'exec',317 vm,318 '--timeout',319 '10m',320 '--sudo',321 '--',322 'dnf',323 'install',324 '-y',325 '-q',326 'java-21-amazon-corretto-headless',327 ])328 await runDetached(329 vm,330 'react-snap',331 `mkdir -p /vercel/sandbox/react && cd /vercel/sandbox/react && tar -xzf ../src.tgz && rm -f ../src.tgz\n` +332 `npm i -g yarn >/dev/null 2>&1\n` +333 `yarn install --frozen-lockfile --ignore-engines >/dev/null 2>&1\n` +334 `echo react env ready\n`,335 30336 )337 const out = await sb(['snapshot', vm, '--stop', '--expiration', '30d'], {338 withStderr: true,339 })340 const snapId = out.match(/snap_[A-Za-z0-9]+/)?.[0]341 if (!snapId)342 throw new Error(`could not parse snapshot id from: ${out.slice(-500)}`)343 fs.mkdirSync(REACT_SNAP_CACHE, { recursive: true })344 fs.writeFileSync(file, `${snapId}\n`)345 return snapId346 } finally {347 await rmVm(vm)348 }349}350 351async function main() {352 const args = parseArgs()353 if (DRY_RUN) {354 console.log(355 `[dry-run] gate plan: ${args.arms.map((a) => `${a.name}=${a.ref}`).join(', ')}`356 )357 console.log(358 `[dry-run] per arm: 1 VM from react build-env snapshot (lazy-created), ` +359 `yarn test --prod ${args.pattern} --ci` +360 (args.extra.length ? `, extra: ${args.extra.join(' && ')}` : '')361 )362 console.log('[dry-run] PASS requires: verified test counts (pipefail on).')363 process.exit(0)364 }365 // Each arm gates against ITS OWN lockfile's environment: arms with366 // different yarn.locks must not share node_modules.367 const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sandbox-gate-'))368 try {369 const results = await Promise.allSettled(370 args.arms.map(async (arm) => {371 const sha = await git(['rev-parse', arm.ref])372 const snapshot = args.snapshot ?? (await ensureReactBuildSnapshot(sha))373 return gateArm(arm, snapshot, tmp, args.extra, args.pattern)374 })375 )376 let failed = 0377 for (let i = 0; i < results.length; i++) {378 const r = results[i]379 if (r.status === 'fulfilled') {380 console.log(`GATE ${args.arms[i].name}: PASS (${r.value.sha})`)381 console.log(382 ` bench this exact sha: --arms cand=${r.value.sha} (a ref can move between gate and bench)`383 )384 } else {385 failed++386 console.log(387 `GATE ${args.arms[i].name}: FAIL — ${r.reason.message.slice(0, 2500)}`388 )389 }390 }391 process.exit(failed ? 1 : 0)392 } finally {393 fs.rmSync(tmp, { recursive: true, force: true })394 }395}396 397main().catch((e) => {398 console.error(e)399 process.exit(1)400})401