281 lines
6.8 KiB
JavaScript
281 lines
6.8 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
|
|
/**
|
|
* Synthetic load generator for the Whiteboard websocket server.
|
|
* Creates multiple socket.io clients, joins a shared room, and simulates
|
|
* cursor/viewport activity to approximate real collaboration traffic.
|
|
*/
|
|
|
|
import { io } from 'socket.io-client'
|
|
import jwt from 'jsonwebtoken'
|
|
|
|
const concurrency = parseInt(process.argv[2] || '1', 10)
|
|
const durationSeconds = parseInt(process.argv[3] || '45', 10)
|
|
const updateRate = parseFloat(process.argv[4] || '3') // cursor updates per second for active senders
|
|
const activeRatio = parseFloat(process.argv[5] || '0.3') // fraction of users broadcasting activity
|
|
|
|
if (!Number.isFinite(concurrency) || concurrency <= 0) {
|
|
throw new Error('Invalid concurrency value')
|
|
}
|
|
|
|
const durationMs = durationSeconds * 1000
|
|
const serverUrl = process.env.LOAD_TEST_SERVER_URL || 'http://127.0.0.1:3002'
|
|
const sharedSecret = process.env.LOAD_TEST_JWT_SECRET || process.env.JWT_SECRET_KEY || 'benchmark-secret'
|
|
const roomId = process.env.LOAD_TEST_ROOM_ID || 'benchmark-room'
|
|
const activeSenders = Math.max(1, Math.round(concurrency * activeRatio))
|
|
|
|
const results = []
|
|
|
|
const nowSeconds = () => Math.floor(Date.now() / 1000)
|
|
|
|
function buildTokenPayload(index) {
|
|
const issuedAt = nowSeconds()
|
|
return {
|
|
userid: `user-${index}`,
|
|
fileId: 4242,
|
|
isFileReadOnly: false,
|
|
user: {
|
|
id: `user-${index}`,
|
|
name: `Load Tester ${index}`,
|
|
},
|
|
iat: issuedAt,
|
|
exp: issuedAt + 6 * 60 * 60,
|
|
}
|
|
}
|
|
|
|
function bytesForPayload(payload) {
|
|
return Buffer.byteLength(JSON.stringify(payload))
|
|
}
|
|
|
|
function scheduleActiveTraffic(socket, metrics, index) {
|
|
if (updateRate <= 0) {
|
|
return { clear: () => {} }
|
|
}
|
|
|
|
const baseDelay = 500 + Math.random() * 500
|
|
const moveIntervalMs = Math.max(200, Math.floor(1000 / updateRate))
|
|
|
|
const encoder = (payload) => Buffer.from(JSON.stringify(payload))
|
|
|
|
let cursorHandler = null
|
|
let viewportHandler = null
|
|
const startTimer = setTimeout(() => {
|
|
cursorHandler = setInterval(() => {
|
|
const payload = {
|
|
type: 'MOUSE_LOCATION',
|
|
payload: {
|
|
pointer: {
|
|
x: Math.random() * 2500,
|
|
y: Math.random() * 1400,
|
|
pointerId: `pointer-${index}`,
|
|
},
|
|
buttons: Math.random() > 0.8 ? 1 : 0,
|
|
user: {
|
|
id: `user-${index}`,
|
|
name: `Load Tester ${index}`,
|
|
},
|
|
},
|
|
}
|
|
const buffer = encoder(payload)
|
|
socket.emit('server-volatile-broadcast', roomId, buffer)
|
|
metrics.bytesSent += buffer.byteLength
|
|
metrics.messagesSent += 1
|
|
}, moveIntervalMs)
|
|
|
|
viewportHandler = setInterval(() => {
|
|
const payload = {
|
|
type: 'VIEWPORT_UPDATE',
|
|
payload: {
|
|
offsetX: Math.random() * 1500,
|
|
offsetY: Math.random() * 900,
|
|
zoom: 0.5 + Math.random() * 0.5,
|
|
scale: 1,
|
|
userId: `user-${index}`,
|
|
},
|
|
}
|
|
const buffer = encoder(payload)
|
|
socket.emit('server-volatile-broadcast', roomId, buffer)
|
|
metrics.bytesSent += buffer.byteLength
|
|
metrics.messagesSent += 1
|
|
}, 1000)
|
|
}, baseDelay)
|
|
|
|
return {
|
|
clear: () => {
|
|
clearTimeout(startTimer)
|
|
if (cursorHandler) {
|
|
clearInterval(cursorHandler)
|
|
}
|
|
if (viewportHandler) {
|
|
clearInterval(viewportHandler)
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
function createClient(index) {
|
|
return new Promise((resolve) => {
|
|
const isActiveSender = index < activeSenders
|
|
const metrics = {
|
|
index,
|
|
isActiveSender,
|
|
bytesSent: 0,
|
|
bytesReceived: 0,
|
|
messagesSent: 0,
|
|
messagesReceived: 0,
|
|
joinDelayMs: null,
|
|
completed: false,
|
|
dropped: false,
|
|
}
|
|
|
|
const token = jwt.sign(buildTokenPayload(index), sharedSecret)
|
|
const socket = io(serverUrl, {
|
|
forceNew: true,
|
|
reconnection: false,
|
|
transports: ['websocket'],
|
|
auth: { token },
|
|
timeout: 10000,
|
|
extraHeaders: {
|
|
Origin: process.env.LOAD_TEST_ORIGIN || 'http://localhost',
|
|
},
|
|
})
|
|
|
|
let stopped = false
|
|
let startTime = Date.now()
|
|
let trafficHandle = null
|
|
|
|
const stop = (reason) => {
|
|
if (stopped) {
|
|
return
|
|
}
|
|
stopped = true
|
|
metrics.stopReason = reason
|
|
metrics.durationMs = Date.now() - startTime
|
|
if (trafficHandle) {
|
|
trafficHandle.clear()
|
|
}
|
|
if (socket.connected) {
|
|
socket.disconnect()
|
|
}
|
|
resolve(metrics)
|
|
}
|
|
|
|
socket.on('connect_error', (error) => {
|
|
metrics.error = error.message
|
|
metrics.dropped = true
|
|
console.error(`[client ${index}] connect_error`, error)
|
|
stop('connect_error')
|
|
})
|
|
|
|
socket.on('disconnect', () => {
|
|
if (!metrics.completed) {
|
|
metrics.dropped = true
|
|
stop('disconnect')
|
|
}
|
|
})
|
|
|
|
socket.on('init-room', () => {
|
|
socket.emit('join-room', roomId)
|
|
})
|
|
|
|
socket.on('sync-designate', () => {
|
|
if (metrics.joinDelayMs !== null) {
|
|
return
|
|
}
|
|
|
|
metrics.joinDelayMs = Date.now() - startTime
|
|
|
|
if (isActiveSender) {
|
|
trafficHandle = scheduleActiveTraffic(socket, metrics, index)
|
|
}
|
|
})
|
|
|
|
const recordPayload = (payload) => {
|
|
const size = bytesForPayload(payload)
|
|
metrics.bytesReceived += size
|
|
metrics.messagesReceived += 1
|
|
}
|
|
|
|
socket.on('room-user-change', recordPayload)
|
|
socket.on('user-joined', recordPayload)
|
|
socket.on('user-left', recordPayload)
|
|
|
|
socket.on('client-broadcast', (data) => {
|
|
let size = 0
|
|
if (data instanceof ArrayBuffer) {
|
|
size = data.byteLength
|
|
} else if (ArrayBuffer.isView(data)) {
|
|
size = data.byteLength
|
|
} else if (typeof data === 'string') {
|
|
size = Buffer.byteLength(data)
|
|
} else if (data) {
|
|
size = bytesForPayload(data)
|
|
}
|
|
metrics.bytesReceived += size
|
|
metrics.messagesReceived += 1
|
|
})
|
|
|
|
setTimeout(() => {
|
|
metrics.completed = true
|
|
stop('completed')
|
|
}, durationMs + 1000)
|
|
})
|
|
}
|
|
|
|
const clientPromises = []
|
|
for (let i = 0; i < concurrency; i += 1) {
|
|
clientPromises.push(createClient(i))
|
|
}
|
|
|
|
const clientResults = await Promise.all(clientPromises)
|
|
|
|
clientResults.forEach((metrics) => results.push(metrics))
|
|
|
|
const totals = clientResults.reduce((acc, metrics) => {
|
|
acc.bytesSent += metrics.bytesSent
|
|
acc.bytesReceived += metrics.bytesReceived
|
|
acc.messagesSent += metrics.messagesSent
|
|
acc.messagesReceived += metrics.messagesReceived
|
|
if (metrics.joinDelayMs !== null) {
|
|
acc.joinDelays.push(metrics.joinDelayMs)
|
|
}
|
|
if (metrics.dropped) {
|
|
acc.dropped += 1
|
|
}
|
|
return acc
|
|
}, {
|
|
bytesSent: 0,
|
|
bytesReceived: 0,
|
|
messagesSent: 0,
|
|
messagesReceived: 0,
|
|
joinDelays: [],
|
|
dropped: 0,
|
|
})
|
|
|
|
const averageJoinDelay = totals.joinDelays.length > 0
|
|
? totals.joinDelays.reduce((sum, value) => sum + value, 0) / totals.joinDelays.length
|
|
: null
|
|
|
|
const summary = {
|
|
serverUrl,
|
|
roomId,
|
|
concurrency,
|
|
activeSenders,
|
|
activeRatio,
|
|
durationSeconds,
|
|
updateRate,
|
|
bytesSent: totals.bytesSent,
|
|
bytesReceived: totals.bytesReceived,
|
|
messagesSent: totals.messagesSent,
|
|
messagesReceived: totals.messagesReceived,
|
|
averageJoinDelayMs: averageJoinDelay,
|
|
droppedConnections: totals.dropped,
|
|
}
|
|
|
|
console.log(JSON.stringify(summary, null, 2))
|