Setting the file. One moment.
Bench Common · Sandbox Bench · vercel/next.js · Skills Docs
ContentsBack to the top of the page 24.4
Bench Common
This file
Number 24.4
Position 4 of 12
Type JavaScript
Size 25 KB
Lines 796 scripts/ bench-common.mjs
JavaScript · 796 lines · 25 KB
11
import
path
from
'path'
12 import crypto from 'crypto'
13 import {
14 loadConfig,
15 sandboxScope,
16 ensureNextRepo,
17 ensureReactRepo,
18 } from './config.mjs'
19
20 const execFileP = promisify (execFile)
21 export { execFileP }
22
23 export const DRY_RUN = process.argv. includes ( '--dry-run' )
24 export const CONFIG = loadConfig ({ requireScope: ! DRY_RUN })
25 // Clones happen on first use: the react one only when a react ref is
26 // benched or pinned (pure Next A/B uses each ref's vendored React).
27 export let nextRepoResolved
28 export function NEXT_REPO_LAZY () {
29 if ( ! nextRepoResolved) nextRepoResolved = ensureNextRepo ( CONFIG )
30 return nextRepoResolved
31 }
32 export let reactRepoResolved
33 export function REACT_REPO_LAZY () {
34 if ( ! reactRepoResolved) reactRepoResolved = ensureReactRepo ( CONFIG )
35 return reactRepoResolved
36 }
37 export const VERCEL = CONFIG .vercelBin
38 export const SCOPE = CONFIG .team ? sandboxScope ( CONFIG ) : []
39 export const CACHE = path. join ( CONFIG .cacheDir, 'e2e' )
40 export const REACT_SNAP_CACHE = path. join ( CONFIG .cacheDir, 'react-snap' )
41 export const SETUP_VERSION = 'v1-al2023-node24-jdk21'
42 // Targets build-all-release-channels needs for a sync-react-able arm
43 // (both channels, oss-stable + oss-experimental).
44 export 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.
47 export const REACT_GH_REPO =
48 CONFIG .reactRepoUrl. match ( /github \. com [:/] + ( [ ^ /] + \/ [ ^ /.] + )/ )?.[ 1 ] ??
49 'react/react'
50 export 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, the
55 // remote VMs keep executing their detached loops, and this file names
56 // them so the results can still be collected.
57 export const status = { file: null , state: {} }
58 export function writeStatus ( patch ) {
59 if ( ! status.file) return
60 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
70 export 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 ~128MB
83 // ("Request Entity Too Large", observed 2026-07-20; 645MB tree tarballs that
84 // uploaded fine hours earlier started failing). Upload large files in parts
85 // and reassemble on the VM, verifying the sha256 end to end.
86 export const CP_CHUNK_BYTES = 128 * 1024 * 1024
87 export async function sbCpToVm ( vm , localPath , vmDest ) {
88 const size = fs. statSync (localPath).size
89 if (size <= CP_CHUNK_BYTES ) {
90 await sb ([ 'cp' , localPath, `${ vm }:${ vmDest }` ])
91 return
92 }
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 last
116 // 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
129 export // PR spec ("37023" or a github PR URL) -> arms: base = merge-base of the
130 // PR head with upstream main, cand = PR head. Fetched into local refs so
131 // git archive / yarn.lock reads work as for any other sha.
132 async 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 ref
137 // race the ref lock. Namespace the temp refs by pid and retry the
138 // 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 break
152 } catch (e) {
153 if (attempt >= 3 ) throw e
154 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: a
198 // perf number from a broken build is worse than no number. Local or
199 // unpushed refs have no CI — --allow-ungated skips the check, and
200 // sandbox-gate.mjs exists to gate such refs on a VM instead.
201 const ciVerdicts = new Map ()
202 export async function assertCiGreen ( sha , armName , allowUngated ) {
203 if ( ! ciVerdicts. has (sha)) {
204 let verdict
205 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 ).stdout
219 const checks = out
220 . 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 be
230 // green. DevTools suites and repo-infra jobs (artifact syncs,
231 // cleanup, staleness) don't gate benching. Ignore-by-name, so
232 // any NEW job blocks by default instead of being skipped.
233 const IGNORED =
234 /devtools |^ cleanup $|^ stale $| _artifacts $|^ sizebot |^ dependabot/ i
235 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 proceed
243 // ungated needs to see what failed at a glance, not re-query CI.
244 verdict =
245 bad. length === 0
246 ? '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 return
261 }
262 if (allowUngated) {
263 console. error (
264 `arm ${ armName }: react ${ sha . slice ( 0 , 12 ) } UNGATED (${ verdict }) — proceeding per --allow-ungated`
265 )
266 return
267 }
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
278 export 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 undefined
285 }
286 }
287
288 export function printRunContext ( out , d ) {
289 if ( ! d) return
290 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.
295 const 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 ]
299 export 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.arm
311 )
312 if ( ! other) return
313 const cands = rows. filter (( r ) => keyOf (r) === key && r.arm !== baseName)
314 const parts = []
315 let n = 0
316 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 === baseName
326 )
327 if (b && b[metric] > 0 ) deltas. push ((c[metric] - b[metric]) / b[metric])
328 }
329 n = deltas. length
330 if (n < 2 ) return
331 const mean = deltas. reduce (( a , b2 ) => a + b2, 0 ) / n
332 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
345 export function pairedP ( deltas ) {
346 const n = deltas. length
347 if (n < 2 ) return 1
348 const mean = deltas. reduce (( a , b ) => a + b, 0 ) / n
349 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 : 0
353 const t = Math. abs (mean / (sd / Math. sqrt (n)))
354 const df = n - 1
355 const pdf = ( x ) => Math. exp ( - ((df + 1 ) / 2 ) * Math. log ( 1 + (x * x) / df))
356 let integral = 0
357 const STEP = 0.001
358 for ( let x = t; x < t + 60 ; x += STEP ) integral += pdf (x + STEP / 2 ) * STEP
359 let norm = 0
360 for ( let x = 0 ; x < 80 ; x += STEP ) norm += pdf (x + STEP / 2 ) * STEP
361 return Math. min ( 1 , integral / norm)
362 }
363
364 export function sbExec ( vm , timeout , script , tag , onRow ) {
365 return new Promise (( resolve , reject ) => {
366 // The CLI does not reliably propagate the remote exit code (observed
367 // exit 0 after a remote `exit 1`, CLI 56.3.x), so the script reports
368 // its own exit through an EXIT-trap marker; no marker means the
369 // transport died mid-run. Both are failures.
370 const wrapped = `trap 'echo "@@EXEC_EXIT $?"' EXIT
371 ${ 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 += c
392 buf += c
393 const lines = buf. split ( ' \n ' )
394 buf = lines. pop ()
395 for ( const l of lines) {
396 if ( ! l) continue
397 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 += c
408 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 !== 0
423 ? `cli exit ${ code }`
424 : marker
425 ? `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
433 export 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 drop
442 // flakily on multi-minute silences): nohup the script on the VM, then
443 // poll its log with short execs. Immune to transport hiccups.
444 export 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 failure
448 // shapes (e.g. `cmd || (tail; exit 1)`), which left loop.done unwritten
449 // 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 \n set -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 = 0
467 let failures = 0
468 const deadline = Date. now () + deadlineMin * 60_000
469 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 out
474 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 = 0
486 } catch (e) {
487 if ( ++ failures >= 6 )
488 throw new Error (
489 `${ tag }: ${ failures } consecutive poll failures: ${ e . message . slice ( 0 , 200 ) }`
490 )
491 continue
492 }
493 const m = out. match ( /@@SIZE ( \d + ) @@DONE ( \S + )/ )
494 const body = out. slice ( 0 , out. lastIndexOf ( ' \n @@SIZE' ))
495 transcript += body
496 for ( const l of body. split ( ' \n ' )) {
497 if ( ! l) continue
498 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 transcript
509 if (m[ 2 ] === 'LOOPFAIL' )
510 throw new Error (
511 `${ tag }: remote loop failed; tail: \n ${ body . slice ( - 1500 ) }`
512 )
513 }
514 }
515 }
516
517 // ------------------------------------------------------------ snapshots
518
519 export async function sha256 ( text ) {
520 return crypto. createHash ( 'sha256' ). update (text). digest ( 'hex' ). slice ( 0 , 16 )
521 }
522
523 export async function snapshotIdFor ( cacheDir , key ) {
524 const file = path. join (cacheDir, `snap-${ key }` )
525 if ( ! fs. existsSync (file)) return undefined
526 const id = fs. readFileSync (file, 'utf8' ). trim ()
527 try {
528 await sb ([ 'snapshots' , 'get' , id])
529 return id
530 } catch {
531 return undefined
532 }
533 }
534
535 export 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 id
544 }
545
546 // React build environment (repo + node_modules + JDK), shared with
547 // sandbox-ab.mjs. Keyed on the arm's yarn.lock.
548 export 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 id
557 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 artifacts
613 // instead of building. Requires gh auth. Returns false -> build remotely.
614 export 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 ).stdout
627 )
628 const id = runs.workflow_runs?. find (( r ) => r.head_sha === sha)?.id
629 if ( ! id) return false
630 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 ).stdout
641 )
642 const art = arts.artifacts?. find (
643 ( a ) => a.name === 'artifacts_combined' && ! a.expired
644 )
645 if ( ! art) return false
646 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 << 20
664 } catch (e) {
665 console. error (
666 `CI artifact lookup failed (${ e . message . slice ( 0 , 100 ) }); building remotely`
667 )
668 return false
669 }
670 }
671
672 // Ref arms build remotely (dual-channel yarn build) and cache by sha.
673 export 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 = sha
678 // Key on the build recipe too: a changed target list must not serve
679 // stale artifacts from the shared cache.
680 const recipe = crypto
681 . 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 = cached
687 if (fs. existsSync (cached)) {
688 console. error ( `arm ${ arm . name }=${ sha . slice ( 0 , 12 ) } (cached build)` )
689 return
690 }
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 return
701 }
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 + commit
726 // date), so the upload must be a real shallow checkout, not a bare
727 // 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 it
762 // alive during the ~10min dual-channel build. Newline before the
763 // backgrounded heartbeat: a trailing & after && backgrounds the
764 // whole chain.
765 await sbExec (
766 vm,
767 '40m' ,
768 `set -e
769 ls /vercel/sandbox/react/node_modules >/dev/null
770 cd /vercel/sandbox/react
771 find . -mindepth 1 -maxdepth 1 ! -name node_modules -exec rm -rf {} +
772 tar -xzf ../src.tgz
773 git rev-parse HEAD
774 (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=$!
775 yarn build "${ E2E_BUILD_TARGETS }" >/tmp/build.log 2>&1 || (kill $HB; tail -20 /tmp/build.log; exit 1)
776 kill $HB
777 tar -czf /vercel/sandbox/arm.tgz build/oss-stable build/oss-experimental
778 echo 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 }