diff --git a/CLAUDE.md b/CLAUDE.md index 0a53daa..b2b81b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,7 +124,8 @@ npx tsc -p . # type-checks web/src node tests/page-smoke.js node tests/native-bridge.js # the page hands playback to a native shell node tests/contrast.js # every theme's palette against WCAG AA -npx playwright test # 40 browser tests against a real daemon on fixture feeds +npx playwright test # 66 browser tests against a real daemon on fixture feeds +node tests/load/run.js # k6: many people at once against a scratch daemon with 1,500 feeds ``` Things about the browser suite that have cost time: @@ -141,6 +142,25 @@ Things about the browser suite that have cost time: * `webServer` starts **before** `globalSetup`, which is why the fixture config is written at config-load time instead. +The load tests (`tests/load`, issue #133) are for what one browser cannot show: twenty-five people +browsing at once, a hundred players saving positions while a scan writes, fifty listeners +seeking through files, a flood of wrong passwords. Things about them: + +* They need **k6**, which `/src/install.sh` installs from k6's own signed apt repository, and they + build and run a **release** binary: Argon2 in a debug build takes about a second a sign-in, + which would measure the build. +* `run.js` serves the feeds itself, generated, and starts **its own daemon** under + `/tmp/ipx-load`, wiped each run, on ports 8793 and 8794, so it can run beside the browser suite. + It stops that daemon by its PID. People sign in by the `X-Load-User` header, trusted from + 127.0.0.1, as production trusts Cloudflare Access's; only the sign-in test uses a password. +* Each threshold is a budget well above what the request takes now: it is there to catch a query + that has started asking once per feed, or writers queueing on a lock, not a slow minute. +* `ipx status`, the Docker healthcheck, runs every second throughout, and a run fails if one takes + the healthcheck's 5s. +* One at a time: `node tests/load/run.js signin`. All four take about six minutes. +* The daemon is on **SQLite**, so its numbers carry SQLite's single connection (#136); production + is on Postgres, which the harness cannot run on yet (#139). + Non-trivial logic leaves one runnable check behind. Pure functions (`merge_policy`, `pick`, `matches_keywords`, `parse_interval`) are the easiest place to put it. diff --git a/docs/architecture.md b/docs/architecture.md index 080c2d1..60ffe0b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -146,6 +146,7 @@ cargo test # parsing, filters, retention, schedules, SQL, per-use node tests/page-smoke.js # the page script loads and every selector it wires at load exists node tests/native-bridge.js # the page hands playback to a native shell npx playwright test # a real browser against a real daemon on fixture feeds +node tests/load/run.js # k6: many people at once against a daemon with 1,500 generated feeds ``` The Rust tests cannot see a wrong selector, a handler that runs and does nothing, or a page that diff --git a/package.json b/package.json index c8d3ec2..b661d6b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "typecheck": "tsc -p .", "smoke": "node tests/page-smoke.js", "test": "playwright test", - "test:headed": "playwright test --headed" + "test:headed": "playwright test --headed", + "load": "node tests/load/run.js" }, "devDependencies": { "@playwright/test": "^1.56.0", diff --git a/tests/load/browse.js b/tests/load/browse.js new file mode 100644 index 0000000..d1305ad --- /dev/null +++ b/tests/load/browse.js @@ -0,0 +1,53 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { BASE, FEEDS, ITEMS, as, me, pick, feedId } from './lib.js'; + +// An evening's browsing: twenty-five people at once opening their feed list, All Subscriptions, +// a feed, a search, the Directory and a feed's page in it. Each answer's budget is well above what +// it takes now, so it fails on a query that has started asking once per feed -- the Directory +// asked the database three questions a feed until 0.10.0 -- not on a slow minute. Fifty measured +// the queue for SQLite's one connection (#136) more than any query. +export const options = { + scenarios: { + evening: { + executor: 'ramping-vus', + stages: [{ duration: '15s', target: 25 }, { duration: '45s', target: 25 }, { duration: '10s', target: 0 }], + }, + }, + thresholds: { + http_req_failed: ['rate==0'], + checks: ['rate==1'], + // About twice each one's p95 on 2026-10-05, SQLite on Tower: 236, 118, 133, 119, 259, 312ms. + 'http_req_duration{name:feeds}': ['p(95)<500'], + 'http_req_duration{name:all-entries}': ['p(95)<300'], + 'http_req_duration{name:feed-entries}': ['p(95)<300'], + 'http_req_duration{name:search}': ['p(95)<300'], + 'http_req_duration{name:directory}': ['p(95)<600'], + 'http_req_duration{name:listed}': ['p(95)<700'], + }, +}; + +export default function () { + const u = me(); + const feeds = http.get(`${BASE}/api/feeds`, as(u, 'feeds')); + check(feeds, { 'your thirty feeds': r => r.status === 200 && r.json().length === 30 }); + + const all = http.get(`${BASE}/api/entries?limit=50&filter=all&sort=published&dir=desc`, as(u, 'all-entries')); + check(all, { 'All Subscriptions, a page of it': r => r.status === 200 && r.json().entries.length === 50 }); + + const f = pick(feeds.json()); + const one = http.get(`${BASE}/api/feeds/${f.id}/entries?limit=50&sort=title&dir=asc`, as(u, 'feed-entries')); + check(one, { 'a feed of yours, every item': r => r.status === 200 && r.json().entries.length === ITEMS }); + + // Every generated item mentions the weather, in its text, not its title. + const found = http.get(`${BASE}/api/entries?q=weather&limit=50`, as(u, 'search')); + check(found, { 'a search of everything you read': r => r.status === 200 && r.json().total === 30 * ITEMS }); + + const dir = http.get(`${BASE}/api/directory`, as(u, 'directory')); + check(dir, { 'the whole Directory': r => r.status === 200 && r.json().length === FEEDS }); + + const listed = http.get(`${BASE}/api/directory/${feedId(Math.floor(Math.random() * FEEDS))}`, as(u, 'listed')); + check(listed, { "a feed's page in the Directory": r => r.status === 200 && r.json().items.length === ITEMS }); + + sleep(1 + Math.random() * 2); +} diff --git a/tests/load/lib.js b/tests/load/lib.js new file mode 100644 index 0000000..d4f3653 --- /dev/null +++ b/tests/load/lib.js @@ -0,0 +1,16 @@ +// What the load tests share. Each virtual user is a listener of its own, signed in by name with +// the header the scratch daemon trusts from 127.0.0.1, as production trusts Cloudflare Access's. +export const BASE = __ENV.BASE; +export const USERS = Number(__ENV.USERS || 100); +export const FEEDS = Number(__ENV.FEEDS || 1500); +// Each generated feed has this many items; see run.js. +export const ITEMS = 20; + +export const as = (name, tag) => ({ + headers: { 'X-Load-User': name, 'Content-Type': 'application/json' }, + tags: { name: tag }, +}); +/// The listener this virtual user is, one of the hundred run.js seeded, thirty feeds each. +export const me = () => `load-${(__VU - 1) % USERS}`; +export const pick = a => a[Math.floor(Math.random() * a.length)]; +export const feedId = n => `gen-${String(n).padStart(4, '0')}`; diff --git a/tests/load/listening.js b/tests/load/listening.js new file mode 100644 index 0000000..f19c075 --- /dev/null +++ b/tests/load/listening.js @@ -0,0 +1,58 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { BASE, as, me, pick } from './lib.js'; + +// Players saving where people are while scans write. A hundred listeners each save a position +// every second -- the player saves every ten, so this is a thousand people listening -- mark an +// item read now and then, and read their position back to see it is theirs and as they left it, +// while one of them forces a scan of all their feeds every five seconds. On SQLite every one of +// these is a write waiting its turn for the one writer. +export const options = { + scenarios: { + listeners: { executor: 'constant-vus', vus: 100, duration: '60s', exec: 'listen' }, + scans: { executor: 'constant-vus', vus: 1, duration: '60s', exec: 'scan' }, + }, + thresholds: { + http_req_failed: ['rate==0'], + checks: ['rate==1'], + // About twice each one's p95 on 2026-10-05, SQLite on Tower: 53, 786 and 383ms. Marking + // read answers with the feed's row, counts and all, and waits behind the scans' writes on + // SQLite's one connection (#136), hence its budget. + 'http_req_duration{name:position}': ['p(95)<300'], + 'http_req_duration{name:flags}': ['p(95)<1500'], + 'http_req_duration{name:read-back}': ['p(95)<800'], + }, +}; + +// Each virtual user's own episode, and how far into it it is. +let ep = null, secs = 0, n = 0; + +export function listen() { + const u = me(); + if (!ep) { + const r = http.get(`${BASE}/api/entries?limit=50`, as(u, 'pick')); + ep = pick(r.json().entries.filter(e => e.duration)); + } + secs += 1; + const saved = http.post(`${BASE}/api/entries/${encodeURIComponent(ep.feed_id)}/${encodeURIComponent(ep.guid)}/position`, + JSON.stringify({ secs, duration: ep.duration }), as(u, 'position')); + check(saved, { 'position saved': r => r.status === 204 }); + if (++n % 10 === 0) { + const flags = http.post(`${BASE}/api/entries/${encodeURIComponent(ep.feed_id)}/${encodeURIComponent(ep.guid)}/flags`, + JSON.stringify({ read: n % 20 === 0 }), as(u, 'flags')); + check(flags, { 'marked read or unread': r => r.status === 200 }); + const back = http.get(`${BASE}/api/feeds/${encodeURIComponent(ep.feed_id)}/entries?limit=50`, as(u, 'read-back')); + const mine = back.status === 200 && back.json().entries.find(e => e.guid === ep.guid); + check(mine, { + 'your position, as you left it': e => e && e.position === secs, + 'read as you marked it': e => e && e.read === (n % 20 === 0), + }); + } + sleep(1); +} + +export function scan() { + const r = http.post(`${BASE}/api/fetch`, JSON.stringify({ force: true }), as('load-0', 'fetch')); + check(r, { 'scan queued': r => r.status === 202 || r.status === 200 }); + sleep(5); +} diff --git a/tests/load/media.js b/tests/load/media.js new file mode 100644 index 0000000..e97718f --- /dev/null +++ b/tests/load/media.js @@ -0,0 +1,47 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { BASE, as, pick } from './lib.js'; + +// Listeners seeking: fifty at once asking for ranges of the same few episodes, as a player does +// on every seek and every few seconds of playback. Every range is checked against what the feed +// served, byte by byte at a sample of offsets, so a wrong offset fails, not only a wrong status. +export const options = { + scenarios: { seeking: { executor: 'constant-vus', vus: 50, duration: '45s' } }, + thresholds: { + http_req_failed: ['rate==0'], + checks: ['rate==1'], + // About four times its p95 on 2026-10-05, 48ms: a range is a file read, little to vary. + 'http_req_duration{name:range}': ['p(95)<200'], + }, +}; +// The generated file: ID3's ten bytes, then a byte its offset gives (run.js, mediaByte). +const SIZE = 2 * 1024 * 1024; +const byte = k => (k * 31 + 7) & 255; + +export function setup() { + const r = http.get(`${BASE}/api/entries?filter=downloaded&limit=50`, as('listener', 'setup')); + const files = r.json().entries.flatMap(e => e.enclosures).filter(x => x.path).map(x => x.id); + if (!files.length) throw new Error('the listener has no downloaded files to seek through'); + return { files }; +} + +export default function ({ files }) { + const start = 10 + Math.floor(Math.random() * (SIZE - 11)); + const end = Math.min(SIZE - 1, start + Math.floor(Math.random() * 65536)); + const r = http.get(`${BASE}/media/${pick(files)}`, { + headers: { 'X-Load-User': 'listener', Range: `bytes=${start}-${end}` }, + responseType: 'binary', + tags: { name: 'range' }, + }); + const body = r.status === 206 ? new Uint8Array(r.body) : new Uint8Array(0); + check(r, { + 'part of the file': r => r.status === 206, + 'the range asked for': r => r.headers['Content-Range'] === `bytes ${start}-${end}/${SIZE}`, + 'its bytes': () => { + if (body.length !== end - start + 1) return false; + for (const at of [0, body.length - 1, ...Array.from({ length: 30 }, () => Math.floor(Math.random() * body.length))]) + if (body[at] !== byte(start + at)) return false; + return true; + }, + }); +} diff --git a/tests/load/run.js b/tests/load/run.js new file mode 100644 index 0000000..b3c9b02 --- /dev/null +++ b/tests/load/run.js @@ -0,0 +1,245 @@ +// Load tests: k6 against a scratch daemon with a catalogue the size of production's, for what +// the browser tests cannot show -- many people at once, and what that does to latency, to the +// database and to the healthcheck. The browser suite drives one person against a handful of +// feeds, one request at a time. +// +// node tests/load/run.js [browse|listening|media|signin ...] default: all of them +// +// It builds a release binary (debug Argon2 alone takes a second a sign-in, which would measure +// the build, not ipx), serves generated feeds from this process, starts a daemon on its own +// config and data under /tmp/ipx-load, wiped first, seeds listeners, then runs each k6 script. +// While each runs, `ipx status` -- the Docker healthcheck -- is run every second, and the run +// fails if any answer takes as long as the healthcheck's 5s timeout. +// +// IPX_LOAD_FEEDS sets the catalogue's size (1500, near production's), IPX_LOAD_USERS the +// listeners (100), IPX_LOAD_LOG=1 shows the daemon's log. The daemon is on SQLite, not Postgres +// as production is: ponytail: Postgres needs a database of its own emptied before each run, +// which this cannot do yet (#139). +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { spawn, spawnSync, execFile, execFileSync } = require('child_process'); + +const repo = path.resolve(__dirname, '../..'); +const root = '/tmp/ipx-load'; +const WEB = 8793, GEN = 8794; +const FEEDS = Number(process.env.IPX_LOAD_FEEDS || 1500); +const USERS = Number(process.env.IPX_LOAD_USERS || 100); +// The first few feeds carry real files, downloaded for the media test. +const MEDIA = 4; +const ITEMS = 20; +const BASE = `http://127.0.0.1:${WEB}`; +const bin = path.join(repo, 'target/release/ipx'); +const SCRIPTS = ['browse', 'listening', 'media', 'signin']; +const env = { + ...process.env, + IPX_CONFIG: `${root}/config/config.toml`, + IPX_DATA_DIR: `${root}/data`, + IPX_LOG: 'ipx=info', + IPX_LOG_FORMAT: 'json', + // Nothing of the test reaches production's database or traces, or a paid API. + IPX_DATABASE_URL: '', + OTEL_EXPORTER_OTLP_ENDPOINT: '', + TYPESAFE_KEY: '', +}; + +// ---- the feeds ------------------------------------------------------------------------------ + +// Apple's categories, some with a subcategory, so the Directory has tiles and pages to draw. +const CATS = [['Technology'], ['News', 'Tech News'], ['Comedy'], ['Leisure', 'Video Games'], + ['Society & Culture', 'Documentary'], ['Sports', 'Soccer'], ['Arts', 'Books'], + ['Science', 'Astronomy'], ['History'], ['True Crime'], ['Business', 'Investing'], + ['Education', 'Self-Improvement'], ['Health & Fitness', 'Mental Health'], ['Music']]; +const esc = s => s.replace(/&/g, '&').replace(/ n % 3 === 2 && n >= MEDIA; +function feedXml(n) { + const [cat, sub] = CATS[n % CATS.length]; + const category = sub + ? `` + : ``; + const items = Array.from({ length: ITEMS }, (_, i) => ` + Episode ${ITEMS - i} of Show ${n}gen-${n}-${i} + ${new Date(now - (i * 3 + n % 3) * 86400e3).toUTCString()} + <p>Show ${n}, episode ${ITEMS - i}: an hour on the news, the weather and whatever came up.</p> + ${isBlog(n) ? '' : `${1800 + i * 60} + `} + `).join(''); + return ` + Generated Show ${n}http://127.0.0.1:${GEN}/site/${n} + Generated show number ${n}, for the load tests. + ${category}${items}`; +} +// A file whose every byte is known from its offset, so the media test can check that a range +// it asked for is the range it got, not merely that it got something that long. ID3 first, so +// ipx takes it for audio. +const mediaByte = k => (k * 31 + 7) & 255; +const MEDIA_BYTES = Buffer.from(Uint8Array.from({ length: 2 * 1024 * 1024 }, (_, k) => mediaByte(k))); +Buffer.from('ID3\x03\x00\x00\x00\x00\x00\x00', 'latin1').copy(MEDIA_BYTES); +const art = fs.readFileSync(path.join(repo, 'tests/ui/fixtures/art.jpg')); + +function serveFeeds() { + return http.createServer((req, res) => { + const m = req.url.match(/^\/(feed|media|art)\/(\d+)/); + if (m?.[1] === 'feed') { res.writeHead(200, { 'content-type': 'application/rss+xml' }); return res.end(feedXml(Number(m[2]))); } + if (m?.[1] === 'media') { res.writeHead(200, { 'content-type': 'audio/mpeg', 'content-length': MEDIA_BYTES.length }); return res.end(MEDIA_BYTES); } + if (m?.[1] === 'art') { res.writeHead(200, { 'content-type': 'image/jpeg' }); return res.end(art); } + res.writeHead(404).end(); + }).listen(GEN, '127.0.0.1'); +} +const feedId = n => `gen-${String(n).padStart(4, '0')}`; +const feedUrl = n => `http://127.0.0.1:${GEN}/feed/${n}.xml`; + +// ---- the daemon ----------------------------------------------------------------------------- + +function writeConfig() { + fs.rmSync(root, { recursive: true, force: true }); + for (const d of ['config', 'data', 'downloads']) fs.mkdirSync(path.join(root, d), { recursive: true }); + // Read into the database's catalogue on the first start, as an existing config.toml is. Only + // the media feeds download: a forced scan of a listener's 30 feeds would otherwise fetch a + // 2 MB episode of each, every time. + const feeds = Array.from({ length: FEEDS }, (_, n) => + `[feeds.${feedId(n)}]\nurl = "${feedUrl(n)}"\nauto_download = ${n < MEDIA}\n`).join('\n'); + fs.writeFileSync(env.IPX_CONFIG, ` +[general] +download_dir = "${root}/downloads" +socket = "${root}/ipx.sock" +schedule = "every 60m" +max_new_per_check = 1 + +[torrent] +enabled = false + +[web] +enabled = true +bind = "127.0.0.1:${WEB}" +token = "loadtokenloadtokenloadtoken12345" +# Each k6 user signs in by name, as Cloudflare Access does in production: no password to hash +# for every one of a hundred listeners. Only the sign-in test uses a password. +trusted_header = "X-Load-User" +trusted_proxies = ["127.0.0.1"] +auto_create_users = true + +${feeds}`); +} + +/// Resolves with the first log line matching `test`, read from the daemon's JSON log, which it +/// writes to stderr. +function waitForLog(daemon, test, ms) { + return new Promise((resolve, reject) => { + let buf = ''; + const timer = setTimeout(() => { daemon.stderr.off('data', on); reject(new Error(`no log line in ${ms / 1000}s for ${test}`)); }, ms); + const on = chunk => { + buf += chunk; + let i; + while ((i = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + let ev; try { ev = JSON.parse(line); } catch { continue; } + if (test(ev)) { clearTimeout(timer); daemon.stderr.off('data', on); return resolve(ev); } + } + }; + daemon.stderr.on('data', on); + }); +} + +const as = name => ({ 'X-Load-User': name, 'Content-Type': 'application/json' }); +async function api(name, url, opts = {}) { + const r = await fetch(BASE + url, { ...opts, headers: { ...as(name), ...opts.headers } }); + if (!r.ok) throw new Error(`${opts.method || 'GET'} ${url} as ${name}: ${r.status} ${await r.text()}`); + return r.status === 204 ? null : r.json().catch(() => null); +} +const opml = ns => `load${ + ns.map(n => ``).join('')}`; + +async function seed() { + // The first account made is the admin. + await api('admin', '/api/me'); + // Every listener subscribes to 30 feeds, overlapping as people's do; one OPML each, one scan. + for (let u = 0; u < USERS; u++) { + const mine = Array.from({ length: 30 }, (_, k) => (u * 7 + k * 41) % FEEDS); + await api(`load-${u}`, '/api/opml', { method: 'POST', body: JSON.stringify({ xml: opml(mine) }) }); + } + // The media test's listener takes the feeds with files. They may be downloaded already: the + // first start subscribes the admin to the whole catalogue, so the first scan fetched them. + for (let n = 0; n < MEDIA; n++) await api('listener', `/api/popular/${feedId(n)}`, { method: 'POST' }); + for (let t = Date.now(); ; await new Promise(r => setTimeout(r, 500))) { + const got = await api('listener', '/api/entries?filter=downloaded&limit=50'); + if (got.entries.flatMap(e => e.enclosures).filter(x => x.path).length >= MEDIA) break; + if (Date.now() - t > 120_000) throw new Error(`the listener's ${MEDIA} files did not download in 120s`); + } + // A password, for the sign-in test; the CLI hashes it as `ipx user add` always does. + execFileSync(bin, ['user', 'add', 'piper'], { input: 'piperpassword', env }); +} + +// ---- the run -------------------------------------------------------------------------------- + +/// `ipx status` once a second until stopped: the slowest answer, and any that failed. Not +/// spawnSync: blocked in it, this process stops draining the daemon's log, the pipe fills, and +/// the daemon stalls writing its next line, which is the test measuring itself. +function watchHealth() { + const seen = { slowest: 0, failed: 0 }; + let on = true; + (async () => { + while (on) { + const t = Date.now(); + const ok = await new Promise(res => execFile(bin, ['status'], { env, timeout: 5000 }, err => res(!err))); + seen.slowest = Math.max(seen.slowest, Date.now() - t); + if (!ok) seen.failed++; + await new Promise(res => setTimeout(res, 1000)); + } + })(); + return () => { on = false; return seen; }; +} + +function k6(script) { + return new Promise(resolve => { + const run = spawn('k6', ['run', '--quiet', '-e', `BASE=${BASE}`, '-e', `USERS=${USERS}`, '-e', `FEEDS=${FEEDS}`, + path.join(__dirname, `${script}.js`)], { stdio: 'inherit' }); + run.on('exit', code => resolve(code)); + }); +} + +(async () => { + const only = process.argv.slice(2); + for (const s of only) if (!SCRIPTS.includes(s)) { console.error(`no load test called ${s}: ${SCRIPTS.join(', ')}`); process.exit(2); } + if (spawnSync('k6', ['version']).error) { console.error('k6 is not installed: see install.sh'); process.exit(2); } + console.log('building the release binary...'); + execFileSync('cargo', ['build', '--release', '-q'], { cwd: repo, stdio: 'inherit' }); + + const feeds = serveFeeds(); + writeConfig(); + // Its log kept out of this run's output, which is k6's; IPX_LOAD_LOG=1 shows it. + const daemon = spawn(bin, ['daemon'], { env, stdio: ['ignore', process.env.IPX_LOAD_LOG ? 'inherit' : 'ignore', 'pipe'] }); + daemon.stderr.setEncoding('utf8'); + if (process.env.IPX_LOAD_LOG) daemon.stderr.on('data', d => process.stderr.write(d)); + // Stopped by hand, the daemon goes too, by its own PID, or it holds the ports for the next run. + for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { daemon.kill('SIGTERM'); process.exit(130); }); + let failed = 0; + try { + const t = Date.now(); + const first = await waitForLog(daemon, ev => ev.ev === 'scan_done' && ev.feeds > 0, 600_000); + console.log(`first scan: ${first.feeds} feeds in ${((Date.now() - t) / 1000).toFixed(1)}s`); + await seed(); + // The log is drained from here on, or the pipe fills and the daemon blocks writing to it. + daemon.stderr.resume(); + for (const script of only.length ? only : SCRIPTS) { + console.log(`\n=== ${script}`); + const stop = watchHealth(); + const code = await k6(script); + const health = stop(); + console.log(`ipx status: slowest ${health.slowest}ms, ${health.failed} failed`); + if (code !== 0) { failed++; console.log(`${script}: thresholds failed (k6 exit ${code})`); } + if (health.failed || health.slowest >= 5000) { failed++; console.log(`${script}: the healthcheck would have failed`); } + } + } catch (e) { + console.error(e.message); + failed++; + } finally { + // Its own PID, never a pkill: Tower sees production's ipx too (#38). + daemon.kill('SIGTERM'); + feeds.close(); + } + console.log(failed ? `\n${failed} failure(s)` : '\nall load tests passed'); + process.exit(failed ? 1 : 0); +})(); diff --git a/tests/load/signin.js b/tests/load/signin.js new file mode 100644 index 0000000..01a5a2d --- /dev/null +++ b/tests/load/signin.js @@ -0,0 +1,43 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend } from 'k6/metrics'; +import { BASE, as, me } from './lib.js'; + +// A flood of sign-in attempts with wrong passwords while everyone else goes on using the site. +// Two things only many requests at once show: whether checking passwords, tens of milliseconds +// of CPU each, starves the rest of the server, and whether a wrong password for a name that is +// an account takes longer to refuse than one for a name that is not -- the answers read the +// same, and the time would tell anyone which names are accounts here. +const gap = new Trend('signin_name_gap', true); +export const options = { + scenarios: { + attempts: { executor: 'constant-vus', vus: 40, duration: '40s', exec: 'attempt' }, + everyone: { executor: 'constant-vus', vus: 5, duration: '40s', exec: 'browse' }, + }, + thresholds: { + http_req_failed: ['rate==0'], + checks: ['rate==1'], + // 57ms on 2026-10-05; 4.3s while password checks ran on the async workers (#137). + 'http_req_duration{name:meanwhile}': ['p(95)<300'], + // Known name against unknown, one straight after the other: no gap but noise. 0.2ms on + // 2026-10-05; 31ms while an unknown name was refused without a check (#138). + signin_name_gap: ['med<10'], + }, +}; +const refused = { headers: { 'Content-Type': 'application/json' }, responseCallback: http.expectedStatuses(401) }; + +export function attempt() { + const known = http.post(`${BASE}/api/login`, JSON.stringify({ name: 'piper', password: 'not-the-password' }), + { ...refused, tags: { name: 'signin-known' } }); + const unknown = http.post(`${BASE}/api/login`, JSON.stringify({ name: `nobody-${__VU}-${__ITER}`, password: 'not-the-password' }), + { ...refused, tags: { name: 'signin-unknown' } }); + check(known, { 'a wrong password refused': r => r.status === 401 }); + check(unknown, { 'an unknown name refused': r => r.status === 401 }); + gap.add(known.timings.duration - unknown.timings.duration); +} + +export function browse() { + const r = http.get(`${BASE}/api/feeds`, as(me(), 'meanwhile')); + check(r, { 'the site still answers': r => r.status === 200 }); + sleep(0.5); +}