scripts/sandbox-ssr.mjs
scripts/sandbox-ssr.mjsBrowse 13 files
6,276 tokens
21,383 bytes
Token encoding: o200k_base
Snapshot bfcf687
← Back to SKILL.md
1// Remote React SSR A/B on Vercel Sandbox via the react repo's2// fixtures/flight-ssr-bench: 8 render variants (Fizz and Flight+Fizz,3// Node and Edge streams, sync and async), both arms always in the SAME4// VM, paired per (block, run), ABBA order. The VM boot is the unit of5// replication; see bench-stats.mjs.6//7// This is the Edge-path complement to sandbox-e2e.mjs (which measures8// the Node path through a real Next.js app). Arms vary React only. The9// fixture (the workload) is pinned to ONE ref for both arms — default10// the react main tip — so only the React builds differ.11//12// Usage:13// node sandbox-ssr.mjs --pr <react pr url|num> [--vms 16] [--label x]14// node sandbox-ssr.mjs --arms base=<ref>,cand=<ref>15// Common: [--runs 2] [--fixture-ref main] [--no-profile] [--keep]16// [--allow-ungated] [--dry-run]17import fs from 'fs'18import os from 'os'19import path from 'path'20import { analyzeE2eRows } from './bench-stats.mjs'21import { openDb, importRun, loadRows, verify as verifyDb } from './bench-db.mjs'22import {23 execFileP,24 CONFIG,25 REACT_REPO_LAZY,26 CACHE,27 SETUP_VERSION,28 REACT_GH_REPO,29 status,30 writeStatus,31 sb,32 sbExec,33 rmVm,34 runDetached,35 resolvePrArms,36 assertCiGreen,37 commitTitle,38 printRunContext,39 makeLive,40 sha256,41 snapshotIdFor,42 takeSnapshot,43 ensureRefArm,44} from './bench-common.mjs'45 46const FIXTURE_DIR = 'fixtures/flight-ssr-bench'47// Provenance: the Flight server/client files the fixture actually48// executes — Node and Edge entry points, so changes touching only one49// stream flavor still move the fingerprint — plus the shared50// react-server runtime (hooks/cache), which none of the layer files51// reflect.52const FP_FILES = [53 'react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.production.js',54 'react-server-dom-webpack/cjs/react-server-dom-webpack-server.edge.production.js',55 'react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.production.js',56 'react-dom/cjs/react-dom-server.node.production.js',57 'react-dom/cjs/react-dom-server.edge.production.js',58 'react/cjs/react.react-server.production.js',59]60 61function parseArgs() {62 const a = process.argv.slice(2)63 const get = (name, dflt) => {64 const i = a.indexOf(name)65 return i >= 0 ? a[i + 1] : dflt66 }67 const arms = get('--arms', '')68 .split(',')69 .filter(Boolean)70 .map((s) => {71 const [name, src] = s.split('=')72 if (!name || !src)73 throw new Error(`bad arm "${s}" in --arms, want name=<ref>`)74 return { name, ref: src }75 })76 const pr = get('--pr', undefined)77 if ((pr ? 1 : 0) + (arms.length ? 1 : 0) !== 1) {78 throw new Error('need exactly one of: --pr, --arms')79 }80 if (arms.length && arms.length !== 2)81 throw new Error('--arms needs exactly two arms (base first)')82 return {83 arms,84 pr,85 dryRun: a.includes('--dry-run'),86 allowUngated: a.includes('--allow-ungated'),87 // The workload: one fixture tree shared by both arms.88 fixtureRef: get('--fixture-ref', 'main'),89 runs: Number(get('--runs', '2')),90 vms: Number(get('--vms', '16')),91 keep: a.includes('--keep'),92 profile: !a.includes('--no-profile'),93 label: get('--label', 'ssr'),94 }95}96 97// Resolve a react ref: shas and already-fetched refs locally, branch98// names against the remote (so "main" is today's main), pinned once99// per run.100const reactShaMemo = new Map()101async function reactShaFor(ref) {102 if (reactShaMemo.has(ref)) return reactShaMemo.get(ref)103 const repo = REACT_REPO_LAZY()104 let sha105 if (/^[0-9a-f]{7,40}$/i.test(ref)) {106 try {107 sha = (108 await execFileP('git', [109 '-C',110 repo,111 'rev-parse',112 '--verify',113 `${ref}^{commit}`,114 ])115 ).stdout.trim()116 } catch {}117 }118 if (!sha) {119 const dst = `refs/bench-tmp/${process.pid}/ssr-${reactShaMemo.size}`120 for (let attempt = 1; ; attempt++) {121 try {122 await execFileP('git', [123 '-C',124 repo,125 'fetch',126 '-q',127 CONFIG.reactRepoUrl,128 `+${ref}:${dst}`,129 ])130 break131 } catch (e) {132 if (attempt >= 3) throw e133 await new Promise((r) => setTimeout(r, 2000 * attempt))134 }135 }136 sha = (137 await execFileP('git', ['-C', repo, 'rev-parse', `${dst}^{commit}`])138 ).stdout.trim()139 }140 reactShaMemo.set(ref, sha)141 return sha142}143 144async function resolveArms(cfg) {145 const arms = cfg.pr146 ? await resolvePrArms(147 cfg.pr,148 REACT_REPO_LAZY(),149 CONFIG.reactRepoUrl,150 'main'151 )152 : cfg.arms153 for (const arm of arms) {154 arm.sha = await reactShaFor(arm.ref)155 }156 cfg.fixtureSha = await reactShaFor(cfg.fixtureRef)157 console.error(158 `fixture: ${FIXTURE_DIR} @ ${cfg.fixtureRef} (${cfg.fixtureSha.slice(0, 12)})`159 )160 // The fixture must emit machine-readable results; parsing its human161 // tables would silently break as it evolves.162 const benchJs = (163 await execFileP(164 'git',165 [166 '-C',167 REACT_REPO_LAZY(),168 'show',169 `${cfg.fixtureSha}:${FIXTURE_DIR}/bench.js`,170 ],171 { maxBuffer: 1 << 24 }172 )173 ).stdout174 if (!benchJs.includes('--json-out')) {175 throw new Error(176 `the fixture at ${cfg.fixtureRef} does not support --json-out; ` +177 'pass --fixture-ref <ref that does> (see SKILL.md)'178 )179 }180 // The workload is pinned; if the PR itself changes the fixture, this181 // run will NOT measure those changes. Say so rather than silently182 // benching something else.183 const touched = (184 await execFileP('git', [185 '-C',186 REACT_REPO_LAZY(),187 'diff',188 '--name-only',189 `${arms[0].sha}..${arms[1].sha}`,190 '--',191 FIXTURE_DIR,192 ])193 ).stdout.trim()194 if (touched) {195 cfg.fixtureTouchedByPr = true196 console.error(197 `NOTE: the candidate changes ${FIXTURE_DIR} itself; the benchmark ` +198 `uses the pinned fixture (${cfg.fixtureSha.slice(0, 12)}) and does ` +199 `not measure those fixture changes:\n${touched}`200 )201 }202 return arms203}204 205// Snapshot: fixture installed + both arms' builds staged, so run VMs206// boot straight into measurement.207async function ensureSsrSnapshot(cfg) {208 const key = await sha256(209 SETUP_VERSION +210 'ssr1' +211 cfg.fixtureSha +212 cfg.arms.map((a) => `${a.name}=${a.sha}`).join()213 )214 let id = await snapshotIdFor(CACHE, key)215 if (id) return id216 const vm = `sbench-ssrsnap-${Date.now().toString(36)}`217 console.error(218 `creating ssr snapshot (one-time for fixture=${cfg.fixtureSha.slice(0, 12)} ` +219 `arms=${cfg.arms.map((a) => a.sha.slice(0, 12)).join(',')})...`220 )221 await sb([222 'create',223 '--name',224 vm,225 '--runtime',226 'node24',227 '--vcpus',228 '8',229 '--timeout',230 '45m',231 '--non-persistent',232 '--network-policy',233 'allow-all',234 '--tag',235 'purpose=sandbox-bench',236 '--silent',237 ])238 try {239 const fixtureTgz = path.join(240 os.tmpdir(),241 `ssr-fixture-${cfg.fixtureSha.slice(0, 12)}.tgz`242 )243 await execFileP('bash', [244 '-c',245 `git -C ${REACT_REPO_LAZY()} archive ${cfg.fixtureSha} ${FIXTURE_DIR} | gzip -1 > ${fixtureTgz}`,246 ])247 await sb(['cp', fixtureTgz, `${vm}:/vercel/sandbox/fixture.tgz`])248 fs.rmSync(fixtureTgz, { force: true })249 for (const arm of cfg.arms) {250 await sb(['cp', arm.tgz, `${vm}:/vercel/sandbox/arm-${arm.name}.tgz`])251 }252 const extractArms = cfg.arms253 .map(254 (a) =>255 `mkdir -p /vercel/sandbox/arm-${a.name} && tar -xzf /vercel/sandbox/arm-${a.name}.tgz -C /vercel/sandbox/arm-${a.name}`256 )257 .join('\n')258 // Smoke: one full bench pass on the base arm proves the fixture259 // installs, builds its bundle, and emits parseable JSON before 16260 // VMs boot from this snapshot.261 await sbExec(262 vm,263 '35m',264 `set -e265npm i -g yarn >/dev/null 2>&1266mkdir -p /vercel/sandbox/fixture267tar -xzf /vercel/sandbox/fixture.tgz --strip-components=2 -C /vercel/sandbox/fixture268${extractArms}269cd /vercel/sandbox/fixture270echo "PHASE install $(date +%s)"271yarn install --ignore-engines >/tmp/install.log 2>&1 || (tail -10 /tmp/install.log; exit 1)272echo "PHASE smoke $(date +%s)"273for p in /vercel/sandbox/arm-${cfg.arms[0].name}/build/oss-experimental/*; do rm -rf node_modules/$(basename $p); done274cp -r /vercel/sandbox/arm-${cfg.arms[0].name}/build/oss-experimental/* node_modules/275NODE_ENV=production node --expose-gc bench.js --json-out=/tmp/smoke.json >/tmp/smoke.log 2>&1 || (tail -20 /tmp/smoke.log; exit 1)276node -e 'const j=require("/tmp/smoke.json"); if (!Array.isArray(j.results) || j.results.length < 4) { console.error("smoke json bad"); process.exit(1); }'277rm -f /tmp/smoke.json /vercel/sandbox/fixture.tgz /vercel/sandbox/arm-*.tgz278echo "PHASE done $(date +%s)"279echo ssr env ready`,280 'ssrsnap'281 )282 return await takeSnapshot(vm, CACHE, key)283 } finally {284 await rmVm(vm)285 }286}287 288// ------------------------------------------------------------------ run289 290async function runVm(index, cfg, snap, outDir) {291 const vm = `sbench-${cfg.label}-${index}-${Date.now().toString(36)}`292 const tag = `vm${index}`293 console.error(`${tag}: creating ${vm} from ssr snapshot`)294 writeStatus({295 vms: { ...status.state.vms, [vm]: { state: 'booting', rows: 0 } },296 })297 await sb([298 'create',299 '--name',300 vm,301 '--snapshot',302 snap,303 '--vcpus',304 '8',305 '--timeout',306 '5h',307 '--non-persistent',308 '--network-policy',309 'allow-all',310 '--tag',311 'purpose=sandbox-bench',312 '--silent',313 ])314 try {315 const [base, cand] = cfg.arms.map((a) => a.name)316 // Row emitter: fixture JSON -> one row per (variant, phase), variant317 // names normalized to the server's kebab keys ("Flight + Fizz318 // (Edge, async)" -> flight-edge-async). Metrics are OMITTED when319 // absent — a zero would pair against a real value as a fabricated320 // -100% claim.321 const emit = `322 const [,run,arm,fp,ver,cpu]=process.argv;323 const key=(n)=>n.toLowerCase().replace("flight + fizz","flight")324 .replace(/[(),]/g,"").trim().replace(/\\s+/g,"-");325 const rows=[];326 const inj=require("/tmp/inject.json");327 for (const r of inj.results) {328 const row={block:+run,arm,run:1,fp,ver,cpu,route:key(r.name),phase:"inject",329 mean:r.mean,median:r.median,p95:r.p95};330 if (r.gcTotalMs>0) row.gcMs=r.gcTotalMs/r.iterations;331 if (r.heapAfter>0) row.heapMb=r.heapAfter/1048576;332 rows.push(row);333 }334 const srv=require("/tmp/server.json");335 for (const r of srv.results) {336 const row={block:+run,arm,run:1,fp,ver,cpu,route:key(r.name),337 phase:"server-c"+r.concurrency,rps:r.reqPerSec,median:r.latencyMedian};338 if (r.latencyP99>0) row.p99=r.latencyP99;339 if (r.errors>0) row.errors=r.errors;340 rows.push(row);341 }342 console.log(rows.map(r=>JSON.stringify(r)).join("\\n"));343 `344 const loop = `set -e345VMINDEX=${index}346CPU=$(grep -m1 'model name' /proc/cpuinfo | cut -d: -f2- | sed 's/^ //')347: > /vercel/sandbox/results.jsonl348cd /vercel/sandbox/fixture349for arm in ${base} ${cand}; do350 V=$(node -e "console.log(require('/vercel/sandbox/arm-$arm/build/oss-experimental/react/package.json').version)")351 F=$(cat ${FP_FILES.map((f) => `/vercel/sandbox/arm-$arm/build/oss-experimental/${f}`).join(' ')} | sha256sum | cut -c1-12)352 echo "arm $arm ver=$V fp=$F"353 eval "VER_$arm=$V; FP_$arm=$F"354done355# Identical fingerprints can be legitimate (arms differing only in356# files outside FP_FILES), so warn, not fail.357if [ "$FP_${base}" = "$FP_${cand}" ]; then358 echo "WARNING: arms fingerprint identically ($FP_${base}) — the hashed React builds are byte-identical; verify the arms differ where intended"359fi360for run in $(seq 1 ${cfg.runs}); do361 # Alternate within the boot AND stagger by VM index so no arm owns362 # the cold first slot across the fleet.363 if [ $(((run + VMINDEX) % 2)) = 1 ]; then ORDER="${cand} ${base}"; else ORDER="${base} ${cand}"; fi364 for arm in $ORDER; do365 for p in /vercel/sandbox/arm-$arm/build/oss-experimental/*; do rm -rf node_modules/$(basename $p); done366 cp -r /vercel/sandbox/arm-$arm/build/oss-experimental/* node_modules/367 NODE_ENV=production node --expose-gc bench.js --json-out=/tmp/inject.json >/tmp/bench.log 2>&1 \368 || (tail -20 /tmp/bench.log; exit 1)369 NODE_ENV=production node bench-server.js --bench --json-out=/tmp/server.json >/tmp/server.log 2>&1 \370 || (tail -20 /tmp/server.log; exit 1)371 eval "FP=\\$FP_$arm; VER=\\$VER_$arm"372 node -e '${emit}' "$run" "$arm" "$FP" "$VER" "$CPU" > /tmp/rows.txt373 cat /tmp/rows.txt >> /vercel/sandbox/results.jsonl374 sed 's/^/ROW /' /tmp/rows.txt375 echo "run $run $arm done"376 done377done378wc -l /vercel/sandbox/results.jsonl`379 let vmRows = 0380 const out = await runDetached(381 vm,382 tag,383 loop,384 (row) => {385 vmRows++386 writeStatus({387 vms: {388 ...status.state.vms,389 [vm]: { state: 'measuring', rows: vmRows },390 },391 })392 cfg.live(index, row)393 },394 160395 )396 writeStatus({397 vms: {398 ...status.state.vms,399 [vm]: { ...status.state.vms[vm], state: 'collecting' },400 },401 })402 const local = path.join(outDir, `results-vm${index}.jsonl`)403 await sb(['cp', `${vm}:/vercel/sandbox/results.jsonl`, local])404 const remoteCount = Number(405 out.match(/(\d+) \/vercel\/sandbox\/results\.jsonl/)?.[1] ?? NaN406 )407 const localCount = fs408 .readFileSync(local, 'utf8')409 .trim()410 .split('\n')411 .filter(Boolean).length412 if (!Number.isFinite(remoteCount) || localCount !== remoteCount) {413 throw new Error(414 `${tag}: downloaded ${localCount} rows, remote reported ${remoteCount} — truncated transfer`415 )416 }417 if (cfg.profile) {418 writeStatus({419 vms: {420 ...status.state.vms,421 [vm]: { ...status.state.vms[vm], state: 'profiling' },422 },423 })424 // Strictly AFTER the timed runs — profiling never touches the425 // numbers. Best-effort: a failed pass must not kill collection.426 // Second-arm-runs-warmer drift cancels across VMs (see427 // sandbox-e2e.mjs).428 const profOrder = index % 2 === 1 ? `${cand} ${base}` : `${base} ${cand}`429 const prof = `set -e430cd /vercel/sandbox/fixture431for arm in ${profOrder}; do432 for p in /vercel/sandbox/arm-$arm/build/oss-experimental/*; do rm -rf node_modules/$(basename $p); done433 cp -r /vercel/sandbox/arm-$arm/build/oss-experimental/* node_modules/434 NODE_ENV=production node --expose-gc bench.js --profile >/tmp/prof.log 2>&1 || (tail -10 /tmp/prof.log; exit 1)435 mkdir -p /vercel/sandbox/prof-$arm && mv build/profiles/* /vercel/sandbox/prof-$arm/436 echo "profiled $arm"437done438# Lets profile analysis split by capture order (see sandbox-e2e.mjs).439echo "${profOrder}" > /vercel/sandbox/prof-order.txt440cd /vercel/sandbox && tar -czf profiles.tgz prof-*`441 try {442 await sbExec(vm, '40m', prof, `${tag}:prof`)443 const profTgz = path.join(outDir, `profiles-vm${index}.tgz`)444 await sb(['cp', `${vm}:/vercel/sandbox/profiles.tgz`, profTgz])445 if (!fs.existsSync(profTgz) || fs.statSync(profTgz).size === 0) {446 throw new Error('profile tarball missing or empty after cp')447 }448 const vmProfDir = path.join(outDir, `prof-vm${index}`)449 fs.mkdirSync(vmProfDir, { recursive: true })450 await execFileP('tar', ['-xzf', profTgz, '-C', vmProfDir])451 console.error(`${tag}: profiles in ${vmProfDir}`)452 } catch (profErr) {453 console.error(454 `${tag}: profile capture failed (timed results unaffected): ${profErr.message}`455 )456 }457 }458 writeStatus({459 vms: {460 ...status.state.vms,461 [vm]: { ...status.state.vms[vm], state: 'done' },462 },463 })464 return local465 } finally {466 if (!cfg.keep) await rmVm(vm)467 else console.error(`${tag}: kept ${vm}`)468 }469}470 471export const SSR_METRICS = [472 'rps',473 'mean',474 'median',475 'p95',476 'p99',477 'gcMs',478 'heapMb',479]480 481function analyze(cfg, outDir) {482 const db = openDb()483 const { runId, samples, artifacts } = importRun(db, outDir)484 const { failures, notes } = verifyDb(db, runId)485 for (const n of notes) console.log(`note: ${n}`)486 if (failures.length) {487 throw new Error(`results db verify FAILED:\n ${failures.join('\n ')}`)488 }489 console.error(490 `results db: ${samples} samples, ${artifacts} artifacts as ${runId} (verify ok)`491 )492 const rows = loadRows(db, runId)493 const [base, cand] = cfg.arms.map((a) => a.name)494 printRunContext((m) => console.log(m), cfg.runContext)495 console.log(`fixture: ${cfg.fixtureSha.slice(0, 12)} (${cfg.fixtureRef})`)496 analyzeE2eRows(rows, base, cand, SSR_METRICS)497}498 499// ----------------------------------------------------------------- main500 501async function plan(cfg) {502 const lines = []503 lines.push(504 `mode: ${cfg.pr ? `react PR ${cfg.pr} (ssr fixture suite)` : 'react A/B (ssr fixture suite)'}`505 )506 lines.push(507 `scope: team=${CONFIG.team ?? '<UNSET — ask user, then: node config.mjs set team=... project=...>'} project=${CONFIG.project ?? '<UNSET>'}`508 )509 try {510 const arms = await resolveArms(cfg)511 for (const arm of arms) {512 const cached = fs513 .readdirSync(CACHE)514 .some((f) => f.startsWith(`arm-${arm.sha.slice(0, 12)}`))515 lines.push(516 `arm ${arm.name}: react=${arm.sha.slice(0, 12)} (${cached ? 'build cached' : 'would build remotely ~15m'})`517 )518 lines.push(519 ` CI gate: react commit must be CI-green (or --allow-ungated after sandbox-gate.mjs)`520 )521 }522 lines.push(523 `fixture: ${FIXTURE_DIR} @ ${cfg.fixtureSha.slice(0, 12)} (pinned, both arms)`524 )525 } catch (e) {526 lines.push(527 `arms: unresolved in dry-run (${e.message.split('\n')[0].slice(0, 120)})`528 )529 }530 lines.push(531 `then: ssr snapshot (cached by content key; ~20m if cold) -> ` +532 `${cfg.vms} VMs x ${cfg.runs} paired ABBA runs, 8 variants x (inject + server c=1/c=10)`533 )534 lines.push(535 `then: boot-level analysis (n=${cfg.vms} boots) -> claims at p<0.01 on A/A-validated infra`536 )537 console.log(lines.map((l) => `[dry-run] ${l}`).join('\n'))538}539 540const cfg = parseArgs()541if (cfg.dryRun) {542 await plan(cfg)543 process.exit(0)544}545fs.mkdirSync(CACHE, { recursive: true })546const outDir = path.join(CACHE, `run-${cfg.label}-${Date.now().toString(36)}`)547fs.mkdirSync(outDir, { recursive: true })548console.log(`run dir: ${outDir}`)549status.file = path.join(outDir, 'status.json')550const digest = setInterval(() => {551 const vms = Object.values(status.state.vms ?? {})552 const rows = vms.reduce((a, v) => a + (v.rows ?? 0), 0)553 const states = {}554 for (const v of vms) states[v.state] = (states[v.state] ?? 0) + 1555 const vmSummary = Object.entries(states)556 .map(([k, n]) => `${n} ${k}`)557 .join(', ')558 console.log(559 `progress: ${status.state.phase}` +560 (status.state.rowsExpected561 ? ` — rows ${rows}/${status.state.rowsExpected}`562 : '') +563 (vmSummary ? ` (${vmSummary})` : '')564 )565}, 120_000)566digest.unref?.()567writeStatus({568 label: cfg.label,569 phase: 'resolving arms',570 pid: process.pid,571 startedAt: new Date().toISOString(),572 vms: {},573 rowsExpected: null,574})575try {576 cfg.arms = await resolveArms(cfg)577 // Human context for the analysis header and meta.json.578 const num = String(cfg.pr ?? '').match(/(\d+)\/?$/)?.[1]579 let pr580 if (num) {581 pr = { url: `https://github.com/${REACT_GH_REPO}/pull/${num}` }582 try {583 pr.title = (584 await execFileP('gh', [585 'api',586 `repos/${REACT_GH_REPO}/pulls/${num}`,587 '--jq',588 '.title',589 ])590 ).stdout.trim()591 } catch {}592 }593 cfg.runContext = {594 pr,595 arms: [],596 }597 for (const a of cfg.arms) {598 cfg.runContext.arms.push({599 name: a.name,600 title: await commitTitle(REACT_REPO_LAZY(), a.sha),601 })602 }603 printRunContext((m) => console.error(m), cfg.runContext)604 for (const arm of cfg.arms) {605 await assertCiGreen(arm.sha, arm.name, cfg.allowUngated)606 await ensureRefArm(arm)607 }608 fs.writeFileSync(609 path.join(outDir, 'meta.json'),610 JSON.stringify(611 {612 base: cfg.arms[0].name,613 cand: cfg.arms[1].name,614 label: cfg.label,615 suite: 'ssr',616 fixtureRef: cfg.fixtureRef,617 fixtureSha: cfg.fixtureSha,618 fixtureTouchedByPr: cfg.fixtureTouchedByPr || undefined,619 vms: cfg.vms,620 blocks: 1,621 runs: cfg.runs,622 pr: cfg.runContext.pr,623 arms: cfg.runContext.arms,624 },625 null,626 2627 )628 )629 cfg.live = makeLive(630 cfg.arms[0].name,631 ['median', 'rps'],632 (r) => `${r.route} ${r.phase}`633 )634 writeStatus({635 phase: 'building ssr snapshot',636 arms: cfg.arms.map((a) => `${a.name}=${a.sha.slice(0, 12)}`),637 })638 const snap = await ensureSsrSnapshot(cfg)639 writeStatus({ phase: 'measuring', snap })640 await Promise.all(641 Array.from({ length: cfg.vms }, (_, i) => runVm(i, cfg, snap, outDir))642 )643 writeStatus({ phase: 'analyzing' })644 console.error(`results in ${outDir}`)645 analyze(cfg, outDir)646 writeStatus({ phase: 'done' })647} catch (err) {648 writeStatus({649 phase: 'failed',650 error: String((err && err.message) || err).slice(0, 500),651 })652 throw err653} finally {654 clearInterval(digest)655}656