246 lines
6.1 KiB
JavaScript
246 lines
6.1 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
|
|
import { spawn, execSync } from 'node:child_process'
|
|
import { promisify } from 'node:util'
|
|
import { setTimeout as delay } from 'node:timers/promises'
|
|
import { once } from 'node:events'
|
|
|
|
const execFile = promisify((cmd, args, callback) => {
|
|
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
let stdout = ''
|
|
let stderr = ''
|
|
child.stdout.on('data', chunk => {
|
|
stdout += chunk.toString()
|
|
})
|
|
child.stderr.on('data', chunk => {
|
|
stderr += chunk.toString()
|
|
})
|
|
child.on('error', error => callback(error))
|
|
child.on('close', code => {
|
|
if (code === 0) {
|
|
callback(null, { stdout, stderr })
|
|
} else {
|
|
const error = new Error(`Command ${cmd} exited with code ${code}: ${stderr}`)
|
|
error.code = code
|
|
callback(error)
|
|
}
|
|
})
|
|
})
|
|
|
|
const concurrencyLevels = process.env.LOAD_TEST_CONCURRENCY
|
|
? process.env.LOAD_TEST_CONCURRENCY.split(',').map(value => parseInt(value.trim(), 10)).filter(Number.isFinite)
|
|
: [50, 100, 500]
|
|
|
|
const testDurationSeconds = parseInt(process.env.LOAD_TEST_DURATION || '60', 10)
|
|
const updateRate = parseFloat(process.env.LOAD_TEST_RATE || '3')
|
|
const activeRatio = parseFloat(process.env.LOAD_TEST_ACTIVE_RATIO || '0.3')
|
|
const sharedSecret = process.env.LOAD_TEST_JWT_SECRET || 'benchmark-secret'
|
|
|
|
if (!Number.isFinite(testDurationSeconds) || testDurationSeconds <= 0) {
|
|
throw new Error('Invalid LOAD_TEST_DURATION')
|
|
}
|
|
|
|
function parsePsOutput(output) {
|
|
const trimmed = output.trim()
|
|
if (!trimmed) {
|
|
return null
|
|
}
|
|
const parts = trimmed.split(/\s+/)
|
|
if (parts.length < 3) {
|
|
return null
|
|
}
|
|
return {
|
|
cpu: parseFloat(parts[0]),
|
|
memPercent: parseFloat(parts[1]),
|
|
rssKb: parseInt(parts[2], 10),
|
|
}
|
|
}
|
|
|
|
async function readProcessStats(pid) {
|
|
try {
|
|
const { stdout } = await execFile('ps', ['-p', String(pid), '-o', '%cpu=,%mem=,rss='])
|
|
return parsePsOutput(stdout)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function startSampling(pid, intervalMs = 1000) {
|
|
const samples = []
|
|
let running = true
|
|
|
|
const loop = async () => {
|
|
while (running) {
|
|
const stats = await readProcessStats(pid)
|
|
if (stats) {
|
|
samples.push({ ...stats, timestamp: Date.now() })
|
|
}
|
|
await delay(intervalMs)
|
|
}
|
|
}
|
|
|
|
const loopPromise = loop()
|
|
|
|
return {
|
|
samples,
|
|
stop: async () => {
|
|
running = false
|
|
await loopPromise
|
|
},
|
|
}
|
|
}
|
|
|
|
function parseNettop(pid) {
|
|
try {
|
|
const output = execSync(`nettop -P -x -J bytes_in,bytes_out -p ${pid} -l 1 -L 1`, { encoding: 'utf8' })
|
|
const lines = output.trim().split('\n')
|
|
const dataLine = lines.find(line => line.includes(`.${pid},`))
|
|
if (!dataLine) {
|
|
return { bytesIn: 0, bytesOut: 0 }
|
|
}
|
|
const parts = dataLine.split(',')
|
|
return {
|
|
bytesIn: parseInt(parts[2], 10) || 0,
|
|
bytesOut: parseInt(parts[3], 10) || 0,
|
|
}
|
|
} catch {
|
|
return { bytesIn: 0, bytesOut: 0 }
|
|
}
|
|
}
|
|
|
|
function waitForServerReady(child) {
|
|
return new Promise((resolve, reject) => {
|
|
let resolved = false
|
|
const handleOutput = (chunk) => {
|
|
const text = chunk.toString()
|
|
process.stdout.write(`[server] ${text}`)
|
|
if (!resolved && text.includes('Server started successfully')) {
|
|
resolved = true
|
|
resolve()
|
|
}
|
|
}
|
|
child.stdout.on('data', handleOutput)
|
|
child.stderr.on('data', chunk => {
|
|
process.stderr.write(`[server] ${chunk.toString()}`)
|
|
})
|
|
child.on('error', reject)
|
|
child.on('exit', code => {
|
|
if (!resolved) {
|
|
reject(new Error(`Server exited with code ${code}`))
|
|
}
|
|
})
|
|
setTimeout(() => {
|
|
if (!resolved) {
|
|
reject(new Error('Server startup timed out'))
|
|
}
|
|
}, 15000)
|
|
})
|
|
}
|
|
|
|
async function runLoadTest(concurrency) {
|
|
console.log(`\n=== Running ${concurrency} concurrent users ===`)
|
|
|
|
const serverEnv = {
|
|
...process.env,
|
|
JWT_SECRET_KEY: sharedSecret,
|
|
NEXTCLOUD_URL: 'http://localhost',
|
|
TLS: 'false',
|
|
NODE_OPTIONS: '--max-old-space-size=8192',
|
|
}
|
|
|
|
const server = spawn('node', ['websocket_server/main.js'], {
|
|
env: serverEnv,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
})
|
|
|
|
await waitForServerReady(server)
|
|
|
|
const baselineNetwork = parseNettop(server.pid)
|
|
const sampler = startSampling(server.pid)
|
|
|
|
const load = spawn('node', [
|
|
'tools/benchmarks/loadTest.mjs',
|
|
String(concurrency),
|
|
String(testDurationSeconds),
|
|
String(updateRate),
|
|
String(activeRatio),
|
|
], {
|
|
env: {
|
|
...process.env,
|
|
LOAD_TEST_JWT_SECRET: sharedSecret,
|
|
LOAD_TEST_SERVER_URL: 'http://127.0.0.1:3002',
|
|
LOAD_TEST_ROOM_ID: 'benchmark-room',
|
|
},
|
|
stdio: ['ignore', 'pipe', 'inherit'],
|
|
})
|
|
|
|
let loadOutput = ''
|
|
load.stdout.on('data', chunk => {
|
|
const text = chunk.toString()
|
|
loadOutput += text
|
|
process.stdout.write(text)
|
|
})
|
|
|
|
const [loadCode] = await once(load, 'exit')
|
|
|
|
await sampler.stop()
|
|
|
|
const finalNetwork = parseNettop(server.pid)
|
|
|
|
server.kill('SIGINT')
|
|
await once(server, 'exit')
|
|
|
|
if (loadCode !== 0) {
|
|
throw new Error(`Load test process exited with code ${loadCode}`)
|
|
}
|
|
|
|
let loadSummary = null
|
|
try {
|
|
loadSummary = JSON.parse(loadOutput)
|
|
} catch {
|
|
throw new Error('Failed to parse load test output')
|
|
}
|
|
|
|
const { samples } = sampler
|
|
const cpuValues = samples.map(sample => sample.cpu).filter(Number.isFinite)
|
|
const rssValues = samples.map(sample => sample.rssKb).filter(Number.isFinite)
|
|
|
|
const avgCpu = cpuValues.length ? cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length : 0
|
|
const peakCpu = cpuValues.length ? Math.max(...cpuValues) : 0
|
|
const avgRssMb = rssValues.length ? (rssValues.reduce((a, b) => a + b, 0) / rssValues.length) / 1024 : 0
|
|
const peakRssMb = rssValues.length ? Math.max(...rssValues) / 1024 : 0
|
|
|
|
const networkDelta = {
|
|
bytesIn: Math.max(0, finalNetwork.bytesIn - baselineNetwork.bytesIn),
|
|
bytesOut: Math.max(0, finalNetwork.bytesOut - baselineNetwork.bytesOut),
|
|
}
|
|
|
|
return {
|
|
concurrency,
|
|
cpu: {
|
|
average: avgCpu,
|
|
peak: peakCpu,
|
|
},
|
|
memory: {
|
|
averageRssMb: avgRssMb,
|
|
peakRssMb: peakRssMb,
|
|
},
|
|
network: networkDelta,
|
|
loadSummary,
|
|
}
|
|
}
|
|
|
|
const aggregatedResults = []
|
|
for (const level of concurrencyLevels) {
|
|
const result = await runLoadTest(level)
|
|
aggregatedResults.push(result)
|
|
}
|
|
|
|
console.log('\n=== Benchmark Summary ===')
|
|
console.log(JSON.stringify(aggregatedResults, null, 2))
|