Skip to content

Commit 398e84e

Browse files
committed
hacks
1 parent 9c65875 commit 398e84e

4 files changed

Lines changed: 1094 additions & 17 deletions

File tree

scripts/benchmark-memory.js

Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Memory benchmark for a running Next.js dev server.
4+
*
5+
* Discovers all routes via the MCP get_routes endpoint, then compiles each
6+
* one via the MCP compile_route endpoint — no HTTP requests to the application
7+
* are made. Samples RSS/VSZ/physical-footprint throughout and writes a CSV.
8+
*
9+
* Usage:
10+
* node scripts/benchmark-memory.js <base-url> [output-file] [--pause[=N]]
11+
*
12+
* Options:
13+
* --pause[=N] Wait 4 seconds after every N compilations (default: 10)
14+
*
15+
* Examples:
16+
* node scripts/benchmark-memory.js http://localhost:3000
17+
* node scripts/benchmark-memory.js http://localhost:3000 /tmp/run-a.csv
18+
* node scripts/benchmark-memory.js http://localhost:3000 /tmp/run-a.csv --pause
19+
* node scripts/benchmark-memory.js http://localhost:3000 /tmp/run-a.csv --pause=25
20+
*
21+
* Output CSV columns:
22+
* timestamp, elapsed_ms, rss_kb, vsz_kb, footprint_kb, event
23+
*
24+
* 'event' is blank for periodic samples, or "compile:<page>" recorded
25+
* immediately after each route is compiled.
26+
*
27+
* Requires: Next.js dev server with experimental.mcpServer enabled.
28+
*/
29+
30+
const { spawnSync } = require('child_process')
31+
const fs = require('fs')
32+
const http = require('http')
33+
const https = require('https')
34+
const { URL } = require('url')
35+
36+
// ---------------------------------------------------------------------------
37+
// Config
38+
// ---------------------------------------------------------------------------
39+
40+
const MAX_ROUTES = 50
41+
const SETTLE_MS = 20_000
42+
const SAMPLE_INTERVAL_MS = 250
43+
const PAUSE_MS = 10_000
44+
45+
const positional = process.argv.slice(2).filter((a) => !a.startsWith('--'))
46+
const flagArgs = process.argv.slice(2).filter((a) => a.startsWith('--'))
47+
const [baseUrlArg, outFileArg] = positional
48+
49+
if (!baseUrlArg) {
50+
console.error(
51+
'Usage: node scripts/benchmark-memory.js <base-url> [output-file] [--pause[=N]]'
52+
)
53+
process.exit(1)
54+
}
55+
56+
const BASE_URL = baseUrlArg.replace(/\/$/, '')
57+
const OUTFILE =
58+
outFileArg ||
59+
`/tmp/next-memory-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.csv`
60+
61+
// --pause enables pausing, optionally with =N to set the pause interval
62+
// (pause every N compilations). Default interval is 10.
63+
const DEFAULT_PAUSE_EVERY = 10
64+
const pauseFlag = flagArgs.find(
65+
(a) => a === '--pause' || a.startsWith('--pause=')
66+
)
67+
const PAUSE_EVERY = pauseFlag
68+
? pauseFlag.includes('=')
69+
? parseInt(pauseFlag.slice('--pause='.length), 10)
70+
: DEFAULT_PAUSE_EVERY
71+
: 0
72+
if (pauseFlag && (!Number.isFinite(PAUSE_EVERY) || PAUSE_EVERY <= 0)) {
73+
console.error(`Invalid --pause value: ${pauseFlag}`)
74+
process.exit(1)
75+
}
76+
77+
// ---------------------------------------------------------------------------
78+
// Find next-server PID
79+
// ---------------------------------------------------------------------------
80+
81+
function findNextServerPid() {
82+
const result = spawnSync('pgrep', ['-f', 'next-server'], { encoding: 'utf8' })
83+
const pids = result.stdout.trim().split('\n').filter(Boolean)
84+
if (pids.length === 0) {
85+
console.error(
86+
'Error: no next-server process found. Start the dev server first.'
87+
)
88+
process.exit(1)
89+
}
90+
if (pids.length > 1) {
91+
console.error(
92+
`Error: multiple next-server processes found (pids: ${pids.join(', ')}).\nStop all but one and retry.`
93+
)
94+
process.exit(1)
95+
}
96+
return parseInt(pids[0], 10)
97+
}
98+
99+
// ---------------------------------------------------------------------------
100+
// RSS/VSZ sampling (macOS: ps returns KB)
101+
// ---------------------------------------------------------------------------
102+
103+
function sampleMemory(pid) {
104+
const result = spawnSync('ps', ['-o', 'rss=,vsz=', '-p', String(pid)], {
105+
encoding: 'utf8',
106+
})
107+
if (result.status !== 0 || !result.stdout.trim()) return null
108+
const [rss, vsz] = result.stdout.trim().split(/\s+/).map(Number)
109+
return { rss, vsz }
110+
}
111+
112+
const PHYSICAL_FOOTPRINT_ONESHOT = `
113+
import sys, struct, ctypes, ctypes.util
114+
libc = ctypes.CDLL(ctypes.util.find_library('c'))
115+
buf = ctypes.create_string_buffer(256)
116+
ret = libc.proc_pid_rusage(int(sys.argv[1]), 4, buf)
117+
if ret != 0:
118+
sys.exit(1)
119+
print(struct.unpack_from('<Q', buf, 72)[0] // 1024)
120+
`
121+
122+
function makeFootprintSampler(pid) {
123+
return {
124+
sample() {
125+
const result = spawnSync(
126+
'python3',
127+
['-c', PHYSICAL_FOOTPRINT_ONESHOT, String(pid)],
128+
{ encoding: 'utf8' }
129+
)
130+
if (result.status !== 0) return null
131+
return Number(result.stdout.trim())
132+
},
133+
close() {},
134+
}
135+
}
136+
137+
// ---------------------------------------------------------------------------
138+
// HTTP fetch helper (no external deps)
139+
// ---------------------------------------------------------------------------
140+
141+
function fetchUrl(url, timeoutMs = 10_000, options = {}) {
142+
return new Promise((resolve, reject) => {
143+
const parsed = new URL(url)
144+
const lib = parsed.protocol === 'https:' ? https : http
145+
const reqOptions = {
146+
method: options.method ?? 'GET',
147+
headers: {
148+
'User-Agent': 'next-memory-benchmark',
149+
...options.headers,
150+
},
151+
}
152+
const req = lib.request(url, reqOptions, (res) => {
153+
let body = ''
154+
res.on('data', (chunk) => (body += chunk))
155+
res.on('end', () => resolve({ status: res.statusCode, body }))
156+
})
157+
req.setTimeout(timeoutMs, () => {
158+
req.destroy()
159+
reject(new Error(`Timeout fetching ${url}`))
160+
})
161+
req.on('error', reject)
162+
if (options.body) req.write(options.body)
163+
req.end()
164+
})
165+
}
166+
167+
// ---------------------------------------------------------------------------
168+
// MCP helpers
169+
// ---------------------------------------------------------------------------
170+
171+
// Call a single MCP tool. Returns { isError, body } where body is the parsed
172+
// JSON content from the tool's response.
173+
async function callMcpTool(toolName, args, timeoutMs = 5_000) {
174+
const url = `${BASE_URL}/_next/mcp`
175+
const body = JSON.stringify({
176+
jsonrpc: '2.0',
177+
method: 'tools/call',
178+
params: { name: toolName, arguments: args },
179+
id: 1,
180+
})
181+
182+
const res = await fetchUrl(url, timeoutMs, {
183+
method: 'POST',
184+
headers: {
185+
'Content-Type': 'application/json',
186+
Accept: 'application/json, text/event-stream',
187+
},
188+
body,
189+
})
190+
191+
// Response is SSE: "event: message\ndata: {...}\n\n"
192+
const dataLine = res.body.split('\n').find((l) => l.startsWith('data:'))
193+
if (!dataLine)
194+
throw new Error(`MCP: no data line in response for ${toolName}`)
195+
196+
const envelope = JSON.parse(dataLine.slice('data:'.length).trim())
197+
if (envelope.error)
198+
throw new Error(`MCP error: ${JSON.stringify(envelope.error)}`)
199+
if (!envelope.result?.content?.[0]?.text)
200+
throw new Error(`MCP: unexpected response shape for ${toolName}`)
201+
202+
return {
203+
isError: !!envelope.result.isError,
204+
body: JSON.parse(envelope.result.content[0].text),
205+
}
206+
}
207+
208+
// ---------------------------------------------------------------------------
209+
// Route discovery and compilation via MCP
210+
// ---------------------------------------------------------------------------
211+
212+
async function discoverRoutes() {
213+
const { isError, body } = await callMcpTool('get_routes', {})
214+
if (isError) {
215+
throw new Error(`get_routes failed: ${JSON.stringify(body)}`)
216+
}
217+
const allRoutes = [
218+
...new Set([...(body.appRouter ?? []), ...(body.pagesRouter ?? [])]),
219+
]
220+
return {
221+
allRoutes,
222+
appCount: (body.appRouter ?? []).length,
223+
pagesCount: (body.pagesRouter ?? []).length,
224+
}
225+
}
226+
227+
// Compile all routes via MCP compile_route. Calls onCompiled(routeSpecifier)
228+
// after each successful compilation, where routeSpecifier is the resolved
229+
// route as returned by the server.
230+
async function compileRoutes(routes, onCompiled) {
231+
let compiledSincePause = 0
232+
for (const routeSpecifier of routes) {
233+
const { isError, body } = await callMcpTool(
234+
'compile_route',
235+
{ routeSpecifier },
236+
// Some compilation can be very slow
237+
120_000
238+
)
239+
if (!isError) {
240+
onCompiled(body.routeSpecifier ?? routeSpecifier)
241+
compiledSincePause++
242+
if (body.issues?.length) {
243+
console.warn(
244+
`\n ${routeSpecifier} compiled with ${body.issues.length} issue(s)`
245+
)
246+
}
247+
} else if (body.notFound) {
248+
console.warn(`\n skipped (not found): ${routeSpecifier}`)
249+
} else {
250+
console.warn(
251+
`\n compile_route error for ${routeSpecifier}: ${body.error}`
252+
)
253+
}
254+
if (PAUSE_EVERY && compiledSincePause >= PAUSE_EVERY) {
255+
compiledSincePause = 0
256+
await new Promise((r) => setTimeout(r, PAUSE_MS))
257+
}
258+
}
259+
}
260+
261+
// ---------------------------------------------------------------------------
262+
// Main
263+
// ---------------------------------------------------------------------------
264+
265+
async function main() {
266+
const pid = findNextServerPid()
267+
console.log(`Found next-server PID: ${pid}`)
268+
console.log(`Base URL: ${BASE_URL}`)
269+
console.log(`Output: ${OUTFILE}`)
270+
if (PAUSE_EVERY)
271+
console.log(
272+
`Pause mode: ${PAUSE_MS / 1000}s after every ${PAUSE_EVERY} compilations`
273+
)
274+
console.log('')
275+
276+
const footprintSampler = makeFootprintSampler(pid)
277+
const startTime = Date.now()
278+
const rows = [
279+
['timestamp', 'elapsed_ms', 'rss_kb', 'vsz_kb', 'footprint_kb', 'event'],
280+
]
281+
282+
let routesCompiled = 0
283+
284+
function now() {
285+
return Date.now() - startTime
286+
}
287+
288+
function recordSample(event = '') {
289+
const mem = sampleMemory(pid)
290+
if (!mem) return null
291+
const footprint = footprintSampler.sample() ?? ''
292+
rows.push([
293+
new Date().toISOString(),
294+
now(),
295+
mem.rss,
296+
mem.vsz,
297+
footprint,
298+
event,
299+
])
300+
return { ...mem, footprint }
301+
}
302+
303+
// Periodic sampler
304+
const sampler = setInterval(() => {
305+
const mem = recordSample()
306+
if (!mem) {
307+
console.error('\nProcess exited during benchmark.')
308+
clearInterval(sampler)
309+
return
310+
}
311+
const elapsed = (now() / 1000).toFixed(1)
312+
const rssMb = (mem.rss / 1024).toFixed(1)
313+
const footprintMb = mem.footprint ? (mem.footprint / 1024).toFixed(1) : '?'
314+
process.stdout.write(
315+
`\r[${elapsed}s] RSS=${rssMb}MB footprint=${footprintMb}MB compiled=${routesCompiled} `
316+
)
317+
}, SAMPLE_INTERVAL_MS)
318+
319+
// Discover routes via MCP get_routes
320+
let allRoutes, appCount, pagesCount
321+
try {
322+
;({ allRoutes, appCount, pagesCount } = await discoverRoutes())
323+
} catch (err) {
324+
clearInterval(sampler)
325+
console.error(`\nFailed to discover routes via MCP: ${err.message}`)
326+
console.error(
327+
'Make sure the dev server is running with experimental.mcpServer enabled.'
328+
)
329+
process.exit(1)
330+
}
331+
332+
console.log(
333+
`Discovered ${appCount} app router + ${pagesCount} pages router routes via MCP.`
334+
)
335+
336+
// Compile each route via MCP compile_route
337+
const routesToCompile = allRoutes.slice(0, MAX_ROUTES)
338+
try {
339+
await compileRoutes(routesToCompile, (page) => {
340+
routesCompiled++
341+
recordSample(`compile:${page}`)
342+
})
343+
} catch (err) {
344+
clearInterval(sampler)
345+
console.error(`\ncompile_route failed: ${err.message}`)
346+
process.exit(1)
347+
}
348+
349+
console.log(`\nCompiled ${routesCompiled} routes.`)
350+
console.log(`Settling for ${SETTLE_MS / 1000}s...`)
351+
await new Promise((r) => setTimeout(r, SETTLE_MS))
352+
353+
clearInterval(sampler)
354+
footprintSampler.close()
355+
356+
// Write CSV
357+
const csv = rows.map((r) => r.join(',')).join('\n') + '\n'
358+
fs.writeFileSync(OUTFILE, csv)
359+
360+
// Summary
361+
const dataRows = rows.slice(1).filter((r) => r[2])
362+
const rssValues = dataRows.map((r) => Number(r[2]))
363+
const vszValues = dataRows.map((r) => Number(r[3]))
364+
const footprintValues = dataRows.map((r) => Number(r[4])).filter(Boolean)
365+
366+
const minRss = Math.min(...rssValues)
367+
const maxRss = Math.max(...rssValues)
368+
const finalRss = rssValues[rssValues.length - 1]
369+
const minVsz = Math.min(...vszValues)
370+
const maxVsz = Math.max(...vszValues)
371+
const finalVsz = vszValues[vszValues.length - 1]
372+
373+
const mb = (kb) => (kb / 1024).toFixed(0) + 'MB'
374+
375+
console.log('\n=== Memory Summary ===')
376+
console.log(
377+
` RSS: min=${mb(minRss)} max=${mb(maxRss)} final=${mb(finalRss)}`
378+
)
379+
if (footprintValues.length > 0) {
380+
const minFp = Math.min(...footprintValues)
381+
const maxFp = Math.max(...footprintValues)
382+
const finalFp = footprintValues[footprintValues.length - 1]
383+
console.log(
384+
` Footprint: min=${mb(minFp)} max=${mb(maxFp)} final=${mb(finalFp)}`
385+
)
386+
}
387+
console.log(
388+
` VSZ: min=${mb(minVsz)} max=${mb(maxVsz)} final=${mb(finalVsz)}`
389+
)
390+
console.log(` Samples: ${dataRows.length}`)
391+
console.log(`\nFull data: ${OUTFILE}`)
392+
}
393+
394+
main().catch((err) => {
395+
console.error(err)
396+
process.exit(1)
397+
})

0 commit comments

Comments
 (0)