mini-static 0.31.3

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
//! Benchmarks the HTTP request path end to end — `Server::handle_request`, the same
//! entry the accept loop and the integration tests use.
//!
//! `path_resolution.rs` measures only the resolver. Everything a real response also pays
//! for — opening the file, stat, ETag generation, MIME lookup, sidecar probing, and
//! streaming the body — was unmeasured, which is precisely the budget any future caching
//! work would claim to improve. These four cases establish that baseline:
//!
//! - `small_file_200` — the common case, a 1 KiB file served whole.
//! - `large_file_200` — 1 MiB, exercising `FileBody`'s chunked streaming.
//! - `not_modified_304` — a revalidating client: headers only, no body, but still a
//!   full resolve + stat.
//! - `sidecar_hit_200` — a precompressed `.br` sidecar, which costs extra `open`/stat
//!   syscalls on top of the original file's.
//!
//! Bodies are drained, not dropped: a `Response` is cheap to build and expensive to
//! read, so measuring construction alone would report a number no client experiences.
//!
//! # Baseline
//!
//! Two generations, same Apple-silicon laptop, APFS. In-process `handle_request` medians
//! from criterion; end-to-end figures from `oha` (c=50, one worker, small file) against a
//! same-day nginx (one worker, sendfile on, identical files):
//!
//! | | 0.28.0 (pooled) | 0.30.0 (inline) | nginx same day |
//! |---|---|---|---|
//! | end-to-end small file | 17,800 req/s @ 380% CPU | **45,100 req/s @ 97% CPU** | 54,600 req/s @ 91% CPU |
//! | requests per CPU-second | 4,500 | **46,200** | 59,900 |
//! | p50 latency | 2.7 ms | 1.09 ms | 0.84 ms |
//!
//! The 0.28 profile measured here (304 at 37.9 µs, small file 56.2 µs) already showed
//! two thirds of a response spent before any byte was read; what it could not show was
//! that most of that was *dispatch*, not syscalls — resolution via `spawn_blocking`,
//! `tokio::fs` opens (a pool round trip each), a pool trip per 64 KiB body chunk, and
//! two pooled sidecar probes per request. Inlining all four (bodies of one chunk or
//! less are read from the already-open handle) took per-CPU efficiency from 8% of
//! nginx's to ~77%, with zero behavioral change.
//!
//! # Against a real site
//!
//! Synthetic fixtures miss configuration-dependent paths. Measured against an actual
//! site (release binary, real content, spa-mode on, `oha` c=50, one worker):
//!
//! | page | 0.29.0 | 0.31.3 |
//! |---|---|---|
//! | `/` (spa-injected HTML) | 25,100 req/s @ 385% CPU | **40,100 @ 98%** |
//! | `/styles.css` | 20,200 req/s @ 358% CPU | **54,100 @ 98%** |
//!
//! The first real-site run is what caught the last blocking-pool user: `/` sat at 152%
//! CPU while `/styles.css` was at 97%, because HTML injection still read whole pages
//! through `tokio::fs` before splicing its script. No fixture here enables spa-mode, so
//! nothing in this file could have found it. The handle now stays synchronous until a
//! body actually streams — injection and small-body reads happen inline, and only
//! `FileBody` converts to async — which took `/` to 98% alongside everything else.
//!
//! # Multi-worker scaling — measured, and mostly unmeasurable here
//!
//! With a multi-threaded runtime (`WORKERS=n`), same harness: one worker 61k req/s,
//! two workers 79k, four workers 82k — apparently poor scaling, except nginx with 1/2/4
//! worker *processes* lands at 54k/66k/84k with the identical efficiency decay, and two
//! load generators against one server sum to the same ~83k as one. The ceiling is the
//! macOS loopback path, not either server: this laptop cannot carry more requests
//! through localhost regardless of who serves them. Conclusions that remain valid from
//! this box: single-worker comparisons (below), and that mini-static at two workers
//! already saturates the machine. Real multi-core scaling needs real network hardware.
//!
//! The remaining gap to nginx is syscall count and copies: `canonicalize` walks the
//! path each request where nginx has an open-file cache, and nginx writes with
//! `sendfile` where hyper buffers through userspace. Both are future, correctness-
//! sensitive work — argued from these numbers when their time comes.

