Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bbdcbe213f | |||
| 8c4a5f396b | |||
| b43ba89778 | |||
| f1d360420c | |||
| 697e907c86 | |||
| 894dbbe31d |
10
CHANGELOG.md
10
CHANGELOG.md
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- On Postgres, a write no longer waits for the database server's disk. A scan read about one feed a second and now reads 1,500 in 20 seconds.
|
||||
- Checking every feed no longer makes every icon in the feed list flash while it runs: the list keeps the icons it has already drawn.
|
||||
|
||||
### Security
|
||||
|
||||
- A flood of sign-in attempts no longer stalls the site for everyone else. Passwords are checked a few at a time, away from the threads that answer every other request; forty wrong passwords at a time made everything else take four seconds.
|
||||
- A wrong password is refused in the same time whether or not the name is an account here, so how long it takes no longer tells anyone which names are.
|
||||
|
||||
## [0.10.0] - 2026-10-05
|
||||
|
||||
### Added
|
||||
|
||||
28
CLAUDE.md
28
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,31 @@ 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** unless `--postgres`, which puts it in `IPX_TEST_DATABASE_URL`, in
|
||||
a schema of its own (`ipx_load`) dropped and made again each run. That URL, in `/src/.envrc`, is
|
||||
`ipodderx_test` on the `databases` project's server, production's, as `ipodderx`, taken from
|
||||
`ipx.env`; the harness refuses a database whose name does not end in `_test`. SQLite's numbers
|
||||
carry its single connection (#136); Postgres's are what production would see, and a run loads
|
||||
the server production's database is on, so not while someone is using iPX in earnest.
|
||||
* The Rust tests run on Postgres with the same URL: `. /src/.envrc && cargo test`. Each test
|
||||
makes a schema `ipxt_<pid>_<n>`, and the next run drops those an earlier one left.
|
||||
|
||||
Non-trivial logic leaves one runnable check behind. Pure functions (`merge_policy`, `pick`,
|
||||
`matches_keywords`, `parse_interval`) are the easiest place to put it.
|
||||
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "ipx"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1-dev"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ipx"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1-dev"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
39
src/auth.rs
39
src/auth.rs
@@ -30,6 +30,35 @@ pub fn verify_password(password: &str, stored: &str) -> bool {
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// How many password checks run at once: each is tens of milliseconds of CPU, and forty wrong
|
||||
/// passwords at a time, run on the async workers, made every other request wait 4s (#137).
|
||||
/// Half the cores, so a flood of sign-ins waits on itself and the rest of the server has the rest.
|
||||
static CHECKS: std::sync::LazyLock<tokio::sync::Semaphore> = std::sync::LazyLock::new(|| {
|
||||
tokio::sync::Semaphore::new(std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1)))
|
||||
});
|
||||
|
||||
/// What a name that is not an account is checked against, so that refusing it takes as long as
|
||||
/// refusing a wrong password does. Refused without a check, it came back 31ms sooner, and the
|
||||
/// time told anyone which names are accounts here (#138).
|
||||
static DECOY: std::sync::LazyLock<String> =
|
||||
std::sync::LazyLock::new(|| hash_password("no account here has this password").expect("hashing a fixed password"));
|
||||
|
||||
/// A sign-in's password against the account's hash, or against `DECOY` when there is no account
|
||||
/// or it has no password: false either way, in the same time. Off the async workers, and a few at
|
||||
/// a time; see `CHECKS`.
|
||||
pub async fn check_password(password: String, stored: Option<String>) -> bool {
|
||||
let Ok(_turn) = CHECKS.acquire().await else { return false };
|
||||
tokio::task::spawn_blocking(move || match stored {
|
||||
Some(h) => verify_password(&password, &h),
|
||||
None => {
|
||||
verify_password(&password, &DECOY);
|
||||
false
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A session id: 256 bits of urandom, hex. Long enough that guessing is not a strategy.
|
||||
pub fn new_session_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
@@ -76,6 +105,16 @@ pub fn name_from_header(raw: &str) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_sign_in_without_an_account_is_checked_all_the_same() {
|
||||
let h = hash_password("correct horse battery").unwrap();
|
||||
assert!(check_password("correct horse battery".into(), Some(h.clone())).await);
|
||||
assert!(!check_password("wrong".into(), Some(h)).await);
|
||||
assert!(!check_password("correct horse battery".into(), None).await);
|
||||
// A decoy that does not parse is refused before any hashing, and the time says so (#138).
|
||||
assert!(PasswordHash::new(&DECOY).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_password_verifies_only_against_itself() {
|
||||
let h = hash_password("correct horse battery").unwrap();
|
||||
|
||||
19
src/db.rs
19
src/db.rs
@@ -125,6 +125,12 @@ async fn connect(location: &str) -> Result<sea_orm::DatabaseConnection> {
|
||||
// 52ms, and 39s to subscribe an admin to 1,500 feeds (#135). A crash of ipx loses nothing;
|
||||
// only a power cut can lose the last transactions, and it never corrupts the file.
|
||||
opts.map_sqlx_sqlite_opts(|o| o.synchronous(sea_orm::sqlx::sqlite::SqliteSynchronous::Normal));
|
||||
// The same on Postgres, for ipx's own sessions: a commit waited for the WAL to reach the
|
||||
// disk, 12.8ms on the databases project's server, and at about fifty commits a feed a scan
|
||||
// took most of a second a feed (#140). A crash of the Postgres server can lose the last
|
||||
// commits, about the last 0.6s, and never corrupts anything. Added to whatever options the
|
||||
// URL sets, so it holds for the tests' search_path too.
|
||||
opts.map_sqlx_postgres_opts(|o| o.options([("synchronous_commit", "off")]));
|
||||
sea_orm::Database::connect(opts)
|
||||
.await
|
||||
.with_context(|| format!("opening {}", redact(location)))
|
||||
@@ -2106,13 +2112,16 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_syncs_at_checkpoints_not_every_commit() {
|
||||
async fn a_commit_does_not_wait_for_the_disk() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
if db.orm.get_database_backend() != sea_orm::DbBackend::Sqlite {
|
||||
return;
|
||||
}
|
||||
let row = db.orm.query_one_raw(Statement::from_string(sea_orm::DbBackend::Sqlite, "PRAGMA synchronous")).await.unwrap();
|
||||
let backend = db.orm.get_database_backend();
|
||||
if backend == sea_orm::DbBackend::Sqlite {
|
||||
let row = db.orm.query_one_raw(Statement::from_string(backend, "PRAGMA synchronous")).await.unwrap();
|
||||
assert_eq!(row.unwrap().try_get_by_index::<i32>(0).unwrap(), 1, "NORMAL, on every connection in the pool (#135)");
|
||||
} else {
|
||||
let row = db.orm.query_one_raw(Statement::from_string(backend, "SHOW synchronous_commit")).await.unwrap();
|
||||
assert_eq!(row.unwrap().try_get_by_index::<String>(0).unwrap(), "off", "with the test's own search_path as well (#140)");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -315,11 +315,8 @@ async fn login(
|
||||
) -> Result<Response, ApiError> {
|
||||
let name = body.name.trim().to_ascii_lowercase();
|
||||
let user = state.ctx.db.user_by_name(&name).await?;
|
||||
// The same answer either way: whether a name exists is not something to leak.
|
||||
let ok = user
|
||||
.as_ref()
|
||||
.and_then(|u| u.pass_hash.as_deref())
|
||||
.is_some_and(|h| crate::auth::verify_password(&body.password, h));
|
||||
// The same answer either way, in the same time: whether a name exists is not something to leak.
|
||||
let ok = crate::auth::check_password(body.password, user.as_ref().and_then(|u| u.pass_hash.clone())).await;
|
||||
if !ok {
|
||||
tracing::warn!(user = %name, "failed sign-in");
|
||||
return Ok((StatusCode::UNAUTHORIZED, "wrong name or password").into_response());
|
||||
|
||||
54
tests/load/browse.js
Normal file
54
tests/load/browse.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import http from 'k6/http';
|
||||
import { check, sleep } from 'k6';
|
||||
import { BASE, FEEDS, ITEMS, as, me, pick, feedId, p95 } 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 on Tower. SQLite: 236, 118, 133, 119, 259, 312ms;
|
||||
// Postgres: 54, 34, 24, 44, 71, 88ms.
|
||||
'http_req_duration{name:feeds}': p95(500, 150),
|
||||
'http_req_duration{name:all-entries}': p95(300, 100),
|
||||
'http_req_duration{name:feed-entries}': p95(300, 100),
|
||||
'http_req_duration{name:search}': p95(300, 100),
|
||||
'http_req_duration{name:directory}': p95(600, 200),
|
||||
'http_req_duration{name:listed}': p95(700, 200),
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
19
tests/load/lib.js
Normal file
19
tests/load/lib.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// 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)];
|
||||
/// A p95 budget, in ms, for whichever database the daemon is on (run.js --postgres). Postgres's
|
||||
/// are tighter: SQLite's carry the queue for its one connection (#136).
|
||||
export const p95 = (sqlite, postgres) => [`p(95)<${__ENV.DB === 'postgres' ? postgres : sqlite}`];
|
||||
export const feedId = n => `gen-${String(n).padStart(4, '0')}`;
|
||||
58
tests/load/listening.js
Normal file
58
tests/load/listening.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import http from 'k6/http';
|
||||
import { check, sleep } from 'k6';
|
||||
import { BASE, as, me, pick, p95 } 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 on Tower. SQLite: 53, 786 and 383ms; Postgres: 9,
|
||||
// 225 and 153ms. Marking read answers with the feed's row, counts and all, and on SQLite
|
||||
// waits behind the scans' writes for its one connection (#136), hence its budget.
|
||||
'http_req_duration{name:position}': p95(300, 100),
|
||||
'http_req_duration{name:flags}': p95(1500, 500),
|
||||
'http_req_duration{name:read-back}': p95(800, 400),
|
||||
},
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
48
tests/load/media.js
Normal file
48
tests/load/media.js
Normal file
@@ -0,0 +1,48 @@
|
||||
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 on SQLite and 45 on Postgres: 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
280
tests/load/run.js
Normal file
280
tests/load/run.js
Normal file
@@ -0,0 +1,280 @@
|
||||
// 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 [--postgres] [browse|listening|media|signin ...] default: all four
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// The daemon is on SQLite unless --postgres, which puts it in IPX_TEST_DATABASE_URL, the
|
||||
// database the Rust tests use on Postgres too (ipodderx_test on the server production's is on, in
|
||||
// /src/.envrc), in a schema of its own, ipx_load, made afresh each run (#139). SQLite's numbers
|
||||
// carry its one connection (#136); Postgres's are what production would see.
|
||||
// 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.
|
||||
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 POSTGRES = process.argv.includes('--postgres');
|
||||
const PG_URL = process.env.IPX_TEST_DATABASE_URL || '';
|
||||
const PG_SCHEMA = 'ipx_load';
|
||||
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.
|
||||
// In its own schema, as Db::memory puts each Rust test, and with notices off, as url_for does.
|
||||
IPX_DATABASE_URL: POSTGRES
|
||||
? `${PG_URL}${PG_URL.includes('?') ? '&' : '?'}options=-c%20search_path%3D${PG_SCHEMA}%20-c%20client_min_messages%3Dwarning`
|
||||
: '',
|
||||
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(/</g, '<').replace(/"/g, '"');
|
||||
const now = Date.now();
|
||||
// Every third feed is a blog: no files, as most of production's are.
|
||||
const isBlog = n => n % 3 === 2 && n >= MEDIA;
|
||||
function feedXml(n) {
|
||||
const [cat, sub] = CATS[n % CATS.length];
|
||||
const category = sub
|
||||
? `<itunes:category text="${esc(cat)}"><itunes:category text="${esc(sub)}"/></itunes:category>`
|
||||
: `<itunes:category text="${esc(cat)}"/>`;
|
||||
const items = Array.from({ length: ITEMS }, (_, i) => `<item>
|
||||
<title>Episode ${ITEMS - i} of Show ${n}</title><guid>gen-${n}-${i}</guid>
|
||||
<pubDate>${new Date(now - (i * 3 + n % 3) * 86400e3).toUTCString()}</pubDate>
|
||||
<description><p>Show ${n}, episode ${ITEMS - i}: an hour on the news, the weather and whatever came up.</p></description>
|
||||
${isBlog(n) ? '' : `<itunes:duration>${1800 + i * 60}</itunes:duration>
|
||||
<enclosure url="http://127.0.0.1:${GEN}/media/${n}/${i}.mp3" length="${MEDIA_BYTES.length}" type="audio/mpeg"/>`}
|
||||
</item>`).join('');
|
||||
return `<?xml version="1.0"?><rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"><channel>
|
||||
<title>Generated Show ${n}</title><link>http://127.0.0.1:${GEN}/site/${n}</link>
|
||||
<description>Generated show number ${n}, for the load tests.</description>
|
||||
<itunes:image href="http://127.0.0.1:${GEN}/art/${n}.jpg"/>${category}${items}</channel></rss>`;
|
||||
}
|
||||
// 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 -----------------------------------------------------------------------------
|
||||
|
||||
/// The load tests' schema dropped and made again, as /tmp/ipx-load is wiped. Only in a database
|
||||
/// made for tests: the URL is production's server and role, and one slip of a name would
|
||||
/// otherwise point this at production's database.
|
||||
function freshPostgres() {
|
||||
const u = PG_URL && new URL(PG_URL);
|
||||
if (!u || !/_test$/.test(u.pathname)) {
|
||||
throw new Error('--postgres needs IPX_TEST_DATABASE_URL, a database whose name ends in _test (. /src/.envrc)');
|
||||
}
|
||||
psql(`DROP SCHEMA IF EXISTS ${PG_SCHEMA} CASCADE`, `CREATE SCHEMA ${PG_SCHEMA}`);
|
||||
}
|
||||
|
||||
/// Statements run in the test database, given by its URL in psql's environment, not its
|
||||
/// arguments, where every process on Tower could read the password.
|
||||
function psql(...sql) {
|
||||
const u = new URL(PG_URL);
|
||||
const env = { ...process.env, PGHOST: u.hostname, PGPORT: u.port || '5432', PGUSER: decodeURIComponent(u.username),
|
||||
PGPASSWORD: decodeURIComponent(u.password), PGDATABASE: u.pathname.slice(1), PGOPTIONS: `-c search_path=${PG_SCHEMA}` };
|
||||
execFileSync('psql', ['-q', '-v', 'ON_ERROR_STOP=1', ...sql.flatMap(s => ['-c', s])], { env, stdio: ['ignore', 'ignore', 'inherit'] });
|
||||
}
|
||||
|
||||
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 => `<?xml version="1.0"?><opml version="2.0"><head><title>load</title></head><body>${
|
||||
ns.map(n => `<outline type="rss" text="${feedId(n)}" xmlUrl="${feedUrl(n)}"/>`).join('')}</body></opml>`;
|
||||
|
||||
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}`,
|
||||
'-e', `DB=${POSTGRES ? 'postgres' : 'sqlite'}`,
|
||||
path.join(__dirname, `${script}.js`)], { stdio: 'inherit' });
|
||||
run.on('exit', code => resolve(code));
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const only = process.argv.slice(2).filter(a => a !== '--postgres');
|
||||
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();
|
||||
if (POSTGRES) freshPostgres();
|
||||
console.log(`on ${POSTGRES ? 'Postgres' : 'SQLite'}`);
|
||||
// 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 planner's statistics, as production's long-standing tables have them. A schema filled
|
||||
// seconds ago has none until autovacuum gets to it, which it did partway through a test, and
|
||||
// the search's p95 swung from 40ms to 166ms between runs with the plan it changed.
|
||||
if (POSTGRES) psql('ANALYZE');
|
||||
// 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);
|
||||
})();
|
||||
44
tests/load/signin.js
Normal file
44
tests/load/signin.js
Normal file
@@ -0,0 +1,44 @@
|
||||
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 on SQLite, 68 on Postgres; 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);
|
||||
}
|
||||
@@ -966,6 +966,24 @@ test('the Directory lists what everyone here reads, the most subscribed first, b
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('the feed list keeps the icons it has drawn when a feed\'s new row comes in (#134)', async ({ page }) => {
|
||||
await expect(page.locator('#feedlist .feed').first()).toBeVisible({ timeout: 20_000 });
|
||||
const kept = await page.evaluate(async () => {
|
||||
const frames = () => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
// Artwork that loads, on a feed of its own row: the fixtures' fails on purpose, for initials.
|
||||
const row = { ...S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id)), image: '/favicon.png' };
|
||||
patchFeed(row); await frames();
|
||||
const rows = [...document.querySelectorAll('#feedlist .feed')];
|
||||
const before = [...document.querySelectorAll('#feedlist .feed img')];
|
||||
// The same feed's new row again, as a check of every feed sends one for each feed it reads.
|
||||
patchFeed({ ...row }); await frames();
|
||||
const after = [...document.querySelectorAll('#feedlist .feed img')];
|
||||
return { drawn: before.length > 0, redrawn: !rows[0].isConnected, same: after.length === before.length && after.every((img, i) => img === before[i]) };
|
||||
});
|
||||
// The rows are new, the images in them the ones already drawn.
|
||||
expect(kept).toEqual({ drawn: true, redrawn: true, same: true });
|
||||
});
|
||||
|
||||
test('adding a feed scans it straight away', async ({ page }) => {
|
||||
await page.locator('#addFeed').click();
|
||||
await page.locator('#nurl').fill('http://127.0.0.1:8792/fresh.xml');
|
||||
|
||||
@@ -66,6 +66,11 @@ function renderFeeds(){
|
||||
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
|
||||
if(row) (back[1]&&$('.chev',row)||row).focus();
|
||||
};
|
||||
// The images already drawn, to go into the rows that replace theirs. Made anew, every icon in
|
||||
// the list went blank and loaded again, once a frame through a check of every feed, as each
|
||||
// feed's new row came in (#134). Matched by their markup, so a changed one is made anew.
|
||||
const drawn=new Map<string,HTMLImageElement[]>();
|
||||
for(const img of $$('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k).push(img); }
|
||||
list.innerHTML='';
|
||||
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
|
||||
const places=document.createElement('div');
|
||||
@@ -139,6 +144,7 @@ function renderFeeds(){
|
||||
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
||||
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
||||
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||
for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); }
|
||||
list.appendChild(el);
|
||||
}
|
||||
paintScanning();
|
||||
|
||||
Reference in New Issue
Block a user