The loadgen example is the project's end-to-end load generator. It measures
the three numbers that matter for the data path and prints either a
human-readable summary or, with --json, a single line suitable for tracking
across commits.
cargo run --release --example loadgen -- throughput [--connections N] [--payload BYTES]
cargo run --release --example loadgen -- handshakes [--auth none|plain|argon2]
cargo run --release --example loadgen -- udp [--payload BYTES]throughput— N concurrent CONNECT streams pump fixed-size chunks through the proxy to an echo server and back. All streams are established before the measurement window opens, so the result reflects steady-state relaying, not connection setup. Reported as payload bytes per second per direction (both directions carry that rate simultaneously).handshakes— workers repeatedly perform a full connection setup (TCP connect, greeting, optional RFC 1929 authentication (bench/benchpass), CONNECT, teardown) and report completed setups per second plus latency percentiles. Socket-level failures (ephemeral-port exhaustion) are counted separately from SOCKS-level failures so environment limits are not mistaken for proxy verdicts.udp— one UDP ASSOCIATE association; datagrams are blasted at maximum rate through the relay to a UDP echo and counted on return.received_ppsis the sustained round-trip capacity;delivered %shows drop behaviour under overload (an unthrottled sender is expected to overrun the relay).
Common options: --connections, --duration, --payload, --json.
- By default the proxy under test is self-hosted in-process (with an
in-process echo server) so a single command produces a number. Proxy, echo,
and load generator share one tokio runtime, which understates absolute
numbers slightly but keeps runs reproducible. For isolated measurements,
start a release proxy separately on the same host and pass
--proxy ADDR; the generator's echo servers bind to loopback, so a proxy on another machine cannot reach them. - The self-hosted proxy installs a
warn-level tracing subscriber, so per-connectioninfo!events are filtered as in a quietly configured deployment. SetRUST_LOG=infoto include logging costs. - Run scenarios as separate invocations and let the OS recover between
connection-churn runs. On Windows, a handshake run consumes two ephemeral
ports per setup and closed sockets linger in TIME_WAIT; back-to-back runs
starve each other and show up as
socket errors(WSAEADDRINUSE). The harness prints a hint when this happens. --iotimeout SECSsetsiotimeoutin the self-hosted config, to compare relays with and without per-read idle timers.--auth plain|argon2authenticates with the fixed credentialsbench/benchpass. The self-hosted proxy provisions a matching userlist automatically; an externally started--proxyinstance must be configured with that user for authenticated scenarios to succeed.
Environment: 12th Gen Intel Core i7-1260P, 32 GiB RAM, Windows 11 Pro,
rustc 1.96.0, commit bea2c97, self-hosted mode, release profile.
| Scenario | Configuration | Result |
|---|---|---|
| throughput | 1 connection, 64 KiB chunks, 10 s | 237 MiB/s (1.99 Gbit/s) per direction |
| throughput | 8 connections, 64 KiB chunks, 10 s | 951 MiB/s (7.97 Gbit/s) per direction |
| handshakes | auth=none, 32 workers, 3 s | 960/s, p50 33 ms, p95 55 ms |
| handshakes | auth=plain, 32 workers, 5 s | 41.5/s, p50 764 ms, p95 983 ms |
| handshakes | auth=argon2, 32 workers, 5 s | 37.8/s, p50 811 ms, p95 1013 ms |
| udp | 512 B payloads, blast, 10 s | 34,500 pps offered → 6,017 pps round-trip (17.4% delivered) |
| Interpretation against the performance roadmap: |
- Authentication is the dominant control-plane cost: enabling username/password drops connection setup from ~960/s to ~40/s (≈24×) with sub-second p50 setup latency, because every connection pays an Argon2id verification gated four-wide. Plaintext credentials are just as slow as Argon2 entries by design (a dummy Argon2 verification equalises timing), so only a verified-credential cache recovers this, not weaker storage.
- The UDP relay saturates near ~6K round-trip packets/sec under blast load; per-packet allocation, the per-packet rule-hit lock, and per-packet timer churn in the relay loop are the suspected costs to attack first.
- TCP relay throughput scales near-linearly from 1 to 8 streams on this 8-core machine; no contention cliff is visible at this concurrency. Each relayed byte crosses four loopback socket hops in this setup, so absolute numbers are conservative.
When a change lands that targets one of these numbers, re-run the matching scenario with the same parameters and update this table alongside the change.
auth.cachettl (default 300 s) caches successful credential verifications as
keyed tags so repeat handshakes skip the full Argon2 cost. Measured with
handshakes --auth argon2 --connections 32 --duration 5:
| Configuration | Result |
|---|---|
--auth-cachettl 0 (cache disabled) |
86/s, p50 375 ms |
| default cache (300 s) | 1,473/s, p50 17 ms |
With the cache enabled, authenticated connection setup reaches the same OS connection-churn ceiling as the no-auth scenario — authentication is no longer the bottleneck. Uncached runs vary roughly 40–90/s with CPU thermal state; both ends sit far below the cached rate. The cache only stores successes, so failed attempts (brute force, username probing) still pay the full Argon2 cost.
The UDP associate relay previously processed both directions in one
serialized loop, so every relayed packet paid a full recv+send round before
the next event was served, and inbound bursts starved the return path. The
two directions now run as separate tasks. Measured with
udp --duration 5 (512 B payloads, blast):
| Configuration | Result |
|---|---|
| single serialized loop | ~4,800 pps round-trip, ~18.5% delivered |
| concurrent directions | ~19,800 pps round-trip, ~76% delivered |
A payload sweep (64 B vs 4 KiB at near-identical pps) showed the path is
per-packet bound, not bandwidth bound; per-packet allocation and timer
churn fixes alone moved nothing, while direction concurrency yielded ~4×.
The remaining ceiling is one syscall pair per datagram per direction —
batched I/O (recvmmsg/sendmmsg, Windows RIO) is the next step if UDP
packet rate becomes a priority.
Log records were formatted and written synchronously on the data path
through a process-global writer. Measured against an out-of-process release
proxy with file logging (handshakes --proxy … --connections 16 --duration 3):
| Configuration | Result |
|---|---|
sync writer, RUST_LOG=info, file sink |
1,004/s — 37% below the RUST_LOG=warn control (1,593/s) |
async writer, RUST_LOG=info, file sink |
indistinguishable from the control (interleaved pairs: 1,399 vs 1,394/s and 1,700 vs 1,394/s under thermal drift) |
Records are still formatted inline but queued to a dedicated writer thread
(8,192-record queue). A full queue drops records and the writer reports the
running drop count in-band — the data plane never blocks on console or file
I/O. A guard held in main (and the service entry point) flushes the queue
on shutdown.