Setting the file. One moment.
Sandbox Gate · Sandbox Bench · vercel/next.js · Skills Docs
ContentsBack to the top of the page 24.4
Bench Common
12 KB scripts/ sandbox-gate.mjs
JavaScript · 400 lines · 12 KB
14 import { promisify } from 'node:util'
15 import fs from 'node:fs'
16 import os from 'node:os'
17 import path from 'node:path'
18 import crypto from 'node:crypto'
19 import { loadConfig, sandboxScope, ensureReactRepo } from './config.mjs'
20
21 const execFileP = promisify (execFile)
22 const DRY_RUN = process.argv. includes ( '--dry-run' )
23 const CONFIG = loadConfig ({ requireScope: ! DRY_RUN })
24 const REACT = ensureReactRepo ( CONFIG )
25 const VERCEL = CONFIG .vercelBin
26 const SCOPE = CONFIG .team ? sandboxScope ( CONFIG ) : []
27 const SETUP_VERSION = 'v1-al2023-node24-jdk21'
28 const REACT_SNAP_CACHE = path. join ( CONFIG .cacheDir, 'react-snap' )
29
30 function 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 globally
50 } 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; reject
57 // anything that could smuggle extra jest flags (--passWithNoTests
58 // 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 args
64 }
65
66 async 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; callers
75 // that parse exact values (size checks) must keep stdout-only.
76 return opts.withStderr ? `${ stdout } \n ${ stderr }` : stdout
77 }
78
79 async function git ( args ) {
80 const { stdout } = await execFileP ( 'git' , [ '-C' , REACT , ... args], {
81 maxBuffer: 256 * 1024 * 1024 ,
82 })
83 return stdout. trim ()
84 }
85
86 async 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.
96 async 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 \n set -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 = 0
116 let failures = 0
117 const deadline = Date. now () + deadlineMin * 60_000
118 while ( true ) {
119 await new Promise (( r ) => setTimeout (r, 45_000 ))
120 if (Date. now () > deadline) throw new Error ( `${ tag }: deadline exceeded` )
121 let out
122 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 = 0
134 } catch (e) {
135 if ( ++ failures >= 6 )
136 throw new Error (
137 `${ tag }: ${ failures } consecutive poll failures: ${ e . message . slice ( 0 , 200 ) }`
138 )
139 continue
140 }
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' ) return
149 if (m[ 2 ] === 'LOOPFAIL' )
150 throw new Error (
151 `${ tag }: remote gate failed; tail: \n ${ body . slice ( - 3000 ) }`
152 )
153 }
154 }
155 }
156
157 async 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 e
182 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).size
208 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 would
229 // 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 extra
238 . 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 90
245 )
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 closure
253 // compiler, keyed on yarn.lock. Created once per lockfile, reused by
254 // every later gate.
255 const snapshotPromises = new Map ()
256
257 async function ensureReactBuildSnapshot ( refSha ) {
258 // Raw bytes, exactly as the e2e harness hashes it: a trim here would
259 // 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 ).stdout
265 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
272 async function ensureReactBuildSnapshotForLock ( refSha , lock ) {
273 const key = crypto
274 . 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 id
284 } 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 30
336 )
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 snapId
346 } finally {
347 await rmVm (vm)
348 }
349 }
350
351 async 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 with
366 // 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 = 0
377 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
397 main (). catch (( e ) => {
398 console. error (e)
399 process. exit ( 1 )
400 })