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);
+}