#!/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" <"$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