Skip to content

Commit 825d52a

Browse files
committed
fix: run production start benchmark before dev benchmark
1 parent 9e1ba61 commit 825d52a

File tree

5 files changed

+256
-423
lines changed

5 files changed

+256
-423
lines changed

.github/actions/next-stats-action/src/run/collect-stats.js

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const path = require('path')
2+
const net = require('net')
23
const fs = require('fs/promises')
34
const getPort = require('get-port')
45
const fetch = require('node-fetch')
@@ -10,6 +11,108 @@ const { parse: urlParse } = require('url')
1011
const benchmarkUrl = require('./benchmark-url')
1112
const { statsAppDir, diffingDir, benchTitle } = require('../constants')
1213

14+
// Check if a port is accepting TCP connections
15+
function checkPort(port, timeout = 100) {
16+
return new Promise((resolve) => {
17+
const socket = new net.Socket()
18+
socket.setTimeout(timeout)
19+
socket.once('connect', () => {
20+
socket.destroy()
21+
resolve(true)
22+
})
23+
socket.once('timeout', () => {
24+
socket.destroy()
25+
resolve(false)
26+
})
27+
socket.once('error', () => {
28+
socket.destroy()
29+
resolve(false)
30+
})
31+
socket.connect(port, 'localhost')
32+
})
33+
}
34+
35+
// Wait for port to start accepting TCP connections
36+
async function waitForPort(port, timeoutMs = 60000) {
37+
const start = Date.now()
38+
while (Date.now() - start < timeoutMs) {
39+
if (await checkPort(port)) {
40+
return Date.now() - start
41+
}
42+
await new Promise((r) => setTimeout(r, 50))
43+
}
44+
return null
45+
}
46+
47+
// Wait for HTTP server to respond
48+
async function waitForHttp(port, timeoutMs = 60000) {
49+
const start = Date.now()
50+
while (Date.now() - start < timeoutMs) {
51+
try {
52+
const res = await fetch(`http://localhost:${port}/`, { timeout: 2000 })
53+
if (res.ok) {
54+
return Date.now() - start
55+
}
56+
} catch (e) {
57+
// Server not ready yet
58+
}
59+
await new Promise((r) => setTimeout(r, 50))
60+
}
61+
return null
62+
}
63+
64+
// Run a single dev server boot benchmark
65+
async function benchmarkDevBoot(appDevCommand, curDir, port, cleanBuild) {
66+
// Clean .next directory for cold start
67+
if (cleanBuild) {
68+
const nextDir = path.join(curDir, '.next')
69+
await fs.rm(nextDir, { recursive: true, force: true })
70+
}
71+
72+
const startTime = Date.now()
73+
const devChild = spawn(appDevCommand, {
74+
cwd: curDir,
75+
env: {
76+
PORT: port,
77+
},
78+
stdio: 'pipe',
79+
})
80+
81+
let exited = false
82+
devChild.on('exit', () => {
83+
exited = true
84+
})
85+
86+
// Capture output for debugging
87+
devChild.stdout.on('data', (data) => process.stdout.write(data))
88+
devChild.stderr.on('data', (data) => process.stderr.write(data))
89+
90+
// Measure time to port listening (TCP level)
91+
const listenTime = await waitForPort(port, 60000)
92+
93+
// Measure time to HTTP ready
94+
let readyTime = null
95+
if (listenTime !== null && !exited) {
96+
readyTime = await waitForHttp(port, 60000)
97+
}
98+
99+
devChild.kill()
100+
101+
// Wait for process to fully exit to avoid port conflicts on subsequent runs
102+
if (!exited) {
103+
await new Promise((resolve) => {
104+
devChild.on('exit', resolve)
105+
// Timeout after 5 seconds in case process doesn't exit cleanly
106+
setTimeout(resolve, 5000)
107+
})
108+
}
109+
110+
return {
111+
listenTime,
112+
readyTime,
113+
}
114+
}
115+
13116
async function defaultGetRequiredFiles(nextAppDir, fileName) {
14117
return [fileName]
15118
}
@@ -33,6 +136,7 @@ module.exports = async function collectStats(
33136
const hasPagesToBench =
34137
Array.isArray(runConfig.pagesToBench) && runConfig.pagesToBench.length > 0
35138

139+
// Run production start benchmark FIRST (before dev benchmark which cleans .next)
36140
if (
37141
!fromDiff &&
38142
statsConfig.appStartCommand &&
@@ -139,6 +243,81 @@ module.exports = async function collectStats(
139243
child.kill()
140244
}
141245

246+
// Measure dev server boot time if configured (full matrix: cold/warm x listen/ready)
247+
// NOTE: This runs AFTER the production start benchmark because it cleans the .next directory
248+
if (!fromDiff && statsConfig.appDevCommand && statsConfig.measureDevBoot) {
249+
const devPort = await getPort()
250+
251+
if (!orderedStats['General']) {
252+
orderedStats['General'] = {}
253+
}
254+
255+
// 1. Cold start benchmark (clean .next directory)
256+
logger('=== Cold Start Benchmark ===')
257+
const coldResult = await benchmarkDevBoot(
258+
statsConfig.appDevCommand,
259+
curDir,
260+
devPort,
261+
true // clean build
262+
)
263+
264+
if (coldResult.listenTime !== null) {
265+
orderedStats['General']['nextDevColdListenDuration (ms)'] =
266+
coldResult.listenTime
267+
}
268+
if (coldResult.readyTime !== null) {
269+
orderedStats['General']['nextDevColdReadyDuration (ms)'] =
270+
coldResult.readyTime
271+
}
272+
273+
// 2. Warm up bytecode cache by running server for ~10 seconds
274+
if (coldResult.readyTime !== null) {
275+
logger('=== Warming up bytecode cache (10s) ===')
276+
const warmupChild = spawn(statsConfig.appDevCommand, {
277+
cwd: curDir,
278+
env: {
279+
PORT: devPort,
280+
},
281+
stdio: 'pipe',
282+
})
283+
284+
// Wait for server to be ready
285+
await waitForHttp(devPort, 60000)
286+
287+
// Let it run for 10 seconds to warm bytecode cache
288+
await new Promise((r) => setTimeout(r, 10000))
289+
290+
warmupChild.kill()
291+
292+
// Wait for warmup server to fully exit to avoid port conflicts
293+
await new Promise((resolve) => {
294+
warmupChild.on('exit', resolve)
295+
// Timeout after 5 seconds in case process doesn't exit cleanly
296+
setTimeout(resolve, 5000)
297+
})
298+
299+
// 3. Warm start benchmark (keep .next directory)
300+
logger('=== Warm Start Benchmark ===')
301+
const warmResult = await benchmarkDevBoot(
302+
statsConfig.appDevCommand,
303+
curDir,
304+
devPort,
305+
false // keep build
306+
)
307+
308+
if (warmResult.listenTime !== null) {
309+
orderedStats['General']['nextDevWarmListenDuration (ms)'] =
310+
warmResult.listenTime
311+
}
312+
if (warmResult.readyTime !== null) {
313+
orderedStats['General']['nextDevWarmReadyDuration (ms)'] =
314+
warmResult.readyTime
315+
}
316+
}
317+
318+
logger('=== Dev Boot Benchmark Complete ===')
319+
}
320+
142321
for (const fileGroup of runConfig.filesToTrack) {
143322
const {
144323
getRequiredFiles = defaultGetRequiredFiles,

0 commit comments

Comments
 (0)