Files

440 lines
19 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* Haven load tester.
*
* Starts a throwaway Haven server with its own empty data folder, fills it
* with fake accounts, connects them all at once and measures what the server
* goes through: people arriving, a steady stream of chat, people dropping off
* and coming back, status changes, and an admin adding channels. It never touches a real server's data;
* the temporary folder is deleted at the end unless --keep is given.
*
* node scripts/loadtest.js --users 500
*
* Options (all optional):
* --users N fake people to connect (default 300)
* --port N port for the throwaway server (default 3990)
* --chat-seconds N how long the chat phase runs (default 15)
* --rate N chat messages per second across everyone (default 5)
* --churn N people who drop off and reconnect (default 10% of users)
* --status N people who change their status (default 10% of users)
* --channels N channels the admin creates (default 3)
* --server-channels N other channels everyone belongs to, like a real
* server's list (default 20)
* --offline N extra members who stay offline with notifications
* turned on, so every chat message sends them a push
* (default 0). Pushes go to a stand-in push service
* run by this script, never out to the internet.
* --json FILE also write the results as JSON to FILE
* --legacy act like an older app that needs every member list
* in full, instead of only the changes
* --keep keep the temporary data folder
*
* What the numbers mean:
* "server delay" is how long the server took to answer a tiny health check
* while the phase ran. It is the lag every user feels: if it reads 800 ms,
* every click and message waited that long behind the server's other work.
* "sent to clients" is everything the server pushed to all the fake people
* during the phase, with the event types that made up most of it.
* "message delay" is how long a chat message took to reach everyone.
* "pushes" is how many notifications reached the stand-in push service,
* how many of the away members got at least one, and how long after the
* last message the last one arrived. A member who is sent several from
* the same channel close together may get only the newest, as their phone
* would only show the newest anyway.
*
* The fake people run in this one process on the same machine, so absolute
* numbers depend on the computer. Compare runs on the same machine.
*/
const { spawn } = require('node:child_process');
const crypto = require('node:crypto');
const fs = require('node:fs');
const https = require('node:https');
const os = require('node:os');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const { io } = require(path.join(ROOT, 'node_modules', 'socket.io-client'));
const Database = require(path.join(ROOT, 'node_modules', 'better-sqlite3'));
const jwt = require(path.join(ROOT, 'node_modules', 'jsonwebtoken'));
const bcrypt = require(path.join(ROOT, 'node_modules', 'bcryptjs'));
const selfsigned = require(path.join(ROOT, 'src', 'selfsignedCert'));
// ── Options ──────────────────────────────────────────────
function readArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith('--')) continue;
const key = a.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith('--')) out[key] = true;
else { out[key] = next; i++; }
}
return out;
}
const args = readArgs(process.argv.slice(2));
const USERS = Math.max(2, parseInt(args.users, 10) || 300);
const PORT = parseInt(args.port, 10) || 3990;
const CHAT_SECONDS = Math.max(1, parseFloat(args['chat-seconds']) || 15);
const RATE = Math.max(0.1, parseFloat(args.rate) || 5);
const CHURN = Math.min(USERS, parseInt(args.churn, 10) || Math.max(1, Math.round(USERS / 10)));
const STATUS = Math.min(USERS, parseInt(args.status, 10) || Math.max(1, Math.round(USERS / 10)));
const CHANNELS = Math.max(0, parseInt(args.channels, 10) || 3);
const SERVER_CHANNELS = args['server-channels'] !== undefined ? Math.max(0, parseInt(args['server-channels'], 10) || 0) : 20;
const OFFLINE = Math.max(0, parseInt(args.offline, 10) || 0);
const PUSH_PORT = PORT + 1;
const BASE = `http://127.0.0.1:${PORT}`;
const sleep = ms => new Promise(r => setTimeout(r, ms));
// ── Throwaway server ─────────────────────────────────────
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'haven-loadtest-'));
const jwtSecret = crypto.randomBytes(32).toString('hex');
let server = null;
// A stand-in push service on this machine. Its certificate is made here and
// trusted only by the throwaway server (NODE_EXTRA_CA_CERTS), so the server
// sends real, encrypted pushes and nothing leaves the machine.
let pushCaFile = null;
let pushSink = null;
function startPushSink() {
const { cert, key } = selfsigned.generate({ cn: 'localhost', names: ['localhost', '127.0.0.1'], days: 2 });
pushCaFile = path.join(dataDir, 'push-sink-ca.pem');
fs.writeFileSync(pushCaFile, cert);
pushSink = https.createServer({ cert, key }, (req, res) => {
req.resume();
req.on('end', () => {
if (phase && !phase.ended) {
phase.pushes++;
phase.pushTargets.add(req.url);
phase.lastPushAt = Date.now();
}
res.writeHead(201); res.end();
});
});
return new Promise(r => pushSink.listen(PUSH_PORT, '127.0.0.1', r));
}
function startServer() {
const log = fs.openSync(path.join(dataDir, 'server.log'), 'a');
server = spawn(process.execPath, ['server.js'], {
cwd: ROOT,
env: {
...process.env,
FORCE_HTTP: 'true',
PORT: String(PORT),
HAVEN_DATA_DIR: dataDir,
JWT_SECRET: jwtSecret,
...(pushCaFile ? { NODE_EXTRA_CA_CERTS: pushCaFile } : {}),
},
stdio: ['ignore', log, log],
windowsHide: true,
});
server.on('exit', code => { if (!stopping) console.error(`\nThe test server stopped unexpectedly (exit ${code}). Its log: ${path.join(dataDir, 'server.log')}`); });
}
let stopping = false;
async function stopServer() {
stopping = true;
if (server && server.exitCode === null) {
server.kill();
await new Promise(r => { server.once('exit', r); setTimeout(r, 5000); });
}
}
async function waitForServer() {
const until = Date.now() + 90000;
while (Date.now() < until) {
try { if ((await fetch(BASE + '/api/health')).ok) return; } catch { /* not up yet */ }
await sleep(300);
}
throw new Error('The test server did not start within 90 seconds');
}
// ── Fake accounts ────────────────────────────────────────
function seed() {
const db = new Database(path.join(dataDir, 'haven.db'));
db.pragma('busy_timeout = 10000');
const hash = bcrypt.hashSync('loadtest-not-a-real-password', 4);
const code = crypto.randomBytes(4).toString('hex');
// The first fake person is the server admin, who creates channels later.
const insUser = db.prepare('INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)');
const insMember = db.prepare('INSERT INTO channel_members (channel_id, user_id) VALUES (?, ?)');
const people = [];
db.transaction(() => {
const insChannel = db.prepare('INSERT INTO channels (name, code) VALUES (?, ?)');
const channelId = insChannel.run('lobby', code).lastInsertRowid;
const others = [];
for (let c = 0; c < SERVER_CHANNELS; c++) {
others.push(insChannel.run(`channel-${c}`, crypto.randomBytes(4).toString('hex')).lastInsertRowid);
}
for (let i = 0; i < USERS; i++) {
const username = `loadtest_${i}`;
const id = Number(insUser.run(username, hash, i === 0 ? 1 : 0).lastInsertRowid);
insMember.run(channelId, id);
for (const other of others) insMember.run(other, id);
people.push({ id, username, isAdmin: i === 0 });
}
// Offline members with notifications on: one browser key pair is enough,
// each gets its own address at the stand-in push service.
if (OFFLINE) {
const ecdh = crypto.createECDH('prime256v1');
ecdh.generateKeys();
const p256dh = ecdh.getPublicKey().toString('base64url');
const auth = crypto.randomBytes(16).toString('base64url');
const insSub = db.prepare('INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth) VALUES (?, ?, ?, ?)');
for (let i = 0; i < OFFLINE; i++) {
const id = Number(insUser.run(`loadtest_offline_${i}`, hash, 0).lastInsertRowid);
insMember.run(channelId, id);
insSub.run(id, `https://127.0.0.1:${PUSH_PORT}/push/${i}`, p256dh, auth);
}
}
})();
db.close();
for (const p of people) {
p.token = jwt.sign({ id: p.id, username: p.username, isAdmin: p.isAdmin, displayName: p.username, pwv: 1 }, jwtSecret, { expiresIn: '2h' });
}
return { code, people };
}
// ── Measuring ────────────────────────────────────────────
let phase = null;
const phases = [];
function newPhase(name) {
phase = { name, started: Date.now(), ended: null, bytes: 0, packets: 0, byEvent: new Map(), health: [], msgDelays: [], sent: 0, pushes: 0, pushTargets: new Set(), lastPushAt: 0, lastSentAt: 0, errors: new Map() };
phases.push(phase);
return phase;
}
function endPhase() { phase.ended = Date.now(); }
// The server answers /api/health on the same thread that does all its other
// work, so how long that takes is how far behind the server is running.
let healthOn = true;
async function healthLoop() {
while (healthOn) {
const t0 = performance.now();
try { await fetch(BASE + '/api/health'); } catch { /* counted as slow below */ }
const ms = performance.now() - t0;
if (phase && !phase.ended) phase.health.push(ms);
await sleep(200);
}
}
function countPacket(data) {
if (!phase || phase.ended || typeof data !== 'string') return;
phase.bytes += data.length;
phase.packets++;
const m = /^\d*\["([^"]+)"/.exec(data.slice(0, 80));
const name = m ? m[1] : '(other)';
phase.byEvent.set(name, (phase.byEvent.get(name) || 0) + data.length);
}
// ── Fake people ──────────────────────────────────────────
function fakeIp(i) {
// Each person gets their own address so the per-address connection limit
// treats them as separate people, the way it would on a real server.
return `10.${(i >> 16) & 255}.${(i >> 8) & 255}.${i & 255}`;
}
function connect(person, i, code) {
return new Promise((resolve) => {
const socket = io(BASE, {
transports: ['websocket'],
auth: args.legacy ? { token: person.token } : { token: person.token, presenceDeltas: 1 },
extraHeaders: { 'x-forwarded-for': fakeIp(i + 1) },
forceNew: true,
reconnection: false,
timeout: 60000,
});
person.socket = socket;
socket.io.on('open',() => socket.io.engine.on('packet', p => countPacket(p.data)));
socket.on('new-message', ({ message }) => {
const m = /^lt (\d+) (\d+)$/.exec(message?.content || '');
if (m && phase && !phase.ended) phase.msgDelays.push(Date.now() - Number(m[2]));
});
socket.on('error-msg', msg => {
if (!phase) return;
const key = String(msg).slice(0, 60);
phase.errors.set(key, (phase.errors.get(key) || 0) + 1);
});
socket.once('connect', () => {
socket.emit('visibility-change', { visible: true });
socket.emit('get-channels');
socket.emit('enter-channel', { code });
socket.emit('get-messages', { code });
resolve(true);
});
socket.once('connect_error', err => {
if (phase) phase.errors.set('connect: ' + err.message, (phase.errors.get('connect: ' + err.message) || 0) + 1);
resolve(false);
});
});
}
async function connectAll(people, code, indexes) {
// Arrive in small waves, like a burst of people opening the app, rather
// than one impossible instant.
let ok = 0;
for (let s = 0; s < indexes.length; s += 25) {
const wave = indexes.slice(s, s + 25).map(i => connect(people[i], i, code));
ok += (await Promise.all(wave)).filter(Boolean).length;
await sleep(50);
}
return ok;
}
// Wait until the server has gone quiet: no health check slower than 100 ms
// and no traffic for a second, or give up after `max` ms.
async function settle(max = 60000) {
const until = Date.now() + max;
let lastPackets = -1, quietSince = Date.now();
while (Date.now() < until) {
await sleep(250);
const recent = phase.health.slice(-3);
const busy = recent.some(ms => ms > 100);
if (phase.packets !== lastPackets || busy) { lastPackets = phase.packets; quietSince = Date.now(); }
else if (Date.now() - quietSince >= 1000) return true;
}
return false;
}
// ── Report ───────────────────────────────────────────────
function pct(list, p) {
if (!list.length) return null;
const s = [...list].sort((a, b) => a - b);
return s[Math.min(s.length - 1, Math.floor(p / 100 * s.length))];
}
const ms = v => v == null ? '-' : `${Math.round(v)} ms`;
function size(b) {
if (b >= 1e9) return (b / 1e9).toFixed(2) + ' GB';
if (b >= 1e6) return (b / 1e6).toFixed(1) + ' MB';
if (b >= 1e3) return (b / 1e3).toFixed(1) + ' KB';
return b + ' B';
}
function summarize(p) {
const top = [...p.byEvent.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3)
.map(([name, b]) => ({ event: name, bytes: b, share: p.bytes ? Math.round(b / p.bytes * 100) : 0 }));
return {
phase: p.name,
seconds: +(((p.ended || Date.now()) - p.started) / 1000).toFixed(1),
sentToClients: p.bytes,
topEvents: top,
serverDelay: { p50: pct(p.health, 50), p95: pct(p.health, 95), max: p.health.length ? p.health.reduce((m, v) => Math.max(m, v), 0) : null },
messages: p.sent ? { sent: p.sent, deliveries: p.msgDelays.length, expected: p.expected || null, p50: pct(p.msgDelays, 50), p95: pct(p.msgDelays, 95), max: p.msgDelays.length ? p.msgDelays.reduce((m, v) => Math.max(m, v), 0) : null } : null,
pushes: p.pushes || p.pushesExpected ? { delivered: p.pushes, members: p.pushTargets.size, away: OFFLINE, lastAfter: p.lastPushAt && p.lastSentAt ? p.lastPushAt - p.lastSentAt : null } : null,
errors: Object.fromEntries(p.errors),
note: p.note || null,
};
}
function printReport(rows) {
console.log(`\nHaven load test: ${USERS} people in one channel, ${SERVER_CHANNELS + 1} channels${OFFLINE ? `, ${OFFLINE} offline with notifications on` : ''}${args.legacy ? ' (as older apps)' : ''}\n`);
for (const r of rows) {
console.log(`▸ ${r.phase} (${r.seconds}s)`);
console.log(` server delay typical ${ms(r.serverDelay.p50)}, bad moments ${ms(r.serverDelay.p95)}, worst ${ms(r.serverDelay.max)}`);
console.log(` sent to clients ${size(r.sentToClients)}` + (r.topEvents.length ? `, mostly ${r.topEvents.map(e => `${e.event} ${e.share}%`).join(', ')}` : ''));
if (r.messages) {
const expected = r.messages.expected ? ` of ${r.messages.expected}` : '';
console.log(` messages ${r.messages.sent} sent, ${r.messages.deliveries}${expected} deliveries, typical ${ms(r.messages.p50)}, bad ${ms(r.messages.p95)}, worst ${ms(r.messages.max)}`);
}
if (r.pushes) console.log(` pushes ${r.pushes.delivered} sent, reaching ${r.pushes.members} of ${r.pushes.away} away members, the last ${ms(r.pushes.lastAfter)} after the last message`);
for (const [e, n] of Object.entries(r.errors)) console.log(` error x${n} ${e}`);
if (r.note) console.log(` note ${r.note}`);
}
console.log('');
}
// ── Run ──────────────────────────────────────────────────
async function main() {
console.log(`Starting a throwaway server on port ${PORT} (data in ${dataDir})...`);
if (OFFLINE) await startPushSink();
startServer();
await waitForServer();
const { code, people } = seed();
console.log(`Created ${USERS} fake accounts${OFFLINE ? ` and ${OFFLINE} offline members with notifications on` : ''}. Connecting them...`);
healthLoop();
// 1. Everyone arrives.
newPhase(`${USERS} people connect and open the channel`);
const all = people.map((_, i) => i);
const connected = await connectAll(people, code, all);
if (!(await settle(120000))) phase.note = 'still busy when the phase was cut off after 2 minutes';
if (connected < USERS) phase.note = `${USERS - connected} could not connect`;
endPhase();
// 2. Steady chat from random people.
newPhase(`chat: ${RATE} messages a second for ${CHAT_SECONDS}s`);
const online = people.filter(p => p.socket?.connected);
const total = Math.round(RATE * CHAT_SECONDS);
for (let n = 0; n < total; n++) {
const who = online[Math.floor(Math.random() * online.length)];
who.socket.emit('send-message', { code, content: `lt ${n} ${Date.now()}` });
phase.sent++;
phase.lastSentAt = Date.now();
await sleep(1000 / RATE);
}
phase.expected = phase.sent * online.length;
phase.pushesExpected = phase.sent * OFFLINE;
await settle(30000);
// Pushes keep arriving after the room goes quiet; wait until they stop.
if (OFFLINE) {
const until = Date.now() + 180000;
while (Date.now() < until && (!phase.lastPushAt || Date.now() - phase.lastPushAt < 2000)) await sleep(250);
}
endPhase();
// 3. Some people drop off and come back (a flaky network, a phone locking).
newPhase(`${CHURN} people drop off and reconnect`);
const churners = all.slice(0, CHURN);
for (const i of churners) people[i].socket.disconnect();
await sleep(500);
await connectAll(people, code, churners);
if (!(await settle(120000))) phase.note = 'still busy when the phase was cut off after 2 minutes';
endPhase();
// 4. Some people change their status.
newPhase(`${STATUS} people change their status`);
for (const i of all.slice(0, STATUS)) {
people[i].socket.emit('set-status', { status: 'away', statusText: '' });
await sleep(20);
}
if (!(await settle(120000))) phase.note = 'still busy when the phase was cut off after 2 minutes';
endPhase();
// 5. The admin adds channels (every add refreshes channel lists).
if (CHANNELS) {
newPhase(`the admin creates ${CHANNELS} channels`);
for (let n = 0; n < CHANNELS; n++) {
people[0].socket.emit('create-channel', { name: `loadtest-${n}` });
await sleep(1000);
}
if (!(await settle(120000))) phase.note = 'still busy when the phase was cut off after 2 minutes';
endPhase();
}
healthOn = false;
for (const p of people) p.socket?.disconnect();
const rows = phases.map(summarize);
printReport(rows);
if (args.json) {
fs.writeFileSync(args.json, JSON.stringify({ users: USERS, when: new Date().toISOString(), phases: rows }, null, 2));
console.log(`Results written to ${args.json}`);
}
}
main()
.catch(err => { console.error('\nLoad test failed:', err.message); process.exitCode = 1; })
.finally(async () => {
healthOn = false;
await stopServer();
pushSink?.close();
if (!args.keep) { try { fs.rmSync(dataDir, { recursive: true, force: true }); } catch { /* a file may still be locked */ } }
else console.log(`Kept the test data in ${dataDir}`);
setTimeout(() => process.exit(), 200).unref();
});