commit e12aa50d201b5c4f67ebc2da78a1e89a45d4949f
parent 7e019a138d550ce866b19c370b00f35fb4988ac7
Author: Christian Grothoff <christian@grothoff.org>
Date: Fri, 7 Aug 2026 20:40:18 +0200
add benchmark
Diffstat:
7 files changed, 1247 insertions(+), 11 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -13,3 +13,4 @@ build/
# 'nix build' result symlink
result
src/tests/test_paywall.conf.edited
+src/tests/merchant_stub
diff --git a/src/tests/README b/src/tests/README
@@ -659,6 +659,148 @@ implementations agreeing IS the protocol, and a change on either side
that this vector does not survive means every order is created under
an id the other side will not look for.
+
+The benchmark (`benchmark.sh`)
+------------------------------
+
+Not a test -- it asserts nothing about correctness and its result
+depends on the machine. It answers two questions: how much
+throughput does putting paivana in front of an origin cost, and how
+fast is paivana on its own when the paywall turns a client away?
+
+ meson test --benchmark proxy_overhead -C build # the first
+ meson test --benchmark paywall_page -C build # the second
+
+Both are registered with meson's `benchmark()` rather than `test()`,
+which is what keeps them out of `make check`: `meson test` does not
+run benchmarks. They are two entries rather than one so that they
+skip independently -- the paywall arm needs things the proxy arm does
+not, and should not be able to take it down with it. Run the script
+directly for the knobs -- `-c` clients, `-s` page size, `-d` seconds,
+`-m direct|proxy|paywall|both|all`. It exits 77 when rustc was not
+available to build upstream_rs, or when the build is sanitized (those
+timings measure ASan), and 1 if any request failed, since a run with
+failures has not measured throughput.
+
+N curl workers fetch a fixed-size page for a fixed time. In `direct`
+they fetch it straight from upstream_rs, in `proxy` through paivana
+in front of that same upstream_rs, and in `paywall` they fetch
+paivana's own 402 page with no upstream in the path at all. For each
+arm it reports requests, requests/s, MB/s (10^6), the page size and
+the server processes' CPU time; then the proxy/direct ratio, and the
+paywall/proxy one. Nothing touches disk: the upstream's page comes
+from a buffer it fills once at startup -- it used to regenerate it a
+byte at a time per request, which was real work charged to the arm
+that has no proxy in it -- the paywall page comes out of paivana's
+response cache, and the clients discard every body.
+
+The expected size is measured from a warm-up request rather than
+assumed, because in paywall mode nothing here knows it up front: it
+is whatever `paywall.en.must` renders to (50300 bytes as of writing).
+That measured size is then what every later response is checked
+against, so a short body counts as a failure rather than as
+throughput that was not achieved.
+
+Three properties of the setup shape the number, and all three make
+the proxy look better than it is, so the reported ratio is a floor:
+
+ - Every request gets a fresh TCP connection in *both* arms. That
+ is forced, not chosen: upstream_rs answers one request per
+ connection and closes, so the direct arm cannot keep-alive at
+ all, while paivana's client side happily would -- MHD strips the
+ upstream's hop-by-hop `Connection: close` and decides the client
+ connection's fate itself. Measured: without the clients sending
+ `Connection: close`, curl's second request through paivana
+ reports num_connects=0 and the same request direct reports 1.
+ So the clients send it, and both arms are charged one TCP setup
+ per request.
+
+ - curl's own process startup (12.5 ms on the machine this was
+ written on -- it links openssl, nghttp2, brotli, zstd, ldap) is
+ amortised by handing each curl invocation a batch of URLs. The
+ batch size converges at run time rather than being computed from
+ the page size, because how long a batch takes depends on the
+ per-worker request rate, which is what is being measured: a size
+ picked up front overshot a 3 s run by 7% at `-c 32`. At a fixed
+ batch of 8 the startup was 44% of the run and the reported rate
+ came out at half the truth.
+
+ Both of the above are per-request constants added to *both*
+ arms, so they pull the ratio toward 1.
+
+ - paivana is single-threaded by construction -- one GNUnet
+ scheduler driving MHD and libcurl -- and upstream_rs spawns a
+ thread per connection, so on a multi-core box the direct arm may
+ use every core and the proxy arm may not. That is a real
+ property of paivana rather than an artefact of the harness, but
+ it does make the ratio a function of the core count, which is
+ why CPU seconds and `nproc` are printed with it.
+
+A fourth applies to paywall mode only: the paywall page is not the
+`-s` size, so that arm is comparable to the others in requests/s and
+not in MB/s. The script prints both page sizes next to the ratio and
+does not offer a MB/s one.
+
+For orientation, one run on a 24-core machine at the defaults (8
+clients, 64 KiB page, 10 s):
+
+ direct 34932 req/s 2289 MB/s upstream_rs 3.07 cores
+ proxy 6816 req/s 447 MB/s paivana 0.97 cores
+ paywall 33993 req/s 1710 MB/s paivana 0.90 cores
+
+so 0.20x of direct through the proxy, and 4.99x the forwarded rate
+for the paywall page (50300 bytes). paivana is at 0.97 cores
+forwarding: it is saturating its single thread, which is the bound
+that matters. Sweeping the paywall arm shows the same bound from the
+other side -- 26852 req/s at `-c 4` and 0.81 cores, 33504 at `-c 8`,
+36049 at `-c 16` and 0.98 cores, 35892 at `-c 32` -- i.e. it stops
+scaling exactly where the thread runs out, at about 36k req/s.
+
+Do not quote those figures. They are stable to a couple of percent
+when the machine is quiet, but an *earlier* set on the same 24-core
+box read 13375 req/s direct and 4707 through paivana, i.e. 0.35x
+rather than 0.20x, because the direct arm was then getting only 1.5
+cores instead of 3.1. The clients are a bash loop forking curl and
+they compete with the servers for the machine, so under contention
+the fastest arm loses the most and the ratio flatters the proxy. The
+run's own CPU numbers are what tell you which regime you were in.
+
+Why the paywall arm needs a merchant backend, and why a stub is
+honest here. paivana does not open its listen socket until it has
+fetched a template from a merchant backend
+(PAIVANA_HTTPD_load_templates -> templates_ready ->
+PAIVANA_HTTPD_serve_requests), so paywall mode cannot measure
+anything without one. It starts `merchant_stub`, which answers the
+two GETs of that startup exchange -- shaped as
+merchant_api_get-private-templates{,-TEMPLATE_ID}.c parse them, with
+the contract test_paywall.sh POSTs to a real backend -- and nothing
+else. That is the whole of what a real backend would do here: the
+template is fetched once, the page is rendered locally from it, and
+the rendered MHD_Response is cached per (language, encoding) in
+load_paywall(), so from the first measured request onwards a live
+merchant is exactly as idle as the stub. This benchmark never buys
+anything, so no code path that can tell the two apart is reached.
+The stub does check the bearer token, since paivana building that
+header out of MERCHANT_ACCESS_TOKEN is the one part of the exchange
+that could silently regress; a 401 makes paivana refuse to start
+rather than start against a configuration nobody would deploy. To
+check the claim instead of taking it, set PAIVANA_BENCH_MERCHANT_URL
+(and PAIVANA_BENCH_MERCHANT_TOKEN) at a live backend carrying a
+`paivana` template -- named `premium`, or whatever
+PAIVANA_BENCH_TEMPLATE_ID says.
+
+Like test_paywall.sh, paywall mode stages `paywall.en.must` into a
+scratch prefix and points PAIVANA_PREFIX at it, rather than requiring
+`make install` for a page that lives in the build tree.
+
+The likeliest cause of failures here is not paivana: one connection
+per request against a fixed server port pins the 4-tuple for the
+TIME_WAIT duration, and a few runs back to back can fill the local
+ephemeral range (28k ports by default against ~13k connections per
+run). The script says so when it sees transfers that never
+connected.
+
+
Environment variables
---------------------
@@ -674,6 +816,19 @@ The driver script honors:
first port of the block the suite binds
(default 18400); see below
+benchmark.sh honors the first four of those, plus:
+
+ PAIVANA_BENCH_PORT_BASE
+ first port of its own block (default 18600)
+ PAIVANA_BENCH_MERCHANT_URL
+ a live merchant backend for paywall mode, instead
+ of starting merchant_stub
+ PAIVANA_BENCH_MERCHANT_TOKEN
+ bearer token for it
+ PAIVANA_BENCH_TEMPLATE_ID
+ template to ask that backend for (default
+ `premium`, which is what merchant_stub serves)
+
Ports used
----------
@@ -708,6 +863,14 @@ paivana you are debugging:
PAIVANA_PORT_BASE=18700 meson test -C build reverse_proxy
+benchmark.sh binds its own three ports off a separate base, so it can
+run beside the suite: PAIVANA_BENCH_PORT_BASE, default 18600, giving
+18601 for upstream_rs, 18602 for paivana and 18603 for merchant_stub.
+It checks the ones the chosen `-m` actually needs, and skips the same
+way. The `paywall_page` benchmark entry passes `-b 18610` so that
+the two entries cannot collide if anyone runs the benchmarks in
+parallel, which is not meson's default but is one flag away.
+
Endpoints (implemented by every upstream)
-----------------------------------------
diff --git a/src/tests/base64url_vectors.c b/src/tests/base64url_vectors.c
@@ -68,7 +68,7 @@ int
main (int argc,
char **argv)
{
- unsigned char buf[96];
+ unsigned char buf[96] = { 0 };
(void) argc;
(void) argv;
diff --git a/src/tests/benchmark.sh b/src/tests/benchmark.sh
@@ -0,0 +1,816 @@
+#!/bin/bash
+#
+# Rough measurement of what the paivana reverse proxy costs, and of
+# what it costs to be turned away by the paywall.
+#
+# Runs N parallel curl clients against a fixed-size static page for a
+# fixed period, and reports completed requests, requests/s and MB/s.
+# There are three things it can point them at:
+#
+# direct straight at the Rust upstream
+# proxy through paivana-httpd in front of that same upstream
+# paywall at paivana's own paywall page, with no upstream involved
+#
+# direct-vs-proxy is what the proxy costs, and is the number this
+# started out to produce. paywall is the other side of paivana: the
+# 402 page an unpaid client is sent to, which paivana renders from its
+# template once and then serves out of its own response cache, so it
+# measures paivana answering by itself -- the cheapest thing it does,
+# and the one an unauthenticated client can ask for without limit.
+#
+# Nothing is written to disk: the upstream's page comes from a buffer
+# built once at its startup, the paywall page from paivana's cache, and
+# every client discards the body. The whole thing runs on loopback
+# against one upstream process and one paivana process.
+#
+# Usage: benchmark.sh [-c CLIENTS] [-s SIZE] [-d SECONDS] [-m MODE]
+#
+# -c N parallel curl clients (default 8)
+# -s SIZE page size in bytes; k/M suffixes accepted (default 64k).
+# Capped at 10M, which is the largest /large/ the upstream
+# will serve. Does not apply to paywall mode, where the
+# page size is whatever the template renders to.
+# -d SECS how long to run each mode for (default 10)
+# -m MODE direct, proxy, paywall, both (= direct + proxy, the
+# default) or all
+# -b PORT first of the three ports used (default 18600, or
+# $PAIVANA_BENCH_PORT_BASE)
+# -h this help
+#
+# Environment: PAIVANA_HTTPD, SRCDIR, BUILDDIR and KEEP_TMP have the
+# same meaning as in test_reverse_proxy.sh. PAIVANA_BENCH_MERCHANT_URL
+# and PAIVANA_BENCH_MERCHANT_TOKEN point paywall mode at a real
+# merchant backend instead of the stub (see below).
+#
+# Exits 77 (meson: SKIP) when a binary or a port is missing, and 1 when
+# any request failed -- a run with failures has not measured throughput,
+# it has measured how fast something can be refused.
+#
+# ---------------------------------------------------------------------
+# Four things about the setup shape the numbers, and are printed with
+# them so nobody has to read this file to interpret a result:
+#
+# * Every request gets a fresh TCP connection, in BOTH modes. Not a
+# choice: upstream_rs answers one request per connection and closes
+# (see its client_loop()), so the direct arm could not keep-alive
+# even if asked. paivana's client side would, though -- MHD strips
+# the upstream's hop-by-hop `Connection: close' and decides the
+# client connection's fate itself -- so the clients send
+# `Connection: close' to hold both arms to the same behaviour.
+# Otherwise the proxy would be credited with connection reuse the
+# thing it is being compared against cannot do.
+#
+# The bias this leaves is a known one: a TCP setup and teardown is
+# charged to both arms, so it is a constant added to both sides of
+# the ratio, which pulls the ratio toward 1. The proxy's real
+# relative cost is therefore a little worse than what is reported,
+# never better. The same goes for the share of each run that is
+# curl's own process startup (see BATCH_LO below): a per-request
+# constant present in both arms, pulling the same way.
+#
+# How large that share is depends on how much of the machine the
+# clients get, which is why the same box has produced both 0.20x
+# and 0.35x for the same configuration: under contention the
+# fastest arm loses the most. The reported CPU is the tell -- an
+# origin at 3 cores and one at 1.5 are not the same measurement.
+#
+# * paivana is single-threaded by construction -- one GNUnet scheduler
+# driving MHD and libcurl, no threads -- while upstream_rs spawns a
+# thread per connection. On a multi-core box the direct arm is
+# therefore free to use every core and the proxy arm is not. That
+# is a real property of the proxy and not an artefact, but it does
+# mean the ratio is a function of how many cores the machine has, so
+# CPU seconds and the core count are reported alongside.
+#
+# * The paywall page is not the same size as -s, so paywall mode is
+# comparable to the other two in requests/s and not in MB/s. Its
+# size is measured and printed rather than assumed.
+#
+# * MB is 10^6 bytes.
+#
+# On the merchant backend in paywall mode: paivana does not open its
+# listen socket until it has fetched a template from one, so paywall
+# mode needs a backend before it can measure anything. It starts
+# merchant_stub, which answers the two GETs of that startup exchange
+# and nothing else. That is not a shortcut around a real backend, it
+# is the whole of what a real backend would do here: the template is
+# fetched once, the page is rendered locally from it, and the rendered
+# response is cached, so from the first measured request onwards a live
+# merchant would be exactly as idle as the stub. Nothing in this
+# benchmark buys anything. For anyone who would rather check that than
+# take it: set PAIVANA_BENCH_MERCHANT_URL (and, if it wants one,
+# PAIVANA_BENCH_MERCHANT_TOKEN) and paivana is pointed at that instead
+# -- it needs an instance carrying a `paivana' template whose ID is
+# `premium'.
+# ---------------------------------------------------------------------
+
+set -u
+
+# EPOCHREALTIME and every printf %f below assume a '.' radix character.
+export LC_ALL=C
+
+CLIENTS=8
+SIZE=65536
+DURATION=10
+MODE=both
+PORT_BASE="${PAIVANA_BENCH_PORT_BASE:-18600}"
+
+# The upstream will not serve more than this from /large/; asking for
+# more silently gets you this much, which would then be reported as the
+# size that was asked for.
+SIZE_MAX=$((10 * 1024 * 1024))
+
+# Prints the comment block above, from the first line of prose to the
+# `# ---' divider. Delimited rather than given as a line range: a line
+# range silently starts printing the wrong thing the first time anyone
+# adds a paragraph up there, and it already had.
+function usage() {
+ awk 'NR < 3 { next } /^# -----/ { exit } { sub(/^# ?/, ""); print }' "$0"
+ exit "${1:-0}"
+}
+
+function die() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+function parse_size() {
+ # Accepts 4096, 64k, 2M (also 64K / 2m).
+ local s="$1" mult=1
+
+ case "$s" in
+ *k | *K) mult=1024; s="${s%[kK]}" ;;
+ *m | *M) mult=$((1024 * 1024)); s="${s%[mM]}" ;;
+ esac
+ case "$s" in
+ '' | *[!0-9]*) die "not a size: $1" ;;
+ esac
+ echo $((s * mult))
+}
+
+while getopts "c:s:d:m:b:h" opt;
+do
+ case "$opt" in
+ c) CLIENTS="$OPTARG" ;;
+ s) SIZE="$(parse_size "$OPTARG")" || exit 1 ;;
+ d) DURATION="$OPTARG" ;;
+ m) MODE="$OPTARG" ;;
+ b) PORT_BASE="$OPTARG" ;;
+ h) usage 0 ;;
+ *) usage 1 ;;
+ esac
+done
+
+case "$CLIENTS" in '' | *[!0-9]*) die "-c wants a count, got '$CLIENTS'" ;; esac
+case "$DURATION" in '' | *[!0-9]*) die "-d wants seconds, got '$DURATION'" ;; esac
+case "$PORT_BASE" in '' | *[!0-9]*) die "-b wants a port, got '$PORT_BASE'" ;; esac
+[ "$CLIENTS" -ge 1 ] || die "-c must be at least 1"
+[ "$DURATION" -ge 1 ] || die "-d must be at least 1"
+[ "$SIZE" -le "$SIZE_MAX" ] ||
+ die "-s is capped at $SIZE_MAX bytes; upstream_rs truncates /large/ there," \
+ "so a larger request would be reported at a size it never served"
+case "$MODE" in
+ direct | proxy | paywall | both | all) ;;
+ *) die "-m wants direct, proxy, paywall, both or all, got '$MODE'" ;;
+esac
+
+# Which arms this run consists of. `both' is direct+proxy, which is
+# what it meant before paywall mode existed and what `make benchmark'
+# still gets; `all' is the three.
+DO_DIRECT=0; DO_PROXY=0; DO_PAYWALL=0
+case "$MODE" in
+ direct) DO_DIRECT=1 ;;
+ proxy) DO_PROXY=1 ;;
+ paywall) DO_PAYWALL=1 ;;
+ both) DO_DIRECT=1; DO_PROXY=1 ;;
+ all) DO_DIRECT=1; DO_PROXY=1; DO_PAYWALL=1 ;;
+esac
+
+# A sanitized build measures the sanitizer. Refusing is more useful
+# than publishing a number that is a factor of several out.
+if [ "${PAIVANA_SANITIZED:-0}" = "1" ];
+then
+ echo "SKIP: this is a sanitizer build; its timings measure ASan," \
+ "not paivana" >&2
+ exit 77
+fi
+
+function here() {
+ cd -- "$(dirname -- "$0")" && pwd
+}
+
+SRCDIR="${SRCDIR:-$(here)}"
+BUILDDIR="${BUILDDIR:-$PWD}"
+
+PAIVANA_HTTPD="${PAIVANA_HTTPD:-$BUILDDIR/../backend/paivana-httpd}"
+if [ ! -x "$PAIVANA_HTTPD" ] && [ -x "$SRCDIR/../backend/paivana-httpd" ];
+then
+ PAIVANA_HTTPD="$SRCDIR/../backend/paivana-httpd"
+fi
+UPSTREAM_RS="$BUILDDIR/upstream_rs"
+MERCHANT_STUB="$BUILDDIR/merchant_stub"
+
+# The template ID merchant_stub serves; also the last path segment of
+# the URL paywall mode measures, so the two have to agree. Overridable
+# only because PAIVANA_BENCH_MERCHANT_URL points at a backend whose
+# template is named by whoever set it up.
+TEMPLATE_ID="${PAIVANA_BENCH_TEMPLATE_ID:-premium}"
+
+# Set from the environment: a live merchant backend to use instead of
+# merchant_stub. Empty means "start the stub".
+MERCHANT_URL="${PAIVANA_BENCH_MERCHANT_URL:-}"
+# Not a credential: it is what the stub is told to expect on the same
+# command line. A real backend's token comes from the environment and
+# is never written to the log or the config we generate for it.
+MERCHANT_TOKEN="${PAIVANA_BENCH_MERCHANT_TOKEN:-secret-token:benchmark-stub}"
+
+if [ "$DO_DIRECT" = 1 ] || [ "$DO_PROXY" = 1 ];
+then
+ if [ ! -x "$UPSTREAM_RS" ];
+ then
+ echo "SKIP: upstream_rs not built (no rustc?), nothing to serve the" \
+ "page these modes measure" >&2
+ exit 77
+ fi
+fi
+if [ "$MODE" != direct ] && [ ! -x "$PAIVANA_HTTPD" ];
+then
+ echo "SKIP: paivana-httpd not found at $PAIVANA_HTTPD" >&2
+ exit 77
+fi
+command -v curl >/dev/null 2>&1 || { echo "SKIP: no curl" >&2; exit 77; }
+
+# What paywall mode needs beyond the above: something to answer
+# paivana's startup template fetch, and the paywall template itself.
+# The latter is generated into the build tree, so a source checkout
+# alone does not have it -- and PAIVANA_PREFIX below stages it rather
+# than requiring `make install', which is what test_paywall.sh does for
+# the same reason.
+PAYWALL_TEMPLATE=""
+if [ "$DO_PAYWALL" = 1 ];
+then
+ if [ -z "$MERCHANT_URL" ] && [ ! -x "$MERCHANT_STUB" ];
+ then
+ echo "SKIP: merchant_stub not built (no rustc?) and no" \
+ "PAIVANA_BENCH_MERCHANT_URL, so the paywall cannot be made active" >&2
+ exit 77
+ fi
+ for cand in "$BUILDDIR/../frontend/paywall.en.must" \
+ "$SRCDIR/../frontend/paywall.en.must";
+ do
+ [ -r "$cand" ] && { PAYWALL_TEMPLATE="$cand"; break; }
+ done
+ if [ -z "$PAYWALL_TEMPLATE" ];
+ then
+ echo "SKIP: the paywall template has not been built, so there is no" \
+ "paywall page to measure" >&2
+ exit 77
+ fi
+fi
+
+RS_PORT=$((PORT_BASE + 1))
+PAIVANA_PORT=$((PORT_BASE + 2))
+MERCHANT_PORT=$((PORT_BASE + 3))
+
+SCRATCH="$(mktemp -d -t paivana-bench.XXXXXX)"
+mkdir -p "$SCRATCH/logs" "$SCRATCH/out"
+
+# Same reason as in test_reverse_proxy.sh: paivana would otherwise pull
+# config.d out of the install prefix rather than the build tree.
+BASE_CONFIG_DIR="$SCRATCH/configd"
+mkdir -p "$BASE_CONFIG_DIR"
+export PAIVANA_BASE_CONFIG="$BASE_CONFIG_DIR"
+
+RS_PID=""
+PAIVANA_PID=""
+MERCHANT_PID=""
+WORKERS=()
+
+function cleanup() {
+ set +e
+ for p in "${WORKERS[@]:-}";
+ do
+ [ -n "$p" ] && kill -KILL "$p" 2>/dev/null
+ done
+ for p in "$PAIVANA_PID" "$RS_PID" "$MERCHANT_PID";
+ do
+ [ -n "$p" ] && kill -TERM "$p" 2>/dev/null
+ done
+ sleep 0.2
+ for p in "$PAIVANA_PID" "$RS_PID" "$MERCHANT_PID";
+ do
+ [ -n "$p" ] && kill -KILL "$p" 2>/dev/null
+ done
+ if [ "${KEEP_TMP:-0}" = "1" ];
+ then
+ echo "Temp files kept in $SCRATCH" >&2
+ else
+ rm -rf "$SCRATCH"
+ fi
+}
+trap cleanup EXIT
+trap 'echo "FAIL: interrupted" >&2; exit 1' INT TERM
+
+# The /dev/tcp probe runs in a subshell on purpose: a failing `exec'
+# redirection kills a non-interactive bash outright, and 2>/dev/null
+# does not save it.
+function port_is_free() {
+ if ( exec 7<>"/dev/tcp/127.0.0.1/$1" ) 2>/dev/null;
+ then
+ return 1
+ fi
+ return 0
+}
+
+function wait_for_port() {
+ local port="$1" pid="$2" tries=50
+
+ while [ "$tries" -gt 0 ];
+ do
+ port_is_free "$port" || return 0
+ kill -0 "$pid" 2>/dev/null || return 1
+ sleep 0.1
+ tries=$((tries - 1))
+ done
+ return 1
+}
+
+PORTS_NEEDED=()
+if [ "$DO_DIRECT" = 1 ] || [ "$DO_PROXY" = 1 ];
+then
+ PORTS_NEEDED+=("$RS_PORT")
+fi
+if [ "$MODE" != direct ];
+then
+ PORTS_NEEDED+=("$PAIVANA_PORT")
+fi
+if [ "$DO_PAYWALL" = 1 ] && [ -z "$MERCHANT_URL" ];
+then
+ PORTS_NEEDED+=("$MERCHANT_PORT")
+fi
+for p in "${PORTS_NEEDED[@]}";
+do
+ port_is_free "$p" && continue
+ echo "SKIP: port $p is already in use; re-run with -b pointing at a" \
+ "free block of three (current base: $PORT_BASE)" >&2
+ exit 77
+done
+
+# Microseconds as an integer. ${EPOCHREALTIME} always has six decimal
+# places, so deleting the radix character is the whole conversion, and
+# it costs no fork -- which matters in the worker loop below, where a
+# $(date) per iteration would be a measurable part of what is being
+# measured.
+function now_us() {
+ local t="${EPOCHREALTIME}"
+ echo "${t/./}"
+}
+
+CLK_TCK="$(getconf CLK_TCK 2>/dev/null || echo 100)"
+
+function cpu_jiffies() {
+ local pid="$1" st
+ local -a f
+
+ if [ -z "$pid" ] || [ ! -r "/proc/$pid/stat" ];
+ then
+ echo 0
+ return 0
+ fi
+ st="$(< "/proc/$pid/stat")" || { echo 0; return 0; }
+ # The comm field is parenthesised and may contain spaces, so it is
+ # cut off rather than counted through. What is left starts at
+ # field 3, which puts utime (14) and stime (15) at 11 and 12.
+ st="${st#*") "}"
+ # shellcheck disable=SC2206 # splitting on whitespace is the point
+ f=($st)
+ echo $(( ${f[11]:-0} + ${f[12]:-0} ))
+}
+
+function start_upstream() {
+ ( exec "$UPSTREAM_RS" "$RS_PORT" ) >"$SCRATCH/logs/rs.log" 2>&1 &
+ RS_PID=$!
+ wait_for_port "$RS_PORT" "$RS_PID" ||
+ { tail -n 20 "$SCRATCH/logs/rs.log" >&2
+ die "upstream_rs did not come up on port $RS_PORT"; }
+}
+
+function start_merchant_stub() {
+ # Only when we are providing the backend ourselves; with
+ # PAIVANA_BENCH_MERCHANT_URL set there is nothing to start.
+ [ -z "$MERCHANT_URL" ] || return 0
+ ( exec "$MERCHANT_STUB" "$MERCHANT_PORT" "$MERCHANT_TOKEN" \
+ ) >"$SCRATCH/logs/merchant.log" 2>&1 &
+ MERCHANT_PID=$!
+ MERCHANT_URL="http://127.0.0.1:$MERCHANT_PORT/"
+ wait_for_port "$MERCHANT_PORT" "$MERCHANT_PID" ||
+ { tail -n 20 "$SCRATCH/logs/merchant.log" >&2
+ die "merchant_stub did not come up on port $MERCHANT_PORT"; }
+}
+
+# $1 = paywall (paywall on, merchant backend configured) or proxy
+# (`-n', no paywall at all). Restarting between the two arms rather
+# than running one daemon is deliberate: `-n' is read once at startup
+# and decides whether the paywall check runs at all, so a single
+# process cannot be both.
+function start_paivana() {
+ local kind="$1"
+ local -a args=(-c "$SCRATCH/paivana.conf" -L ERROR)
+
+ {
+ echo "# Generated by benchmark.sh."
+ echo "[paivana]"
+ # Required even in paywall mode, where nothing is ever
+ # forwarded and so nothing has to be listening there.
+ echo "DESTINATION_BASE_URL = http://127.0.0.1:$RS_PORT/"
+ echo "SERVE = tcp"
+ echo "PORT = $PAIVANA_PORT"
+ echo "BASE_URL = http://localhost:$PAIVANA_PORT/"
+ echo "SECRET = paivana-benchmark"
+ if [ "$kind" = paywall ];
+ then
+ echo "MERCHANT_BACKEND_URL = $MERCHANT_URL"
+ echo "MERCHANT_ACCESS_TOKEN = $MERCHANT_TOKEN"
+ fi
+ } > "$SCRATCH/paivana.conf"
+ # The file names a bearer token, so it is readable by us alone --
+ # $SCRATCH is a mktemp -d, but the token can also come from the
+ # environment and pointing at a real backend must not be a way to
+ # publish its token to everyone on the machine.
+ chmod 600 "$SCRATCH/paivana.conf"
+ if [ "$kind" != paywall ];
+ then
+ args+=(-n)
+ fi
+ ( if [ "$kind" = paywall ]; then export PAIVANA_PREFIX="$SCRATCH/prefix/"; fi
+ exec "$PAIVANA_HTTPD" "${args[@]}" ) >"$SCRATCH/logs/paivana.log" 2>&1 &
+ PAIVANA_PID=$!
+ # In paywall mode this waits out the template fetch as well: the
+ # listen socket is not bound until the backend has answered, so a
+ # port that never opens is as likely to be a merchant problem as a
+ # paivana one, which is why the log tail is printed either way.
+ wait_for_port "$PAIVANA_PORT" "$PAIVANA_PID" ||
+ { tail -n 20 "$SCRATCH/logs/paivana.log" >&2
+ die "paivana-httpd did not come up on port $PAIVANA_PORT"; }
+}
+
+function stop_paivana() {
+ [ -n "$PAIVANA_PID" ] || return 0
+ kill -TERM "$PAIVANA_PID" 2>/dev/null
+ wait "$PAIVANA_PID" 2>/dev/null
+ PAIVANA_PID=""
+}
+
+# A worker hands several URLs to one curl invocation, because a fork
+# per request would put bash's process creation into the measurement.
+# But it only looks at the clock between invocations, so the batch size
+# is also the granularity at which the run can stop -- and the right
+# size cannot be computed up front: how long a batch takes depends on
+# the per-worker request rate, which falls as -c rises and is exactly
+# what is being measured. A size picked from the page size alone
+# overshot a 3 s run by 7% at -c 32 and would have been far worse at
+# -c 200.
+#
+# So the workers converge on it instead: start small, double while a
+# batch comes in under BATCH_LO, halve when it exceeds BATCH_HI. That
+# bounds the overshoot at roughly BATCH_HI no matter what -c and -s
+# are, and costs a handful of extra invocations at the start.
+#
+# The bounds are set by what one curl invocation costs before it sends
+# anything: measured at 12.5 ms on the machine this was written on,
+# which is a lot of dynamic linking (openssl, nghttp2, brotli, zstd,
+# ldap...). Holding a batch to at least BATCH_LO keeps that under
+# about 6% of the batch; at a fixed batch of 8 it was 44%, and the
+# reported rate came out at half the truth.
+BATCH_MAX=256
+BATCH_LO=200000 # us; below this curl's own startup is too large a share
+BATCH_HI=600000 # us; above this the deadline granularity is too coarse
+
+# -w goes to stderr, not stdout, for a reason that is easy to trip
+# over: -o is a *per-URL* option, so with many URLs on one command line
+# only the first body would be redirected and the rest would land in
+# the middle of the statistics. %{stderr} splits the two streams
+# instead, letting stdout be discarded wholesale. stderr is unbuffered,
+# so a line is on disk the moment the transfer ends.
+#
+# -m 30 keeps a hung transfer from wedging a worker until the deadline:
+# the loop only reaches its clock check between invocations.
+CURL_ARGS=(
+ -s
+ -m 30
+ -H 'Connection: close'
+ -w '%{stderr}%{http_code} %{size_download}
+'
+)
+
+function worker() {
+ local out="$1" deadline_us="$2"; shift 2
+ local t t0 dt n=8
+
+ [ "$n" -gt "$BATCH_MAX" ] && n="$BATCH_MAX"
+ while :;
+ do
+ t="${EPOCHREALTIME}"; t0="${t/./}"
+ [ "$t0" -ge "$deadline_us" ] && break
+ curl "${CURL_ARGS[@]}" "${@:1:$n}" >/dev/null 2>>"$out"
+ t="${EPOCHREALTIME}"; dt=$(( ${t/./} - t0 ))
+ if [ "$dt" -lt "$BATCH_LO" ] && [ "$n" -lt "$BATCH_MAX" ];
+ then
+ n=$((n * 2))
+ [ "$n" -gt "$BATCH_MAX" ] && n="$BATCH_MAX"
+ elif [ "$dt" -gt "$BATCH_HI" ] && [ "$n" -gt 1 ];
+ then
+ n=$((n / 2))
+ fi
+ done
+}
+
+# Results of the last run_mode(), for the comparison at the end.
+R_OK=0; R_BAD=0; R_BYTES=0; R_SECS=0; R_RATE=0; R_MBPS=0; R_PAGE=0
+
+function run_mode() {
+ # $1 = label, $2 = URL, $3 = expected status, $4.. = pids whose CPU
+ # time to account
+ local label="$1" url="$2" want_status="$3"; shift 3
+ local pids=("$@")
+ local outdir="$SCRATCH/out/$label"
+ local i start_us end_us deadline_us
+ local -a urls cpu0 cpu1
+
+ rm -rf "$outdir"; mkdir -p "$outdir"
+
+ urls=()
+ for ((i = 0; i < BATCH_MAX; i++));
+ do
+ urls+=("$url")
+ done
+
+ # Warm up: fault in the upstream's page buffer, or render and cache
+ # the paywall page, so that neither is charged to the run. It also
+ # establishes the size every later response is checked against --
+ # measured rather than assumed, because in paywall mode the size is
+ # whatever the template renders to and nothing here knows it up
+ # front.
+ local warm
+ warm="$(curl -s -m 30 -H 'Connection: close' -o /dev/null \
+ -w '%{http_code} %{size_download}' "$url")" ||
+ die "$label: the very first request to $url failed"
+ read -r i R_PAGE <<<"$warm"
+ [ "$i" = "$want_status" ] ||
+ die "$label: $url answered $i, expected $want_status" \
+ "-- benchmarking that would measure the wrong response"
+ [ "$R_PAGE" -gt 0 ] ||
+ die "$label: $url returned an empty body; there is nothing to measure"
+ # Note the redirection rather than -o: -o is per-URL, so with two
+ # URLs it would discard one body and print the other.
+ curl -s -m 30 -H 'Connection: close' "$url" "$url" >/dev/null ||
+ die "$label: the warm-up request to $url failed"
+
+ cpu0=()
+ for p in "${pids[@]}";
+ do
+ cpu0+=("$(cpu_jiffies "$p")")
+ done
+
+ start_us="$(now_us)"
+ deadline_us=$((start_us + DURATION * 1000000))
+ WORKERS=()
+ for ((i = 0; i < CLIENTS; i++));
+ do
+ worker "$outdir/w$i.err" "$deadline_us" "${urls[@]}" &
+ WORKERS+=($!)
+ done
+ for p in "${WORKERS[@]}";
+ do
+ wait "$p"
+ done
+ WORKERS=()
+ end_us="$(now_us)"
+
+ cpu1=()
+ for p in "${pids[@]}";
+ do
+ cpu1+=("$(cpu_jiffies "$p")")
+ done
+
+ local elapsed_us=$((end_us - start_us))
+ local stats
+ stats="$(cat "$outdir"/w*.err 2>/dev/null |
+ awk -v want="$R_PAGE" -v st="$want_status" '
+ {
+ # A short body is as much a failure as a 502: it would
+ # otherwise be counted as a request served and its
+ # missing bytes as throughput not achieved. The status
+ # is the one the warm-up got, so in paywall mode a 402
+ # is the success case and a 200 would be the failure.
+ if (st == $1 && $2 + 0 == want) { ok++; bytes += $2 }
+ else { bad++; codes[$1]++ }
+ }
+ END {
+ brk = ""
+ for (c in codes) brk = brk " " c "=" codes[c]
+ printf "%d %d %d%s\n", ok + 0, bad + 0, bytes + 0, brk
+ }')"
+ local brk
+ read -r R_OK R_BAD R_BYTES brk <<<"$stats"
+
+ R_SECS="$(awk -v u="$elapsed_us" 'BEGIN { printf "%.2f", u / 1000000 }')"
+ # The parentheses around each ternary are load-bearing: inside a
+ # printf argument list awk reads a bare `>' as an output
+ # redirection, not as a comparison.
+ R_RATE="$(awk -v n="$R_OK" -v u="$elapsed_us" \
+ 'BEGIN { printf "%.1f", (u > 0 ? n * 1000000 / u : 0) }')"
+ # bytes per microsecond is already megabytes (10^6) per second.
+ R_MBPS="$(awk -v b="$R_BYTES" -v u="$elapsed_us" \
+ 'BEGIN { printf "%.1f", (u > 0 ? b / u : 0) }')"
+
+ echo
+ echo "$label"
+ # Per-mode rather than only in the banner: the paywall page is
+ # whatever the template renders to and is not the -s size, so a
+ # single "page:" line at the top would be wrong for one of the arms.
+ printf ' page : %s bytes, HTTP %s\n' "$R_PAGE" "$want_status"
+ printf ' requests : %s ok, %s failed%s\n' \
+ "$R_OK" "$R_BAD" "${brk:+ ($brk)}"
+ printf ' elapsed : %s s\n' "$R_SECS"
+ printf ' requests/s : %s\n' "$R_RATE"
+ printf ' throughput : %s MB/s\n' "$R_MBPS"
+
+ local n=0 line=""
+ for p in "${pids[@]}";
+ do
+ local d=$(( ${cpu1[$n]} - ${cpu0[$n]} ))
+ line="$line${line:+, }$(awk -v j="$d" -v hz="$CLK_TCK" -v s="$R_SECS" \
+ -v name="${PIDNAMES[$n]}" \
+ 'BEGIN { printf "%s %.2f s (%.2f cores)", name, j / hz,
+ (s > 0 ? j / hz / s : 0) }')"
+ n=$((n + 1))
+ done
+ printf ' server cpu : %s\n' "$line"
+}
+
+if [ "$DO_DIRECT" = 1 ] || [ "$DO_PROXY" = 1 ];
+then
+ start_upstream
+fi
+
+echo "paivana benchmark"
+if [ "$DO_DIRECT" = 1 ] || [ "$DO_PROXY" = 1 ];
+then
+ printf ' page : %s bytes, /large/%s on upstream_rs (static, no disk)\n' \
+ "$SIZE" "$SIZE"
+fi
+if [ "$DO_PAYWALL" = 1 ];
+then
+ printf ' paywall page : /.well-known/paivana/templates/%s, rendered once\n' \
+ "$TEMPLATE_ID"
+ printf ' and served from paivana'"'"'s own cache; size measured below\n'
+fi
+printf ' clients : %s parallel curl workers, up to %s URLs per invocation,\n' \
+ "$CLIENTS" "$BATCH_MAX"
+printf ' one fresh connection per request in every mode\n'
+printf ' duration : %s s per mode\n' "$DURATION"
+printf ' machine : %s CPUs\n' "$(nproc 2>/dev/null || echo '?')"
+
+FAILED=0
+
+if [ "$DO_DIRECT" = 1 ];
+then
+ PIDNAMES=(upstream_rs)
+ run_mode "direct (curl -> upstream_rs)" \
+ "http://127.0.0.1:$RS_PORT/large/$SIZE" 200 "$RS_PID"
+ [ "$R_PAGE" -eq "$SIZE" ] ||
+ die "upstream_rs served $R_PAGE bytes where $SIZE were asked for"
+ D_RATE="$R_RATE"; D_MBPS="$R_MBPS"; D_OK="$R_OK"
+ [ "$R_BAD" -gt 0 ] && FAILED=1
+fi
+
+if [ "$DO_PROXY" = 1 ];
+then
+ start_paivana proxy
+ PIDNAMES=(paivana-httpd upstream_rs)
+ run_mode "proxy (curl -> paivana-httpd -> upstream_rs)" \
+ "http://127.0.0.1:$PAIVANA_PORT/large/$SIZE" 200 \
+ "$PAIVANA_PID" "$RS_PID"
+ P_RATE="$R_RATE"; P_MBPS="$R_MBPS"; P_OK="$R_OK"
+ [ "$R_BAD" -gt 0 ] && FAILED=1
+ stop_paivana
+fi
+
+if [ "$DO_PAYWALL" = 1 ];
+then
+ # paivana loads the paywall page from its installation prefix, so a
+ # build tree alone is not enough; stage the one file into a prefix
+ # of our own rather than demanding `make install'. Same trick, and
+ # the same PAIVANA_PREFIX, as test_paywall.sh.
+ mkdir -p "$SCRATCH/prefix/share/paivana/templates"
+ cp "$PAYWALL_TEMPLATE" "$SCRATCH/prefix/share/paivana/templates/" ||
+ die "cannot stage $PAYWALL_TEMPLATE"
+ start_merchant_stub
+ start_paivana paywall
+ # upstream_rs is not in this path at all -- the paywall answers
+ # before anything is forwarded -- so it is not accounted, and
+ # merchant_stub is not either: it saw its two requests before the
+ # clock started and is idle from here on. If that is wrong, its
+ # log says so.
+ PIDNAMES=(paivana-httpd)
+ run_mode "paywall (curl -> paivana-httpd, no upstream)" \
+ "http://127.0.0.1:$PAIVANA_PORT/.well-known/paivana/templates/$TEMPLATE_ID" \
+ 402 "$PAIVANA_PID"
+ # No W_MBPS: the comparison below is deliberately requests/s only,
+ # since the two arms serve pages of different sizes.
+ W_RATE="$R_RATE"; W_OK="$R_OK"; W_PAGE="$R_PAGE"
+ [ "$R_BAD" -gt 0 ] && FAILED=1
+ stop_paivana
+fi
+
+if [ "$DO_DIRECT" = 1 ] && [ "$DO_PROXY" = 1 ];
+then
+ echo
+ echo "cost of the reverse proxy"
+ awk -v dr="$D_RATE" -v pr="$P_RATE" -v dm="$D_MBPS" -v pm="$P_MBPS" '
+ BEGIN {
+ printf "%s", " requests/s : " pr " through paivana vs " dr \
+ " direct"
+ if (dr > 0 && pr > 0)
+ printf " -> %.2fx of direct (%.2fx slower)", pr / dr, dr / pr
+ printf "\n"
+ printf " throughput : %s vs %s MB/s\n", pm, dm
+ }'
+ echo
+ echo " Read as a rough figure for this machine, not a verdict. The"
+ echo " direct arm is free to use every core and paivana is"
+ echo " single-threaded, so the ratio depends on the core count. Both"
+ echo " arms are charged a TCP setup and a share of curl's own startup"
+ echo " per request; those are constants on both sides, so they pull the"
+ echo " ratio toward 1 and the proxy's real relative cost is a little"
+ echo " worse than shown, never better. The clients compete with the"
+ echo " servers for this machine, so on a busy one the fastest arm loses"
+ echo " the most and the ratio flatters the proxy further; the core"
+ echo " counts above are what say which of those you got. See the top"
+ echo " of this script."
+fi
+
+if [ "$DO_PAYWALL" = 1 ] && [ "$DO_PROXY" = 1 ];
+then
+ echo
+ echo "cost of the paywall, against the same paivana forwarding"
+ # Requests/s only. The two arms serve different-sized pages -- the
+ # paywall page is the template's size and the proxied one is -s --
+ # so their MB/s are not two measurements of the same thing, and
+ # printing a ratio of them would invite exactly that reading. Both
+ # sizes are shown instead.
+ awk -v wr="$W_RATE" -v pr="$P_RATE" -v wp="$W_PAGE" -v sz="$SIZE" '
+ BEGIN {
+ printf "%s", " requests/s : " wr " turned away at the paywall vs " \
+ pr " forwarded"
+ if (pr > 0 && wr > 0)
+ printf " -> %.2fx", wr / pr
+ printf "\n"
+ printf " page sizes : %d bytes paywall vs %d bytes forwarded\n", wp, sz
+ }'
+ echo
+ echo " The paywall arm has no upstream in the path at all -- paivana"
+ echo " answers out of its own memory -- so the only things bounding it"
+ echo " are its single thread and the clients. Which of the two it is"
+ echo " is in the core count above: if the rate stays flat as -c rises"
+ echo " while paivana sits below 1.0 cores, the thread is spending the"
+ echo " difference waiting on the kernel and more clients will not move"
+ echo " the number. Note also that this page is the one an"
+ echo " unauthenticated client can ask for without limit -- no cookie,"
+ echo " no payment -- which is what makes its rate worth knowing."
+fi
+
+if [ "$FAILED" -ne 0 ];
+then
+ echo
+ echo "FAIL: some requests did not complete; the rates above are not a" \
+ "measurement of throughput" >&2
+ # A code of 000 is curl reporting that the transfer never happened,
+ # and by far the likeliest reason here is the client running out of
+ # ephemeral ports: one connection per request against a fixed
+ # server port means the 4-tuple is pinned for the TIME_WAIT
+ # duration, and a few runs back to back can put tens of thousands
+ # of them there. Worth saying, because it is an exhausted local
+ # port range and not anything paivana did.
+ if grep -qs '^000 ' "$SCRATCH"/out/*/w*.err;
+ then
+ echo "NOTE: transfers that never connected (000) usually mean the" \
+ "local ephemeral port range" \
+ "($(tr '\t' '-' < /proc/sys/net/ipv4/ip_local_port_range \
+ 2>/dev/null))" \
+ "is full of TIME_WAIT sockets" \
+ "($(ss -tan state time-wait 2>/dev/null | wc -l) right now)." \
+ "Wait a minute and re-run." >&2
+ fi
+ exit 1
+fi
+
+if [ "${D_OK:-1}" -eq 0 ] || [ "${P_OK:-1}" -eq 0 ] || [ "${W_OK:-1}" -eq 0 ];
+then
+ echo
+ echo "FAIL: no request completed at all" >&2
+ exit 1
+fi
+
+exit 0
diff --git a/src/tests/merchant_stub.rs b/src/tests/merchant_stub.rs
@@ -0,0 +1,197 @@
+/*
+ This file is part of Paivana.
+ Copyright (C) 2026 Taler Systems SA
+
+ Paivana is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Affero General Public License
+ as published by the Free Software Foundation; either version
+ 3, or (at your option) any later version.
+
+ Paivana is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public
+ License along with Paivana; see the file COPYING. If not,
+ write to the Free Software Foundation, Inc., 51 Franklin
+ Street, Fifth Floor, Boston, MA 02110-1301, USA.
+*/
+
+// merchant_stub: the two GET endpoints of the GNU Taler merchant
+// backend that paivana reads at startup, and nothing else.
+//
+// `benchmark.sh -m paywall' needs paivana running with the paywall
+// *on*, and paivana does not open its listen socket until it has
+// fetched a template: PAIVANA_HTTPD_load_templates() ->
+// check_templates() -> setup_template() -> templates_ready() ->
+// PAIVANA_HTTPD_serve_requests(). So something has to answer those
+// two requests before anything can be measured at all.
+//
+// Nothing after that exchange touches the backend again. The paywall
+// page is rendered locally from the template, and the rendered
+// MHD_Response is then cached per (language, encoding) in
+// load_paywall(), so across the measurement window this process is
+// idle and would be equally idle if it were a real merchant. That is
+// what makes a stub honest here where test_paywall.sh needs the real
+// thing: this benchmark never buys anything, so no code path that
+// distinguishes the two is ever reached. `benchmark.sh' takes
+// PAIVANA_BENCH_MERCHANT_URL for anyone who wants to check that claim
+// against a live backend.
+//
+// The two bodies are shaped as the merchant client library parses
+// them -- merchant_api_get-private-templates.c wants `templates' with
+// `template_id' and `template_description', and
+// merchant_api_get-private-templates-TEMPLATE_ID.c wants
+// `template_description' and an object `template_contract' -- and the
+// contract itself is the one test_paywall.sh POSTs to a real backend,
+// so the page paivana renders here is the page it renders there.
+
+use std::env;
+use std::io::{BufRead, BufReader, Write};
+use std::net::{TcpListener, TcpStream};
+use std::thread;
+
+// The template paivana is expected to find. Fixed rather than
+// configurable: benchmark.sh has to name it in the URL it measures.
+const TEMPLATE_ID: &str = "premium";
+
+fn body_templates() -> String {
+ format!(
+ "{{\"templates\":[{{\"template_id\":\"{}\",\
+ \"template_description\":\"Paywalled content\"}}]}}",
+ TEMPLATE_ID
+ )
+}
+
+fn body_template() -> String {
+ // website_regex `.*' and a single choice, i.e. what
+ // test_paywall.sh creates. One choice rather than several because
+ // that is the shipped shape: with two or more the page grows a
+ // selector (`has_choices'), which would make the measured page
+ // depend on a decision this stub had made.
+ String::from(
+ "{\"template_description\":\"Paywalled content\",\
+ \"template_contract\":{\"template_type\":\"paivana\",\
+ \"summary\":\"Access to the article\",\
+ \"website_regex\":\".*\",\
+ \"max_pickup_duration\":{\"d_us\":3600000000},\
+ \"choices\":[{\"amount\":\"TESTKUDOS:1\",\
+ \"description\":\"One article\"}]}}",
+ )
+}
+
+fn send(stream: &mut TcpStream, code: u16, reason: &str, body: &str) {
+ // `Connection: close' for the same reason upstream_rs sends it
+ // (see its client_loop()): this server answers one request per
+ // connection and then drops the stream, and paivana's merchant
+ // handles sit on a shared curl multi handle that would otherwise
+ // reuse a connection we have already shut.
+ let head = format!(
+ "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\n\
+ Content-Length: {}\r\nConnection: close\r\n\r\n",
+ code,
+ reason,
+ body.len()
+ );
+ let _ = stream.write_all(head.as_bytes());
+ let _ = stream.write_all(body.as_bytes());
+}
+
+fn client_loop(mut stream: TcpStream, token: &str) {
+ let (method, path, authorized) = {
+ let mut br = BufReader::new(&mut stream);
+ let mut line = String::new();
+ if br.read_line(&mut line).unwrap_or(0) == 0 {
+ return;
+ }
+ let mut parts = line.trim_end().splitn(3, ' ');
+ let method = match parts.next() {
+ Some(m) => m.to_string(),
+ None => return,
+ };
+ let path = match parts.next() {
+ Some(p) => p.to_string(),
+ None => return,
+ };
+ let mut authorized = false;
+ loop {
+ let mut h = String::new();
+ if br.read_line(&mut h).unwrap_or(0) == 0 {
+ break;
+ }
+ let t = h.trim_end();
+ if t.is_empty() {
+ break;
+ }
+ if let Some(idx) = t.find(':') {
+ if t[..idx].trim().eq_ignore_ascii_case("Authorization")
+ && t[idx + 1..].trim() == format!("Bearer {}", token)
+ {
+ authorized = true;
+ }
+ }
+ }
+ (method, path, authorized)
+ };
+
+ eprintln!("merchant_stub: {} {}", method, path);
+ // Checked rather than ignored: paivana building the Authorization
+ // header out of MERCHANT_ACCESS_TOKEN is the one thing about this
+ // exchange that could silently regress, and a 401 here makes
+ // paivana say so ("Access to templates unauthorized") instead of
+ // starting anyway and leaving the benchmark to measure a
+ // configuration nobody would deploy.
+ if !authorized {
+ send(&mut stream, 401, "Unauthorized",
+ "{\"code\":2000,\"hint\":\"merchant_stub: no or wrong bearer token\"}");
+ return;
+ }
+ if method != "GET" {
+ send(&mut stream, 405, "Method Not Allowed", "{}");
+ return;
+ }
+ // The path arrives with whatever prefix MERCHANT_BACKEND_URL had;
+ // benchmark.sh points it at the root, so these are exact.
+ if path == "/private/templates" {
+ send(&mut stream, 200, "OK", &body_templates());
+ return;
+ }
+ if path == format!("/private/templates/{}", TEMPLATE_ID) {
+ send(&mut stream, 200, "OK", &body_template());
+ return;
+ }
+ send(&mut stream, 404, "Not Found",
+ "{\"code\":2906,\"hint\":\"merchant_stub: no such endpoint\"}");
+}
+
+fn main() {
+ // Same reasoning as upstream_rs: a port that does not parse must
+ // be an error rather than a silent fallback, or the caller waits
+ // out its readiness timeout on a port nothing ever bound.
+ let port: u16 = match env::args().nth(1) {
+ None => 8405,
+ Some(s) => match s.parse::<u16>() {
+ Ok(p) if p >= 1 => p,
+ _ => {
+ eprintln!("invalid port {:?}", s);
+ std::process::exit(1);
+ }
+ },
+ };
+ let token = env::args().nth(2).unwrap_or_else(|| String::from("secret-token:stub"));
+ // Loopback only: it hands out a merchant configuration to anyone
+ // who asks with the right token, and has no business being
+ // reachable from the network.
+ let listener = TcpListener::bind(("127.0.0.1", port)).expect("bind failed");
+ eprintln!("merchant_stub listening on port {}", port);
+ for stream in listener.incoming() {
+ match stream {
+ Ok(s) => {
+ let token = token.clone();
+ thread::spawn(move || client_loop(s, &token));
+ }
+ Err(_) => continue,
+ }
+ }
+}
diff --git a/src/tests/meson.build b/src/tests/meson.build
@@ -170,6 +170,20 @@ if rustc_bin.found()
build_by_default: true,
)
test_deps += upstream_rs
+
+ # Only benchmark.sh -m paywall uses this, and only to get paivana
+ # past the template fetch it does before it starts serving. Built
+ # here rather than beside it because it needs the same rustc that
+ # `-m paywall' already requires for upstream_rs, so it costs the
+ # benchmark no dependency it did not have.
+ merchant_stub = custom_target(
+ 'merchant_stub',
+ input: 'merchant_stub.rs',
+ output: 'merchant_stub',
+ command: [rustc_bin, '-O', '-o', '@OUTPUT@', '@INPUT@'],
+ build_by_default: true,
+ )
+ test_deps += merchant_stub
endif
# Under `./configure --enable-sanitizers' the whole project is built
@@ -211,3 +225,36 @@ test(
depends: test_deps,
timeout: 900,
)
+
+# What the reverse proxy costs: N curl clients at a fixed-size static
+# page, first straight at upstream_rs and then through paivana in front
+# of it. Registered with benchmark() rather than test() deliberately --
+# `meson test' does not run benchmarks, so this stays out of
+# `make check', where a timing run would contribute nothing but twenty
+# seconds and a result that depends on how loaded the machine is. Run
+# it with `meson test --benchmark proxy_overhead -C build', or call the
+# script directly for the options (-c clients, -s size, -d seconds).
+benchmark(
+ 'proxy_overhead',
+ files('benchmark.sh'),
+ env: test_env,
+ depends: test_deps,
+ timeout: 300,
+)
+
+# The other half of the same script: what paivana costs when it answers
+# by itself, i.e. the 402 paywall page an unpaid client is sent to, with
+# no upstream in the path. A second entry rather than `-m all' on the
+# one above so that the two skip independently -- this arm needs the
+# generated paywall template and merchant_stub, and neither should be
+# able to take the proxy measurement down with it. Its own port base
+# because these two would otherwise collide if anyone ran the benchmarks
+# in parallel, which is not meson's default but is one flag away.
+benchmark(
+ 'paywall_page',
+ files('benchmark.sh'),
+ args: ['-m', 'paywall', '-b', '18610'],
+ env: test_env,
+ depends: test_deps,
+ timeout: 300,
+)
diff --git a/src/tests/upstream_rs.rs b/src/tests/upstream_rs.rs
@@ -26,11 +26,20 @@
use std::env;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
+use std::sync::Arc;
use std::thread;
use std::time::Duration;
const UPSTREAM: &str = "rs";
+// Largest body /large/ will serve. The buffer is built once at
+// startup and sliced per request rather than regenerated: a
+// byte-at-a-time fill of 64 KiB is real work, and benchmark.sh
+// compares this server against paivana in front of it, where anything
+// the origin spends on manufacturing the page is charged to the arm
+// that does not have the proxy in it.
+const LARGE_MAX: usize = 10 * 1024 * 1024;
+
struct Request {
method: String,
path: String,
@@ -103,7 +112,7 @@ fn send_response(stream: &mut TcpStream, code: u16, reason: &str,
let _ = stream.write_all(body);
}
-fn handle(req: &Request, stream: &mut TcpStream) {
+fn handle(req: &Request, stream: &mut TcpStream, large: &[u8]) {
if req.method == "OPTIONS" {
send_response(
stream, 204, "No Content", "text/plain", &[],
@@ -135,12 +144,8 @@ fn handle(req: &Request, stream: &mut TcpStream) {
return;
}
if let Some(rest) = req.path.strip_prefix("/large/") {
- let n: usize = rest.parse().unwrap_or(0).min(10 * 1024 * 1024);
- let mut buf = Vec::with_capacity(n);
- for i in 0..n {
- buf.push(b'A' + ((i % 26) as u8));
- }
- send_response(stream, 200, "OK", "application/octet-stream", &buf, &[]);
+ let n: usize = rest.parse().unwrap_or(0).min(LARGE_MAX);
+ send_response(stream, 200, "OK", "application/octet-stream", &large[..n], &[]);
return;
}
if let Some(rest) = req.path.strip_prefix("/slow/") {
@@ -202,7 +207,7 @@ fn handle(req: &Request, stream: &mut TcpStream) {
send_response(stream, 404, "Not Found", "text/plain", b"not found\n", &[]);
}
-fn client_loop(mut stream: TcpStream) {
+fn client_loop(mut stream: TcpStream, large: &[u8]) {
// We can't easily loop keep-alive with our BufReader pattern without
// ownership gymnastics; handle one request per connection. That is
// why every response above carries `Connection: close': paivana
@@ -215,7 +220,7 @@ fn client_loop(mut stream: TcpStream) {
// the suite reports an intermittent proxy bug that is really this
// server lying about its own connection handling.
if let Some(req) = parse_request(&mut stream) {
- handle(&req, &mut stream);
+ handle(&req, &mut stream, large);
}
}
@@ -233,6 +238,12 @@ fn main() {
}
},
};
+ // Filled before the listener exists: bind() already starts queueing
+ // connections, and the readiness probe the test driver uses is a
+ // successful connect, so a fill after the bind would be time the
+ // driver has been told the server is ready for.
+ let large: Arc<Vec<u8>> =
+ Arc::new((0..LARGE_MAX).map(|i| b'A' + ((i % 26) as u8)).collect());
// Loopback only: this server echoes an arbitrary POST body back
// and hands out 10 MiB on request, and has no business being
// reachable from the network for the duration of `make check'.
@@ -241,7 +252,8 @@ fn main() {
for stream in listener.incoming() {
match stream {
Ok(s) => {
- thread::spawn(move || client_loop(s));
+ let large = Arc::clone(&large);
+ thread::spawn(move || client_loop(s, &large));
}
Err(_) => continue,
}