177 lines
6.9 KiB
Bash
Executable file
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
|