smolboid/tests/stress.sh

177 lines
6.9 KiB
Bash
Executable file

#!/usr/bin/env bash
# smolboid stress test: a mail flood against the daily quota and reply
# budget, an HTTP load burst against a full feed, and feed-integrity checks
# throughout. Real binaries, real Noise sessions.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
FUMI="$ROOT/../fumi/target/debug/fumi"
BUNSHIN_BIN="$ROOT/../bunshin/target/debug/bunshin"
SMOLBOID="$ROOT/target/debug/smolboid"
HOST=127.0.0.1
SMTP_PORT=19621
HTTP_PORT=18091
RIG="$(mktemp -d /tmp/smolboid-stress.XXXXXX)"
DATA="$RIG/data"
DAEMON_ADDR="twtxt@$HOST:$SMTP_PORT"
FEED="$DATA/feeds/tobias.txt"
BUNSHIN_PID=""
SMOLBOID_PID=""
cleanup() {
[[ -n "$BUNSHIN_PID" ]] && kill "$BUNSHIN_PID" 2>/dev/null || true
[[ -n "$SMOLBOID_PID" ]] && kill "$SMOLBOID_PID" 2>/dev/null || true
}
trap cleanup EXIT
step() { printf '\n== %s ==\n' "$*"; }
fail() { echo "FAIL: $*" >&2; exit 1; }
wait_for() {
local file=$1 text=$2 tries=${3:-40} i
for ((i = 0; i < tries; i++)); do
[[ -f $file ]] && grep -q "$text" "$file" && return 0
sleep 1
done
fail "timed out waiting for '$text' in $file"
}
count_posts() { grep -cE '^[0-9]' "$FEED" 2>/dev/null || echo 0; }
step "build"
cargo build --manifest-path "$ROOT/../bunshin/Cargo.toml" --quiet
cargo build --manifest-path "$ROOT/../fumi/Cargo.toml" --quiet
cargo build --manifest-path "$ROOT/Cargo.toml" --quiet
step "bootstrap: bunshin, author tobias, daemon twtxt"
mkdir -p "$DATA"
"$BUNSHIN_BIN" keygen --key "$RIG/server.key" >"$RIG/keygen.log" 2>&1
SERVER_KEY="$(grep -oE '[a-z2-7]{52}' "$RIG/keygen.log" | head -1)"
[[ -n "$SERVER_KEY" ]] || fail "no server key"
# Raised server rate limits: the flood stresses smolboid's quota, not
# bunshin's per-IP throttles — everything here shares 127.0.0.1, and the
# daemon's one-second polling already AUTHs 60 times a minute.
"$BUNSHIN_BIN" serve --key "$RIG/server.key" --db "$RIG/server.db" \
--host "$HOST" --port "$SMTP_PORT" \
--rate-connections 1000000 --rate-sends 1000000 --rate-tokens 1000000 \
>"$RIG/bunshin.log" 2>&1 &
BUNSHIN_PID=$!
sleep 1
author() { "$FUMI" --key "$RIG/author.key" --db "$RIG/author.db" --timeout 10 "$@"; }
daemon_fumi() { "$FUMI" --key "$RIG/master.key" --db "$DATA/mail.db" --timeout 10 "$@"; }
author keygen --force >/dev/null
author trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
author register "tobias@$HOST:$SMTP_PORT" >/dev/null
"$SMOLBOID" keygen "$RIG/master.key" >/dev/null
daemon_fumi trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
daemon_fumi register "$DAEMON_ADDR" >/dev/null
step "admit"
cat >"$RIG/smolboid.toml" <<EOF
listen = $HOST:$HTTP_PORT
base_url = http://$HOST:$HTTP_PORT
smolmail_host = $HOST
mailbox = twtxt
master = $RIG/master.key
data = $DATA
poll_secs = 1
timeout = 10
cap_bytes = 1048576
posts_per_day = 25
reposts_per_day = 25
EOF
"$SMOLBOID" admit --config "$RIG/smolboid.toml" "tobias@$HOST:$SMTP_PORT" >"$RIG/admit.log" 2>&1 \
|| { cat "$RIG/admit.log"; fail "admit"; }
author fetch >/dev/null
step "serve"
"$SMOLBOID" serve --config "$RIG/smolboid.toml" >"$RIG/serve.log" 2>&1 &
SMOLBOID_PID=$!
sleep 2
step "mail burst: 40 unpaced posts in a few seconds"
START=$(date +%s)
for i in $(seq 1 40); do
author send "$DAEMON_ADDR" --body "flood post $i" >/dev/null
done
echo "40 sends in $(( $(date +%s) - START ))s"
# Rapid fire means many payload seconds collide; what must hold is
# integrity: nothing invalid, the quota never exceeded, the daemon alive.
for ((i = 0; i < 40; i++)); do
sleep 1
[[ "$(count_posts)" -ge 1 ]] && break
done
[[ "$(count_posts)" -le 25 ]] || fail "quota exceeded under burst: $(count_posts) records"
"$SMOLBOID" check "$FEED" >"$RIG/check.log" \
|| { cat "$RIG/check.log"; fail "the burst left the feed unparseable"; }
grep -qE "0 unsigned, 0 invalid" "$RIG/check.log" \
|| { cat "$RIG/check.log"; fail "the burst published invalid records"; }
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" >/dev/null || fail "listener unhealthy after the burst"
step "quota precision: paced posts fill exactly to 25"
# One send per second keeps timestamps distinct, so only the daily quota
# can stop publication: the feed lands on exactly the configured cap.
for i in $(seq 41 70); do
author send "$DAEMON_ADDR" --body "flood post $i" >/dev/null
sleep 1
[[ "$(count_posts)" == 25 ]] && break
done
for ((i = 0; i < 20; i++)); do
[[ "$(count_posts)" == 25 ]] && break
sleep 1
done
[[ "$(count_posts)" == 25 ]] || fail "quota held at $(count_posts) records, want 25"
"$SMOLBOID" check "$FEED" >"$RIG/check.log" \
|| { cat "$RIG/check.log"; fail "the quota-cut feed does not verify"; }
grep -q "25 verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|| { cat "$RIG/check.log"; fail "not all 25 verify"; }
author fetch >/dev/null
step "reply budget: the flood drew at most 10 replies"
# The welcome mail also carries a twtxt: subject; only rejections count.
author list --requests 2>/dev/null | grep "twtxt:" | grep -cv "feed is ready" \
>"$RIG/replies.count" || true
REPLIES=$(grep -oE '^[0-9]+' "$RIG/replies.count" | head -1)
echo "error replies after the flood: $REPLIES"
[[ "$REPLIES" -le 10 ]] || fail "reply budget exceeded: $REPLIES replies in an hour"
step "http load: 200 requests, 20 parallel"
URL="http://$HOST:$HTTP_PORT/twtxt/tobias.txt"
ETAG="$(curl -s -D - -o /dev/null "$URL" | tr -d '\r' | awk -F'"' '/^ETag:/ {print $2}')"
[[ -n "$ETAG" ]] || fail "no etag"
START=$(date +%s%N)
seq 1 200 | xargs -P 20 -I{} curl -sf -o /dev/null "$URL" || fail "a load request failed"
END=$(date +%s%N)
MS=$(((END - START) / 1000000))
echo "200 requests in ${MS}ms ($((200000 / MS)) req/s lower bound)"
AFTER="$(curl -s -D - -o /dev/null "$URL" | tr -d '\r' | awk -F'"' '/^ETag:/ {print $2}')"
[[ "$AFTER" == "$ETAG" ]] || fail "the etag moved under load without a write"
step "conditional load: 200 requests, 20 parallel, all 304"
START=$(date +%s%N)
seq 1 200 | xargs -P 20 -I{} \
curl -sf -o /dev/null -w '%{http_code}\n' -H "If-None-Match: \"$ETAG\"" "$URL" \
| sort | uniq -c >"$RIG/codes.log"
END=$(date +%s%N)
echo "200 conditional requests in $(((END - START) / 1000000))ms"
grep -q "200 304" "$RIG/codes.log" || { cat "$RIG/codes.log"; fail "not all conditional requests were 304"; }
step "read-during-write: the feed stays parseable while the mail loop runs"
author send "$DAEMON_ADDR" --header "Twt-Delete: $(grep -E '^[0-9]' "$FEED" | tail -1 | cut -f1)" --body "" >/dev/null
BROKEN=0
for i in $(seq 1 60); do
curl -sf "$URL" >/dev/null || BROKEN=$((BROKEN + 1))
# One per-loop read while the delete lands between polls.
sleep 0.2
done
[[ "$BROKEN" == 0 ]] || fail "$BROKEN reads failed while the feed changed"
for ((i = 0; i < 20; i++)); do
[[ "$(count_posts)" == 24 ]] && break
sleep 1
done
[[ "$(count_posts)" == 24 ]] || fail "delete did not land"
"$SMOLBOID" check "$FEED" >/dev/null || fail "final feed does not verify"
[[ "$(count_posts)" == 24 ]] || fail "expected 24 records after the delete, got $(count_posts)"
step "stress passed"
echo "feed: $FEED"
echo "logs: $RIG"
cleanup
trap - EXIT