use std::fs;

use bytes::Bytes;
use criterion::{criterion_group, criterion_main, Criterion};
use http_body_util::BodyExt;
use hyper::{HeaderMap, Method};
use mini_static::{ResponseBody, Server};
use tempfile::TempDir;
use tokio::runtime::Builder;

const SMALL_FILE_BYTES: usize = 1024;
const LARGE_FILE_BYTES: usize = 1024 * 1024;

fn build_fixture_root() -> TempDir {
    let dir = tempfile::tempdir().expect("create fixture tempdir");
    let root = dir.path();

    fs::write(root.join("small.txt"), vec![b'a'; SMALL_FILE_BYTES]).expect("write small fixture");
    fs::write(root.join("large.bin"), vec![b'b'; LARGE_FILE_BYTES]).expect("write large fixture");

    // A sidecar whose bytes differ from the original, so serving the wrong one would
    // show up as a different response size rather than passing silently.
    fs::write(root.join("asset.js"), vec![b'c'; SMALL_FILE_BYTES]).expect("write asset fixture");
    fs::write(root.join("asset.js.br"), vec![b'd'; 256]).expect("write sidecar fixture");

    dir
}

fn headers_with(name: &str, value: &str) -> HeaderMap {
    let mut headers = HeaderMap::new();
    headers.insert(
        hyper::header::HeaderName::from_bytes(name.as_bytes()).expect("valid header name"),
        value.parse().expect("valid header value"),
    );
    headers
}

/// Drive one request to completion, body included, and hand back the collected bytes so
/// the optimizer cannot discard the work.
async fn drain(server: &Server, method: &Method, path: &str, headers: &HeaderMap) -> Bytes {
    let response: hyper::Response<ResponseBody> =
        server.handle_request(method, path, headers).await;
    response
        .into_body()
        .collect()
        .await
        .expect("body collection is infallible")
        .to_bytes()
}

fn bench_handle_request(c: &mut Criterion) {
    // A current-thread runtime: the crate's dev-dependencies enable `rt` but not
    // `rt-multi-thread`, and a single-threaded driver measures the request path itself
    // rather than the scheduler's work-stealing.
    let runtime = Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("build tokio runtime");
    let fixture = build_fixture_root();
    let server = Server::new(fixture.path()).expect("canonicalize fixture root");
    let no_headers = HeaderMap::new();

    // Captured from a real response so the 304 case revalidates against the validator
    // the server actually issues, rather than a hand-written guess at its format.
    let etag = runtime.block_on(async {
        server
            .handle_request(&Method::GET, "/small.txt", &no_headers)
            .await
            .headers()
            .get("ETag")
            .expect("a 200 carries an ETag")
            .to_str()
            .expect("ETag is ASCII")
            .to_string()
    });
    let revalidate = headers_with("if-none-match", &etag);
    let accept_brotli = headers_with("accept-encoding", "br");

    let mut group = c.benchmark_group("handle_request");

    group.bench_function("small_file_200", |b| {
        b.iter(|| runtime.block_on(drain(&server, &Method::GET, "/small.txt", &no_headers)))
    });

    group.bench_function("large_file_200", |b| {
        b.iter(|| runtime.block_on(drain(&server, &Method::GET, "/large.bin", &no_headers)))
    });

    group.bench_function("not_modified_304", |b| {
        b.iter(|| runtime.block_on(drain(&server, &Method::GET, "/small.txt", &revalidate)))
    });

    group.bench_function("sidecar_hit_200", |b| {
        b.iter(|| runtime.block_on(drain(&server, &Method::GET, "/asset.js", &accept_brotli)))
    });

    group.finish();
}

criterion_group!(benches, bench_handle_request);
criterion_main!(benches);