scripts/bench-common.mjs
scripts/bench-common.mjsBrowse 13 files
7,446 tokens
25,620 bytes
Token encoding: o200k_base
Snapshot bfcf687
← Back to SKILL.md
1// Shared core for the sandbox-bench launchers (sandbox-e2e.mjs,2// sandbox-ssr.mjs): config-derived constants, vercel sandbox plumbing3// (exec, chunked upload, detached loops), PR/arm resolution with the4// CI-green gate, cached react arm builds, snapshots, the status.json5// recovery record, and live interim estimates. Workload-specific code6// (what runs on a measurement VM) stays in each launcher.7import { execFile, spawn } from 'child_process'8import { promisify } from 'util'9import fs from 'fs'10import os from 'os'11import path from 'path'12import crypto from 'crypto'13import {14 loadConfig,15 sandboxScope,16 ensureNextRepo,17 ensureReactRepo,18} from './config.mjs'19 20const execFileP = promisify(execFile)21export { execFileP }22 23export const DRY_RUN = process.argv.includes('--dry-run')24export const CONFIG = loadConfig({ requireScope: !DRY_RUN })25// Clones happen on first use: the react one only when a react ref is26// benched or pinned (pure Next A/B uses each ref's vendored React).27export let nextRepoResolved28export function NEXT_REPO_LAZY() {29 if (!nextRepoResolved) nextRepoResolved = ensureNextRepo(CONFIG)30 return nextRepoResolved31}32export let reactRepoResolved33export function REACT_REPO_LAZY() {34 if (!reactRepoResolved) reactRepoResolved = ensureReactRepo(CONFIG)35 return reactRepoResolved36}37export const VERCEL = CONFIG.vercelBin38export const SCOPE = CONFIG.team ? sandboxScope(CONFIG) : []39export const CACHE = path.join(CONFIG.cacheDir, 'e2e')40export const REACT_SNAP_CACHE = path.join(CONFIG.cacheDir, 'react-snap')41export const SETUP_VERSION = 'v1-al2023-node24-jdk21'42// Targets build-all-release-channels needs for a sync-react-able arm43// (both channels, oss-stable + oss-experimental).44export const E2E_BUILD_TARGETS =45 'react/,react.react-server,react-dom/,react-dom.,react-dom-server,scheduler/,react-is,react-server-dom-turbopack,react-server-dom-webpack'46// owner/repo for CI-artifact lookup, from the configured clone URL.47export const REACT_GH_REPO =48 CONFIG.reactRepoUrl.match(/github\.com[:/]+([^/]+\/[^/.]+)/)?.[1] ??49 'react/react'50export const NEXT_GH_REPO =51 CONFIG.nextRepoUrl.match(/github\.com[:/]+([^/]+\/.+?)(?:\.git)?$/)?.[1] ??52 'vercel/next.js'53 54// Recovery record for bench-collect.mjs: if a launcher dies, the55// remote VMs keep executing their detached loops, and this file names56// them so the results can still be collected.57export const status = { file: null, state: {} }58export function writeStatus(patch) {59 if (!status.file) return60 status.state = {61 ...status.state,62 ...patch,63 updatedAt: new Date().toISOString(),64 }65 try {66 fs.writeFileSync(status.file, JSON.stringify(status.state, null, 2))67 } catch {}68}69 70export async function sb(args, opts = {}) {71 const scoped = ['sandbox', ...args]72 const sep = scoped.indexOf('--')73 scoped.splice(sep < 0 ? scoped.length : sep, 0, ...SCOPE)74 const { stdout, stderr } = await execFileP(VERCEL, scoped, {75 maxBuffer: 64 * 1024 * 1024,76 ...opts,77 })78 // The CLI prints some results (e.g. snapshot ids) on stderr.79 return `${stdout}\n${stderr}`80}81 82// The platform rejects single `sandbox cp` uploads somewhere above ~128MB83// ("Request Entity Too Large", observed 2026-07-20; 645MB tree tarballs that84// uploaded fine hours earlier started failing). Upload large files in parts85// and reassemble on the VM, verifying the sha256 end to end.86export const CP_CHUNK_BYTES = 128 * 1024 * 102487export async function sbCpToVm(vm, localPath, vmDest) {88 const size = fs.statSync(localPath).size89 if (size <= CP_CHUNK_BYTES) {90 await sb(['cp', localPath, `${vm}:${vmDest}`])91 return92 }93 const partDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbcp-'))94 try {95 await execFileP('split', [96 '-b',97 String(CP_CHUNK_BYTES),98 localPath,99 path.join(partDir, 'part-'),100 ])101 const parts = fs.readdirSync(partDir).sort()102 for (const p of parts) {103 await sb(['cp', path.join(partDir, p), `${vm}:${vmDest}.${p}`])104 }105 const localSha = (106 await execFileP('shasum', ['-a', '256', localPath])107 ).stdout.split(' ')[0]108 const catList = parts.map((p) => `'${vmDest}.${p}'`).join(' ')109 const out = await sbExec(110 vm,111 '10m',112 `cat ${catList} > '${vmDest}' && rm -f ${catList} && sha256sum '${vmDest}' | cut -d' ' -f1`,113 `cp:${path.basename(vmDest)}`114 )115 // sbExec output interleaves stderr (CLI banners); take the last116 // sha-shaped token rather than the last line.117 const shaTokens = out.match(/\b[0-9a-f]{64}\b/g)118 const remoteSha = shaTokens ? shaTokens[shaTokens.length - 1] : ''119 if (remoteSha !== localSha) {120 throw new Error(121 `chunked upload of ${localPath} corrupt: local ${localSha} != remote ${remoteSha}`122 )123 }124 } finally {125 fs.rmSync(partDir, { recursive: true, force: true })126 }127}128 129export // PR spec ("37023" or a github PR URL) -> arms: base = merge-base of the130// PR head with upstream main, cand = PR head. Fetched into local refs so131// git archive / yarn.lock reads work as for any other sha.132async function resolvePrArms(pr, repo, repoUrl, defaultBranch) {133 const num = String(pr).match(/(\d+)\/?$/)?.[1]134 if (!num) throw new Error(`cannot parse PR number from "${pr}"`)135 console.error(`fetching ${repoUrl} PR #${num} + ${defaultBranch}...`)136 // The clone is shared: concurrent launchers fetching the same ref137 // race the ref lock. Namespace the temp refs by pid and retry the138 // fetch (pack files still contend occasionally).139 const ns = `refs/bench-tmp/${process.pid}`140 for (let attempt = 1; ; attempt++) {141 try {142 await execFileP('git', [143 '-C',144 repo,145 'fetch',146 '-q',147 repoUrl,148 `+refs/pull/${num}/head:${ns}/pr-${num}`,149 `+refs/heads/${defaultBranch}:${ns}/upstream-${defaultBranch}`,150 ])151 break152 } catch (e) {153 if (attempt >= 3) throw e154 console.error(155 `fetch attempt ${attempt} failed (${e.message.split('\n')[0].slice(0, 80)}); retrying...`156 )157 await new Promise((r) =>158 setTimeout(r, 5000 * attempt + Math.random() * 5000)159 )160 }161 }162 const cand = (163 await execFileP('git', ['-C', repo, 'rev-parse', `${ns}/pr-${num}`])164 ).stdout.trim()165 const base = (166 await execFileP('git', [167 '-C',168 repo,169 'merge-base',170 `${ns}/upstream-${defaultBranch}`,171 cand,172 ])173 ).stdout.trim()174 await execFileP('git', [175 '-C',176 repo,177 'update-ref',178 '-d',179 `${ns}/pr-${num}`,180 ]).catch(() => {})181 await execFileP('git', [182 '-C',183 repo,184 'update-ref',185 '-d',186 `${ns}/upstream-${defaultBranch}`,187 ]).catch(() => {})188 console.error(189 `PR #${num}: cand=${cand.slice(0, 12)} base=${base.slice(0, 12)} (merge-base with ${defaultBranch})`190 )191 return [192 { name: 'base', ref: base },193 { name: `pr${num}`, ref: cand },194 ]195}196 197// Green CI on the react repo is the correctness gate for react arms: a198// perf number from a broken build is worse than no number. Local or199// unpushed refs have no CI — --allow-ungated skips the check, and200// sandbox-gate.mjs exists to gate such refs on a VM instead.201const ciVerdicts = new Map()202export async function assertCiGreen(sha, armName, allowUngated) {203 if (!ciVerdicts.has(sha)) {204 let verdict205 try {206 const out = (207 await execFileP(208 'gh',209 [210 'api',211 '--paginate',212 `repos/${REACT_GH_REPO}/commits/${sha}/check-runs?per_page=100`,213 '--jq',214 '[.check_runs[] | {name, conclusion}]',215 ],216 { maxBuffer: 1 << 24 }217 )218 ).stdout219 const checks = out220 .trim()221 .split('\n')222 .filter(Boolean)223 .flatMap((page) => JSON.parse(page))224 if (checks.length === 0) {225 verdict = 'no CI runs found (unpushed or unbuilt commit)'226 } else if (checks.some((c) => c.conclusion === null)) {227 verdict = 'CI still running — retry when it finishes'228 } else {229 // Policy: all tests (including build), flow, and lint must be230 // green. DevTools suites and repo-infra jobs (artifact syncs,231 // cleanup, staleness) don't gate benching. Ignore-by-name, so232 // any NEW job blocks by default instead of being skipped.233 const IGNORED =234 /devtools|^cleanup$|^stale$|_artifacts$|^sizebot|^dependabot/i235 const relevant = checks.filter((c) => !IGNORED.test(c.name))236 const bad = relevant.filter(237 (c) =>238 c.conclusion !== 'success' &&239 c.conclusion !== 'neutral' &&240 c.conclusion !== 'skipped'241 )242 // Name the offenders: the human deciding whether to proceed243 // ungated needs to see what failed at a glance, not re-query CI.244 verdict =245 bad.length === 0246 ? 'green'247 : `CI not green (${bad.length}/${relevant.length} relevant checks): ` +248 [...new Set(bad.map((c) => `${c.name} (${c.conclusion})`))]249 .slice(0, 8)250 .join('; ')251 }252 } catch (e) {253 verdict = `could not query CI (${e.message.split('\n')[0].slice(0, 80)})`254 }255 ciVerdicts.set(sha, verdict)256 }257 const verdict = ciVerdicts.get(sha)258 if (verdict === 'green') {259 console.error(`arm ${armName}: react ${sha.slice(0, 12)} CI green`)260 return261 }262 if (allowUngated) {263 console.error(264 `arm ${armName}: react ${sha.slice(0, 12)} UNGATED (${verdict}) — proceeding per --allow-ungated`265 )266 return267 }268 throw new Error(269 `arm ${armName}: react ${sha.slice(0, 12)}: ${verdict}.\n` +270 'Benching an unverified build produces untrustworthy numbers. Either wait for/fix CI, ' +271 'gate the ref yourself (node sandbox-gate.mjs --arms ' +272 armName +273 '=<ref>), and then ' +274 're-run with --allow-ungated, or pass --allow-ungated if you accept the risk.'275 )276}277 278export async function commitTitle(repo, ref) {279 try {280 return (281 await execFileP('git', ['-C', repo, 'log', '-1', '--format=%s', ref])282 ).stdout.trim()283 } catch {284 return undefined285 }286}287 288export function printRunContext(out, d) {289 if (!d) return290 if (d.pr) out(`PR: ${d.pr.title ? `"${d.pr.title}" — ` : ''}${d.pr.url}`)291 for (const a of d.arms ?? []) if (a.title) out(` ${a.name}: "${a.title}"`)292}293 294// Live running estimate, printed as pairs complete across all VMs.295const T95 = [296 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,297 2.14, 2.13,298]299export function makeLive(baseName, metrics, keyOf) {300 const rows = []301 return (vm, row) => {302 rows.push({ vmIdx: vm, ...row })303 const key = keyOf(row)304 const other = rows.find(305 (r) =>306 r.vmIdx === vm &&307 keyOf(r) === key &&308 r.block === row.block &&309 r.run === row.run &&310 r.arm !== row.arm311 )312 if (!other) return313 const cands = rows.filter((r) => keyOf(r) === key && r.arm !== baseName)314 const parts = []315 let n = 0316 for (const metric of metrics) {317 const deltas = []318 for (const c of cands) {319 const b = rows.find(320 (r) =>321 r.vmIdx === c.vmIdx &&322 keyOf(r) === key &&323 r.block === c.block &&324 r.run === c.run &&325 r.arm === baseName326 )327 if (b && b[metric] > 0) deltas.push((c[metric] - b[metric]) / b[metric])328 }329 n = deltas.length330 if (n < 2) return331 const mean = deltas.reduce((a, b2) => a + b2, 0) / n332 const sd = Math.sqrt(333 deltas.reduce((a, b2) => a + (b2 - mean) ** 2, 0) / (n - 1)334 )335 const ci = ((T95[n - 2] ?? 2.0) * sd) / Math.sqrt(n)336 const p = pairedP(deltas)337 parts.push(338 `${metric} ${(mean * 100).toFixed(1)}%±${(ci * 100).toFixed(1)} p=${p.toFixed(3)}`339 )340 }341 console.error(`live ${key} n=${n}: ${parts.join(' ')}`)342 }343}344 345export function pairedP(deltas) {346 const n = deltas.length347 if (n < 2) return 1348 const mean = deltas.reduce((a, b) => a + b, 0) / n349 const sd = Math.sqrt(350 deltas.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1)351 )352 if (sd === 0) return mean === 0 ? 1 : 0353 const t = Math.abs(mean / (sd / Math.sqrt(n)))354 const df = n - 1355 const pdf = (x) => Math.exp(-((df + 1) / 2) * Math.log(1 + (x * x) / df))356 let integral = 0357 const STEP = 0.001358 for (let x = t; x < t + 60; x += STEP) integral += pdf(x + STEP / 2) * STEP359 let norm = 0360 for (let x = 0; x < 80; x += STEP) norm += pdf(x + STEP / 2) * STEP361 return Math.min(1, integral / norm)362}363 364export function sbExec(vm, timeout, script, tag, onRow) {365 return new Promise((resolve, reject) => {366 // The CLI does not reliably propagate the remote exit code (observed367 // exit 0 after a remote `exit 1`, CLI 56.3.x), so the script reports368 // its own exit through an EXIT-trap marker; no marker means the369 // transport died mid-run. Both are failures.370 const wrapped = `trap 'echo "@@EXEC_EXIT $?"' EXIT371${script}`372 const child = spawn(373 VERCEL,374 [375 'sandbox',376 'exec',377 vm,378 ...SCOPE,379 '--timeout',380 timeout,381 '--',382 'bash',383 '-c',384 wrapped,385 ],386 { stdio: ['ignore', 'pipe', 'pipe'] }387 )388 let out = ''389 let buf = ''390 child.stdout.on('data', (c) => {391 out += c392 buf += c393 const lines = buf.split('\n')394 buf = lines.pop()395 for (const l of lines) {396 if (!l) continue397 if (l.startsWith('ROW ') && onRow) {398 try {399 onRow(JSON.parse(l.slice(4)))400 } catch {}401 } else {402 process.stderr.write(`[${tag}] ${l}\n`)403 }404 }405 })406 child.stderr.on('data', (c) => {407 out += c408 process.stderr.write(409 String(c)410 .split('\n')411 .filter(Boolean)412 .map((l) => `[${tag}!] ${l}\n`)413 .join('')414 )415 })416 child.on('exit', (code) => {417 const marker = out.match(/@@EXEC_EXIT (\d+)\s*$/m)418 if (code === 0 && marker && marker[1] === '0') {419 resolve(out)420 } else {421 const why =422 code !== 0423 ? `cli exit ${code}`424 : marker425 ? `remote exit ${marker[1]}`426 : 'no exit marker (transport died mid-run)'427 reject(new Error(`${tag}: ${why}\n${out.slice(-2000)}`))428 }429 })430 })431}432 433export async function rmVm(name) {434 try {435 await sb(['rm', name])436 } catch (e) {437 process.stderr.write(`warning: could not remove ${name}: ${e.message}\n`)438 }439}440 441// Long-running remote work detached from the exec stream (streams drop442// flakily on multi-minute silences): nohup the script on the VM, then443// poll its log with short execs. Immune to transport hiccups.444export async function runDetached(vm, tag, script, onLine, deadlineMin) {445 let transcript = ''446 const local = path.join(os.tmpdir(), `loop-${vm}.sh`)447 // EXIT trap, not ERR: the ERR trap does not fire for several failure448 // shapes (e.g. `cmd || (tail; exit 1)`), which left loop.done unwritten449 // and the poll waiting on heartbeats forever.450 fs.writeFileSync(451 local,452 `trap 'code=$?; if [ $code -eq 0 ]; then echo LOOPOK; else echo LOOPFAIL; fi > /vercel/sandbox/loop.done' EXIT\nset -e\n${script}\n`453 )454 await sb(['cp', local, `${vm}:/vercel/sandbox/loop.sh`])455 fs.rmSync(local, { force: true })456 await sb([457 'exec',458 vm,459 '--timeout',460 '2m',461 '--',462 'bash',463 '-c',464 'rm -f /vercel/sandbox/loop.done /vercel/sandbox/loop.log; nohup bash /vercel/sandbox/loop.sh >/vercel/sandbox/loop.log 2>&1 & echo kicked',465 ])466 let offset = 0467 let failures = 0468 const deadline = Date.now() + deadlineMin * 60_000469 while (true) {470 await new Promise((r) => setTimeout(r, 45_000))471 if (Date.now() > deadline)472 throw new Error(`${tag}: detached loop deadline exceeded`)473 let out474 try {475 out = await sb([476 'exec',477 vm,478 '--timeout',479 '2m',480 '--',481 'bash',482 '-c',483 `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)"`,484 ])485 failures = 0486 } catch (e) {487 if (++failures >= 6)488 throw new Error(489 `${tag}: ${failures} consecutive poll failures: ${e.message.slice(0, 200)}`490 )491 continue492 }493 const m = out.match(/@@SIZE (\d+) @@DONE (\S+)/)494 const body = out.slice(0, out.lastIndexOf('\n@@SIZE'))495 transcript += body496 for (const l of body.split('\n')) {497 if (!l) continue498 if (l.startsWith('ROW ') && onLine) {499 try {500 onLine(JSON.parse(l.slice(4)))501 } catch {}502 } else {503 process.stderr.write(`[${tag}] ${l}\n`)504 }505 }506 if (m) {507 offset = Math.min(Number(m[1]), offset + 200000)508 if (m[2] === 'LOOPOK') return transcript509 if (m[2] === 'LOOPFAIL')510 throw new Error(511 `${tag}: remote loop failed; tail:\n${body.slice(-1500)}`512 )513 }514 }515}516 517// ------------------------------------------------------------ snapshots518 519export async function sha256(text) {520 return crypto.createHash('sha256').update(text).digest('hex').slice(0, 16)521}522 523export async function snapshotIdFor(cacheDir, key) {524 const file = path.join(cacheDir, `snap-${key}`)525 if (!fs.existsSync(file)) return undefined526 const id = fs.readFileSync(file, 'utf8').trim()527 try {528 await sb(['snapshots', 'get', id])529 return id530 } catch {531 return undefined532 }533}534 535export async function takeSnapshot(vm, cacheDir, key) {536 const out = await sb(['snapshot', vm, '--stop', '--expiration', '30d'])537 const id = out.match(/snap_[A-Za-z0-9]+/)?.[0]538 if (!id)539 throw new Error(`could not parse snapshot id from: ${out.slice(-500)}`)540 fs.mkdirSync(cacheDir, { recursive: true })541 fs.writeFileSync(path.join(cacheDir, `snap-${key}`), `${id}\n`)542 console.error(`snapshot ${id} cached (${key})`)543 return id544}545 546// React build environment (repo + node_modules + JDK), shared with547// sandbox-ab.mjs. Keyed on the arm's yarn.lock.548export async function ensureReactBuildSnapshot(refSha) {549 const lock = await execFileP(550 'git',551 ['-C', REACT_REPO_LAZY(), 'show', `${refSha}:yarn.lock`],552 { maxBuffer: 1 << 28 }553 )554 const key = await sha256(SETUP_VERSION + lock.stdout)555 let id = await snapshotIdFor(REACT_SNAP_CACHE, key)556 if (id) return id557 const vm = `react-snap-build-${Date.now().toString(36)}`558 console.error(559 `creating react build-env snapshot (one-time for this yarn.lock)...`560 )561 await sb([562 'create',563 '--name',564 vm,565 '--runtime',566 'node24',567 '--vcpus',568 '8',569 '--timeout',570 '45m',571 '--non-persistent',572 '--network-policy',573 'allow-all',574 '--tag',575 'purpose=sandbox-bench',576 '--silent',577 ])578 try {579 const src = path.join(os.tmpdir(), `react-snap-src-${key}.tgz`)580 await execFileP('bash', [581 '-c',582 `git -C ${REACT_REPO_LAZY()} archive ${refSha} | gzip -1 > ${src}`,583 ])584 await sb(['cp', src, `${vm}:/vercel/sandbox/src.tgz`])585 fs.rmSync(src, { force: true })586 await sb([587 'exec',588 vm,589 '--timeout',590 '10m',591 '--sudo',592 '--',593 'dnf',594 'install',595 '-y',596 '-q',597 'java-21-amazon-corretto-headless',598 ])599 await sbExec(600 vm,601 '20m',602 `set -e; mkdir -p /vercel/sandbox/react && cd /vercel/sandbox/react && tar -xzf ../src.tgz && rm -f ../src.tgz && ` +603 `npm i -g yarn >/dev/null 2>&1 && yarn install --frozen-lockfile --ignore-engines >/dev/null 2>&1 && echo react env ready`,604 'react-snap'605 )606 return await takeSnapshot(vm, REACT_SNAP_CACHE, key)607 } finally {608 await rmVm(vm)609 }610}611 612// React CI builds every upstream commit and PR; reuse those artifacts613// instead of building. Requires gh auth. Returns false -> build remotely.614export async function tryCiArtifactArm(sha, cached, pack) {615 try {616 const runs = JSON.parse(617 (618 await execFileP(619 'gh',620 [621 'api',622 `repos/${REACT_GH_REPO}/actions/workflows/runtime_build_and_test.yml/runs?head_sha=${sha}&per_page=5`,623 ],624 { maxBuffer: 1 << 24 }625 )626 ).stdout627 )628 const id = runs.workflow_runs?.find((r) => r.head_sha === sha)?.id629 if (!id) return false630 const arts = JSON.parse(631 (632 await execFileP(633 'gh',634 [635 'api',636 `repos/${REACT_GH_REPO}/actions/runs/${id}/artifacts?name=artifacts_combined`,637 ],638 { maxBuffer: 1 << 24 }639 )640 ).stdout641 )642 const art = arts.artifacts?.find(643 (a) => a.name === 'artifacts_combined' && !a.expired644 )645 if (!art) return false646 console.error(647 `downloading CI artifacts for ${sha.slice(0, 12)} (run ${id})...`648 )649 const work = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-arm-'))650 try {651 await execFileP(652 'bash',653 [654 '-c',655 `cd ${work} && gh api repos/${REACT_GH_REPO}/actions/artifacts/${art.id}/zip > a.zip && ` +656 `unzip -q a.zip && tar -xzf build.tgz && ${pack}`,657 ],658 { maxBuffer: 1 << 24 }659 )660 } finally {661 fs.rmSync(work, { recursive: true, force: true })662 }663 return fs.existsSync(cached) && fs.statSync(cached).size > 1 << 20664 } catch (e) {665 console.error(666 `CI artifact lookup failed (${e.message.slice(0, 100)}); building remotely`667 )668 return false669 }670}671 672// Ref arms build remotely (dual-channel yarn build) and cache by sha.673export async function ensureRefArm(arm) {674 const sha = (675 await execFileP('git', ['-C', REACT_REPO_LAZY(), 'rev-parse', arm.ref])676 ).stdout.trim()677 arm.sha = sha678 // Key on the build recipe too: a changed target list must not serve679 // stale artifacts from the shared cache.680 const recipe = crypto681 .createHash('sha256')682 .update(E2E_BUILD_TARGETS)683 .digest('hex')684 .slice(0, 6)685 const cached = path.join(CACHE, `arm-${sha.slice(0, 12)}-${recipe}.tgz`)686 arm.tgz = cached687 if (fs.existsSync(cached)) {688 console.error(`arm ${arm.name}=${sha.slice(0, 12)} (cached build)`)689 return690 }691 fs.mkdirSync(CACHE, { recursive: true })692 if (693 await tryCiArtifactArm(694 sha,695 cached,696 `tar -czf ${cached} build/oss-stable build/oss-experimental`697 )698 ) {699 console.error(`arm ${arm.name}=${sha.slice(0, 12)} from CI artifacts`)700 return701 }702 const snap = await ensureReactBuildSnapshot(sha)703 const vm = `sbench-armbuild-${Date.now().toString(36)}`704 console.error(705 `building react arm ${arm.name}=${sha.slice(0, 12)} remotely...`706 )707 await sb([708 'create',709 '--name',710 vm,711 '--snapshot',712 snap,713 '--vcpus',714 '8',715 '--timeout',716 '45m',717 '--non-persistent',718 '--network-policy',719 'allow-all',720 '--tag',721 'purpose=sandbox-bench',722 '--silent',723 ])724 try {725 // build-all-release-channels stamps versions from git (sha + commit726 // date), so the upload must be a real shallow checkout, not a bare727 // archive.728 const src = path.join(os.tmpdir(), `arm-src-${sha.slice(0, 12)}.tgz`)729 const work = fs.mkdtempSync(path.join(os.tmpdir(), 'arm-git-'))730 const tmpRef = `refs/bench-tmp/${sha.slice(0, 12)}`731 await execFileP('git', ['-C', REACT_REPO_LAZY(), 'update-ref', tmpRef, sha])732 try {733 await execFileP('git', ['init', '-q', work])734 await execFileP('git', [735 '-C',736 work,737 'fetch',738 '-q',739 '--depth',740 '1',741 REACT_REPO_LAZY(),742 tmpRef,743 ])744 await execFileP('git', ['-C', work, 'checkout', '-q', 'FETCH_HEAD'])745 await execFileP('bash', [746 '-c',747 `cd ${work} && COPYFILE_DISABLE=1 tar --no-xattrs -czf ${src} .`,748 ])749 } finally {750 await execFileP('git', [751 '-C',752 REACT_REPO_LAZY(),753 'update-ref',754 '-d',755 tmpRef,756 ])757 fs.rmSync(work, { recursive: true, force: true })758 }759 await sb(['cp', src, `${vm}:/vercel/sandbox/src.tgz`])760 fs.rmSync(src, { force: true })761 // The exec stream drops on long silent commands; heartbeat keeps it762 // alive during the ~10min dual-channel build. Newline before the763 // backgrounded heartbeat: a trailing & after && backgrounds the764 // whole chain.765 await sbExec(766 vm,767 '40m',768 `set -e769ls /vercel/sandbox/react/node_modules >/dev/null770cd /vercel/sandbox/react771find . -mindepth 1 -maxdepth 1 ! -name node_modules -exec rm -rf {} +772tar -xzf ../src.tgz773git rev-parse HEAD774(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=$!775yarn build "${E2E_BUILD_TARGETS}" >/tmp/build.log 2>&1 || (kill $HB; tail -20 /tmp/build.log; exit 1)776kill $HB777tar -czf /vercel/sandbox/arm.tgz build/oss-stable build/oss-experimental778echo arm built`,779 `armbuild:${arm.name}`780 )781 fs.mkdirSync(CACHE, { recursive: true })782 const armTmp = `${cached}.tmp-${process.pid}`783 await sb(['cp', `${vm}:/vercel/sandbox/arm.tgz`, armTmp])784 if (fs.existsSync(armTmp)) fs.renameSync(armTmp, cached)785 // sandbox cp does not reliably fail on missing remote files.786 if (!fs.existsSync(cached) || fs.statSync(cached).size < 1 << 20) {787 fs.rmSync(cached, { force: true })788 throw new Error(789 `arm ${arm.name}: downloaded artifact missing or too small`790 )791 }792 console.error(`cached ${cached}`)793 } finally {794 await rmVm(vm)795 }796}797