diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index ee9779e..34b0f4c 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -1,6 +1,9 @@ self-hosted-runner: # `celeris-cluster` is the label our ephemeral cluster runners # register themselves with (see .github/actions/cluster-runner-up). - # Declaring it here silences actionlint's runner-label warning. + # `msr1` pins matrix orchestration to that specific node (it is the + # benchmark conductor; see benchmark-tier.yml runs-on). Declaring both + # here silences actionlint's runner-label warning. labels: - celeris-cluster + - msr1 diff --git a/.github/workflows/benchmark-tier.yml b/.github/workflows/benchmark-tier.yml index 178074a..df1863f 100644 --- a/.github/workflows/benchmark-tier.yml +++ b/.github/workflows/benchmark-tier.yml @@ -21,13 +21,34 @@ name: Benchmark Tier # never runs from an untrusted PR surface). on: + schedule: + # Weekly full-grid SATURATION bench (the "fast" profile: rated OFF, 35s/10s, + # ~21.6h — fits the 24h cluster slot). This re-activates the weekly cadence + # that was previously manual-only. Wednesday 04:00 UTC keeps it clear of the + # weekend soak; the matrix-tier-cluster concurrency group serializes anyway. + - cron: "0 4 * * 3" workflow_dispatch: inputs: profile: - description: "full | headline (default: full — every server × every scenario, capability-gated. headline is the ~3h smoke-test opt-in, never the silent default, because users asked repeatedly for \"no missing tests\" and got the curated subset instead.)" + description: "fast (DEFAULT, recommended): full grid, saturation-only (rated OFF), 35s/10s → ~21.6h, fits 24h. | full / headline: ALSO run the rated/SLO sweep — much longer; full needs a raised BENCH_BUDGET (set automatically below) and won't fit 24h." required: false type: string - default: full + default: fast + competitors: + description: "Adapter columns to BENCH (BENCH_COMPETITORS). 'all' (DEFAULT) runs the full grid; a CSV of registry slugs (e.g. 'httpzig,drogon,aspnet-h2') runs ONLY those columns — used to re-run the subset that DNF'd in a prior run without paying for the whole grid. Names must match servers.Registry." + required: false + type: string + default: all + deploy_competitors: + description: "Binary set to BUILD+DEPLOY (DEPLOY_COMPETITORS). 'all' (DEFAULT). For a subset re-run set this to the underlying BINARY names — h2 columns share their h1 sibling's binary, so 'aspnet-h2' needs binary 'aspnet' (e.g. 'httpzig,drogon,aspnet,axum'). Must be the deploy-side names (nativeBuildSpecs/servers dirs), not column slugs." + required: false + type: string + default: all + publish: + description: "Push results to the docs repo (BENCH_PUBLISH). 'true' (DEFAULT) publishes; 'false' produces data only (no docs push, no integrity gate) — use for a subset re-run that you compose + publish manually." + required: false + type: string + default: "true" permissions: contents: read @@ -45,7 +66,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: ./.github/actions/cluster-runner-up with: tailscale-oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} @@ -58,17 +79,38 @@ jobs: matrix: name: benchmark tier (amd64) needs: [setup] - runs-on: [self-hosted, celeris-cluster] - # 72h ceiling: the full profile on one arch is ~19h single-pass, plus + # Orchestrate from msr1 specifically: the ansible control loop + result + # aggregation must NOT share a host with the SUT (bench_target=msa2-server) + # or the loadgen (msa2-client), or its CPU/IO steals from the measurement. + # msr1 (arm64) is unusable as a BENCH target under NIC load (firmware bug + # celeris#312) but is perfectly safe and otherwise-idle as the conductor — + # it just runs ansible over SSH. This keeps the amd64 SUT's numbers clean. + runs-on: [self-hosted, celeris-cluster, msr1] + # 72h ceiling: the full profile on one arch is ~30h single-pass (the + # grid grew with the mid-size payload rows + native h2c columns), plus # Deploy + Cleanup. Well under GitHub's 5-day self-hosted job hard - # limit. This is only a ceiling — the headline profile finishes in - # hours and exits early. BENCH_BUDGET (below) is the real fail-loud - # projection gate. + # limit. This is only a ceiling — the headline profile (same grid, + # shorter window) projects ~21.6h. BENCH_BUDGET (below) is the real + # fail-loud projection gate. timeout-minutes: 4320 env: - BENCH_PROFILE: ${{ github.event.inputs.profile || 'full' }} + BENCH_PROFILE: ${{ github.event.inputs.profile || 'fast' }} + # Column subset to BENCH for a targeted re-run; 'all' on schedule/default. + BENCH_COMPETITORS: ${{ github.event.inputs.competitors || 'all' }} + # Binary/module set to BUILD+DEPLOY. MUST be job-level (not just the + # Deploy step): BenchTier runs an internal auto-deploy when no manifest + # is present (always true right after the prior run's Cleanup), and that + # auto-deploy falls back to BENCH_COMPETITORS — which is column slugs + # (e.g. 'aspnet-h2') that Deploy rejects. Pinning the module names here + # (e.g. 'aspnet') makes both the explicit Deploy step AND the auto-deploy + # use them. h2 columns reuse their h1 sibling's binary, so this set + # differs from BENCH_COMPETITORS. + DEPLOY_COMPETITORS: ${{ github.event.inputs.deploy_competitors || 'all' }} + # 'false' => data-only (no docs push, no integrity gate). Maps to the + # BENCH_PUBLISH=0 the mage targets read; any other value publishes. + BENCH_PUBLISH: ${{ github.event.inputs.publish == 'false' && '0' || '1' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: @@ -115,8 +157,8 @@ jobs: - name: mage Deploy (competitors + db services for the profile) env: - # Bench needs the competitor binaries staged. - DEPLOY_COMPETITORS: all + # DEPLOY_COMPETITORS is set at the job level (above) so the BenchTier + # auto-deploy inherits it too. # driver-* cells in the profile need postgres/redis/memcached. DEPLOY_NEEDS_DBSERVICES: "1" run: mage Deploy @@ -129,11 +171,12 @@ jobs: # Restore "both" once the arm64 host is fixed (and #168 ArchParallel # lands, or the budget/timeout below is re-checked for two arches). BENCH_TARGET: msa2-server - # The full single-pass matrix runs ~19h on one arch — raise the - # fit budget above the 24h weekly default so BenchTier doesn't - # refuse a future larger registry (must stay under the job - # timeout-minutes above). - BENCH_BUDGET: "70h" + # fast/headline fit the 24h cluster slot, so the budget asserts <24h + # (FitWithin fails loudly if the grid ever grows past it). Only the + # rated "full" deep-dive needs the raised ceiling (it runs the rated + # sweep on the whole grid — many hours); both stay under the job + # timeout-minutes above. + BENCH_BUDGET: ${{ github.event.inputs.profile == 'full' && '70h' || '24h' }} DOCS_TOKEN: ${{ secrets.DOCS_DISPATCH_TOKEN }} run: mage BenchTier @@ -156,7 +199,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: ./.github/actions/cluster-runner-down with: tailscale-oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7afd894..c6353a4 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,7 +23,7 @@ jobs: name: go vet + golangci-lint + gofmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: go-version: "1.26.4" diff --git a/.github/workflows/matrix-nightly-tier.yml b/.github/workflows/matrix-nightly-tier.yml index a6215fe..4888257 100644 --- a/.github/workflows/matrix-nightly-tier.yml +++ b/.github/workflows/matrix-nightly-tier.yml @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: ./.github/actions/cluster-runner-up with: tailscale-oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} @@ -50,7 +50,7 @@ jobs: runs-on: [self-hosted, celeris-cluster] timeout-minutes: 180 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: @@ -157,7 +157,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: ./.github/actions/cluster-runner-down with: tailscale-oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} diff --git a/.github/workflows/matrix-pr-tier.yml b/.github/workflows/matrix-pr-tier.yml index 86bed31..007fa30 100644 --- a/.github/workflows/matrix-pr-tier.yml +++ b/.github/workflows/matrix-pr-tier.yml @@ -95,7 +95,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: ./.github/actions/cluster-runner-up with: tailscale-oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} @@ -110,7 +110,7 @@ jobs: runs-on: [self-hosted, celeris-cluster] timeout-minutes: 30 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: @@ -202,7 +202,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: ./.github/actions/cluster-runner-down with: tailscale-oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} diff --git a/.github/workflows/matrix-weekend-tier.yml b/.github/workflows/matrix-weekend-tier.yml index 2c4a3ae..5025996 100644 --- a/.github/workflows/matrix-weekend-tier.yml +++ b/.github/workflows/matrix-weekend-tier.yml @@ -36,7 +36,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: ./.github/actions/cluster-runner-up with: tailscale-oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} @@ -51,7 +51,7 @@ jobs: runs-on: [self-hosted, celeris-cluster] timeout-minutes: 1740 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: @@ -148,7 +148,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: ./.github/actions/cluster-runner-down with: tailscale-oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7d08af1..612d41d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: name: root module runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: go-version: "1.26.4" @@ -45,7 +45,7 @@ jobs: - iris - stdhttp steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: go-version: "1.26.4" diff --git a/.gitignore b/.gitignore index 66f1662..7340ec6 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,13 @@ /servers/elysia/bun.lock /servers/elysia/bun.lockb +# Express (Node.js) competitor adapter. node runs src/ directly (no +# bundle), so only the resolved-deps cache and npm's lockfile stay out +# of git — every cluster deploy does a fresh `npm install` against the +# upstream registry, enforcing the always-latest policy. +/servers/express/node_modules/ +/servers/express/package-lock.json + # Python adapter local-dev artefacts. The cluster builds the venv under # {{ bench_root }}/competitors//.venv via the python ansible role # — both the in-repo .venv and the bytecode caches are dev-only debris. @@ -83,3 +90,18 @@ servers/_docker/**/*.tar /validation/refapp/driver_memcached/driver_memcached /validation/refapp/observability/observability /validation/refapp/static_swagger_proxy/static_swagger_proxy + +# wave-6 native competitor adapters (actix/lithium/starlette/bunraw/httpzig/ +# h2o/uws/fastify/express/vertx/netty + nbio). The cluster builds every +# artefact under bench_root; nothing here should be committed. Generic globs +# so a local validation build never dirties the tree. +/servers/*/target/ +/servers/*/build/ +/servers/*/node_modules/ +/servers/*/dist/ +/servers/*/zig-out/ +/servers/*/.zig-cache/ +/servers/**/__pycache__/ +/servers/*/server +/servers/actix/target/ +/servers/actix/Cargo.lock diff --git a/ansible/deploy.yml b/ansible/deploy.yml index 018dd99..515da67 100644 --- a/ansible/deploy.yml +++ b/ansible/deploy.yml @@ -342,6 +342,11 @@ | selectattr('key', 'in', competitor_list) | selectattr('value.lang', 'equalto', 'cpp') | list | length) > 0 }} + need_c: >- + {{ (competitor_sources | default({}) | dict2items + | selectattr('key', 'in', competitor_list) + | selectattr('value.lang', 'equalto', 'c') + | list | length) > 0 }} need_dotnet: >- {{ (competitor_sources | default({}) | dict2items | selectattr('key', 'in', competitor_list) @@ -362,6 +367,16 @@ | selectattr('key', 'in', competitor_list) | selectattr('value.lang', 'equalto', 'python') | list | length) > 0 }} + need_node: >- + {{ (competitor_sources | default({}) | dict2items + | selectattr('key', 'in', competitor_list) + | selectattr('value.lang', 'equalto', 'node') + | list | length) > 0 }} + need_java: >- + {{ (competitor_sources | default({}) | dict2items + | selectattr('key', 'in', competitor_list) + | selectattr('value.lang', 'equalto', 'java') + | list | length) > 0 }} - name: Install rust toolchain (only when a rust competitor is in scope — incl. hyper) ansible.builtin.include_role: @@ -377,6 +392,13 @@ - inventory_hostname in groups['bench_targets'] - need_cpp | bool + - name: Install c toolchain + libreactor (only when a c competitor is in scope) + ansible.builtin.include_role: + name: c + when: + - inventory_hostname in groups['bench_targets'] + - need_c | bool + - name: Install .NET SDK (only when a dotnet competitor is in scope) ansible.builtin.include_role: name: dotnet @@ -405,6 +427,20 @@ - inventory_hostname in groups['bench_targets'] - need_python | bool + - name: Install node toolchain (only when a node competitor is in scope) + ansible.builtin.include_role: + name: node + when: + - inventory_hostname in groups['bench_targets'] + - need_node | bool + + - name: Install java toolchain (only when a java competitor is in scope) + ansible.builtin.include_role: + name: java + when: + - inventory_hostname in groups['bench_targets'] + - need_java | bool + # ---------- native competitor builds ---------- # For each non-Go competitor, push the source tarball, extract under # {{ bench_root }}/competitors-src//, then run build_cmd inside diff --git a/ansible/roles/c/tasks/main.yml b/ansible/roles/c/tasks/main.yml new file mode 100644 index 0000000..1e366ba --- /dev/null +++ b/ansible/roles/c/tasks/main.yml @@ -0,0 +1,216 @@ +--- +# Native C toolchain + libreactor, for the libreactor adapter. Mirrors the +# cpp/drogon role: a C framework links against system libraries, so the +# cluster's own gcc + apt build-deps are the toolchain, and libreactor is +# compiled from source into a pristine prefix under +# {{ bench_root }}/libreactor. +# +# Pristine accounting (what cleanup.yml reverses): +# - apt build-deps WE installed → installed_packages (apt-purge). We probe +# each with dpkg -s and only request the missing ones, so a node that +# already ships e.g. autoconf/libssl-dev keeps them on cleanup. +# - the libreactor build tree + install prefix → installed_toolchains entry +# { lang: 'c-libreactor', path: {{ bench_root }}/libreactor } (rm -rf). +# The adapter's own build output lives under competitors-src/libreactor +# and is wiped by the bench_root sweep. +# +# Why build libreactor from source: libreactor is not packaged in Ubuntu's +# repos, and adding a PPA would mutate /etc (the pristine rule forbids it). +# A from-source autotools build into a bench_root prefix is the minimally- +# invasive path: only the (already-tracked) generic C apt deps touch the +# system; everything libreactor-specific stays under bench_root. Always-latest +# stable is the newest libreactor git tag at deploy time (mirrors the +# always-latest policy of the other roles). +# +# LIBREACTOR_PREFIX (exported by build_native_competitor.yml for c adapters) +# points the adapter Makefile's -I/-L at this prefix via `make PREFIX=...`. + +- name: c | ensure libreactor prefix root exists + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: '0755' + loop: + - "{{ bench_root }}/libreactor" + - "{{ bench_root }}/libreactor/src" + - "{{ bench_root }}/libreactor/prefix" + +# libreactor's build deps: a C compiler (build-essential → gcc), the +# autotools chain (autoconf/automake/libtool — libreactor ships autogen.sh, +# not a tarball with a pre-generated configure), git (to fetch the source), +# pkg-config, and OpenSSL dev headers (the server module embeds an SSL_CTX +# and #includes , so the static archive references libssl/ +# libcrypto symbols and the adapter links -lssl -lcrypto). Probe each so we +# only apt-install — and therefore only later apt-purge — the missing ones. +# libh2o (the second c adapter, h2o) adds: cmake (its build system), +# zlib + brotli + wslay (the libh2o-evloop static archive references their +# symbols, so the adapter links -lz -lbrotlienc -lbrotlidec -lwslay). These +# are harmless extras on a libreactor-only deploy. +- name: c | detect missing system packages (compiler + autotools + openssl + libh2o deps) + ansible.builtin.shell: | + for pkg in build-essential autoconf automake libtool pkg-config \ + git ca-certificates libssl-dev \ + cmake zlib1g-dev libbrotli-dev libwslay-dev; do + dpkg -s "$pkg" >/dev/null 2>&1 || echo "$pkg" + done + register: c_missing_pkgs + changed_when: false + +- name: c | install missing apt packages + become: true + ansible.builtin.apt: + name: "{{ c_missing_pkgs.stdout_lines }}" + state: present + update_cache: true + when: c_missing_pkgs.stdout_lines | length > 0 + +- name: c | append apt packages to manifest fact + ansible.builtin.set_fact: + installed_packages: "{{ installed_packages + c_missing_pkgs.stdout_lines }}" + when: c_missing_pkgs.stdout_lines | length > 0 + +# Build libreactor from the default branch (master), NOT the newest semver +# tag. The latest tag (v1.0.1) predates the merge of the dynamic primitives +# into the tree: its example/*.c still `#include ` (an external +# libdynamic header) and `make all` builds those examples, so a from-source +# build fails on the missing header. master bundles the dynamic primitives +# (reactor_vector/reactor_pool/…) into libreactor.la and keeps examples in +# DIST_SUBDIRS only (not built by `make all`), so it compiles clean with no +# external deps. We pin to the resolved HEAD commit for reproducibility and +# to mark the build prefix. ls-remote so we never clone history to resolve. +- name: c | resolve libreactor master HEAD commit + ansible.builtin.shell: | + set -euo pipefail + git ls-remote https://github.com/fredrikwidlund/libreactor.git HEAD \ + | cut -f1 | cut -c1-12 + register: libreactor_tag + changed_when: false + +- name: c | record resolved libreactor commit + ansible.builtin.set_fact: + libreactor_version: "{{ libreactor_tag.stdout | trim }}" + +# Idempotency: skip the whole clone+build when the resolved version's static +# archive already exists in the prefix. lib/libreactor.a is the canonical +# "already built" marker the adapter Makefile links against. +- name: c | check whether resolved libreactor is already built + ansible.builtin.stat: + path: "{{ bench_root }}/libreactor/prefix/lib/libreactor.a" + register: libreactor_built + +# Shallow-clone the default branch (master). --depth 1 with no --branch +# fetches the current master tip, which is what we resolved above. +- name: c | shallow-clone libreactor master + ansible.builtin.command: + cmd: >- + git clone --depth 1 + https://github.com/fredrikwidlund/libreactor.git + {{ bench_root }}/libreactor/src/libreactor-{{ libreactor_version }} + creates: "{{ bench_root }}/libreactor/src/libreactor-{{ libreactor_version }}/configure.ac" + when: not libreactor_built.stat.exists + +# autogen + configure + make + make install into the bench_root prefix. +# libreactor is autotools: autogen.sh regenerates configure, then a normal +# --prefix build. The default optimized build is fine; the ADAPTER picks up +# -O3 -flto -march=native from its own Makefile. We do NOT run `make check` +# (it needs libcmocka and is irrelevant to producing the archive). +- name: c | autogen + configure + build + install libreactor into bench_root prefix + environment: + CFLAGS: "-O3 -march=native" + ansible.builtin.shell: | + set -euo pipefail + cd "{{ bench_root }}/libreactor/src/libreactor-{{ libreactor_version }}" + ./autogen.sh + ./configure --prefix="{{ bench_root }}/libreactor/prefix" + make -j"$(nproc)" + make install + args: + executable: /bin/bash + creates: "{{ bench_root }}/libreactor/prefix/lib/libreactor.a" + when: not libreactor_built.stat.exists + +# --------------------------------------------------------------------- +# libh2o (the h2o adapter). Built ONLY when h2o is in the competitor set, +# so a libreactor-only deploy skips this entirely. libh2o-evloop is the +# event-loop variant (no libuv); the adapter links it via pkg-config or +# -I/-L against {{ bench_root }}/h2o/prefix (H2O_PREFIX in +# build_native_competitor.yml). +# --------------------------------------------------------------------- +- name: c | h2o | ensure libh2o roots exist + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: '0755' + loop: + - "{{ bench_root }}/h2o" + - "{{ bench_root }}/h2o/src" + - "{{ bench_root }}/h2o/prefix" + when: "'h2o' in (competitor_list | default([]))" + +- name: c | h2o | check whether libh2o is already built + ansible.builtin.stat: + path: "{{ bench_root }}/h2o/prefix/lib/libh2o-evloop.a" + register: libh2o_built + when: "'h2o' in (competitor_list | default([]))" + +# h2o vendors picotls/quicly/picohttpparser as git submodules — the build +# needs their sources, so the shallow clone MUST recurse submodules. +- name: c | h2o | shallow-clone h2o master (with submodules) + ansible.builtin.command: + cmd: >- + git clone --depth 1 --recurse-submodules --shallow-submodules + https://github.com/h2o/h2o.git + {{ bench_root }}/h2o/src/h2o + creates: "{{ bench_root }}/h2o/src/h2o/CMakeLists.txt" + when: + - "'h2o' in (competitor_list | default([]))" + - not (libh2o_built.stat.exists | default(false)) + +# Build ONLY the libh2o-evloop target (the standalone h2o server needs +# libuv + a full link we don't want). h2o's INSTALL(TARGETS h2o) is +# UNCONDITIONAL, so `cmake --install` would fail on the unbuilt server +# binary — instead we stage exactly what its lib-install rules do: +# the archive, the generated .pc (prefix-correct because CMAKE_INSTALL_PREFIX +# is set), and the public headers from include/ + deps/{picotls,quicly}/include. +- name: c | h2o | cmake build libh2o-evloop + stage into prefix + environment: + CFLAGS: "-O3 -march=native" + ansible.builtin.shell: | + set -euo pipefail + PREFIX="{{ bench_root }}/h2o/prefix" + cd "{{ bench_root }}/h2o/src/h2o" + cmake -S . -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DWITH_MRUBY=off \ + -DCMAKE_INSTALL_PREFIX="$PREFIX" \ + -DCMAKE_INSTALL_LIBDIR=lib + cmake --build build --target libh2o-evloop -j"$(nproc)" + mkdir -p "$PREFIX/lib/pkgconfig" "$PREFIX/include" + cp build/libh2o-evloop.a "$PREFIX/lib/" + cp build/libh2o-evloop.pc "$PREFIX/lib/pkgconfig/" + cp -a include/. "$PREFIX/include/" + cp -a deps/picotls/include/. "$PREFIX/include/" + cp -a deps/quicly/include/. "$PREFIX/include/" + args: + executable: /bin/bash + creates: "{{ bench_root }}/h2o/prefix/lib/libh2o-evloop.a" + when: + - "'h2o' in (competitor_list | default([]))" + - not (libh2o_built.stat.exists | default(false)) + +- name: c | resolve installed gcc version + ansible.builtin.shell: | + gcc --version 2>/dev/null | head -1 || true + register: c_versions + changed_when: false + +- name: c | append toolchain entry to manifest + ansible.builtin.set_fact: + installed_toolchains: >- + {{ installed_toolchains + + [{ 'lang': 'c-libreactor', + 'path': bench_root + '/libreactor', + 'apt_pkgs': c_missing_pkgs.stdout_lines }] }} + fetched_versions: >- + {{ fetched_versions + | combine({ 'c': (c_versions.stdout | trim) + ' / libreactor ' + libreactor_version }) }} diff --git a/ansible/roles/cpp/tasks/main.yml b/ansible/roles/cpp/tasks/main.yml index e1d3c4c..6b94d69 100644 --- a/ansible/roles/cpp/tasks/main.yml +++ b/ansible/roles/cpp/tasks/main.yml @@ -43,11 +43,15 @@ # hashing), c-ares (async DNS), brotli (optional compression). pkg-config # lets drogon's CMake locate them. Probe each so we only apt-install — # and therefore only later apt-purge — the genuinely-missing ones. -- name: cpp | detect missing system packages (compiler + cmake + drogon libs) +# lithium (the second cpp adapter) is header-only but its http_backend +# links Boost.Context (fiber stacks); libboost-context-dev + libboost-dev +# satisfy find_package(Boost COMPONENTS context). +- name: cpp | detect missing system packages (compiler + cmake + drogon/lithium libs) ansible.builtin.shell: | for pkg in build-essential cmake git pkg-config ca-certificates \ libjsoncpp-dev uuid-dev zlib1g-dev libssl-dev \ - libc-ares-dev libbrotli-dev; do + libc-ares-dev libbrotli-dev \ + libboost-context-dev libboost-dev; do dpkg -s "$pkg" >/dev/null 2>&1 || echo "$pkg" done register: cpp_missing_pkgs diff --git a/ansible/roles/java/tasks/build_competitor.yml b/ansible/roles/java/tasks/build_competitor.yml new file mode 100644 index 0000000..8fbb114 --- /dev/null +++ b/ansible/roles/java/tasks/build_competitor.yml @@ -0,0 +1,59 @@ +--- +# Build one java competitor adapter (vert.x / netty) into a runnable +# launcher under {{ bench_root }}/competitors/. Mirrors +# the bun role: build the artifact, render a POSIX-sh launcher, and let +# build_native_competitor.yml's java branch symlink it into competitors/. +# +# Inputs (set by the caller via include_role vars): +# competitor_name the slug used by servers.Registry ("vertx", "netty"). +# competitor_src absolute path to the extracted source dir on the +# bench host — the directory containing pom.xml + src/. +# +# Maven emits a shaded fat jar under target/. We do NOT hard-code its +# name (vertx vs netty use different finalNames): the launcher globs the +# non-"original-" jar in target/ at runtime, which is the shaded artifact. +# +# Per-arch netty epoll: the vert.x pom hard-codes the epoll native +# classifier (linux-x86_64) unless -Dnetty.epoll.classifier overrides it; +# on the arm64 node that would bundle the wrong .so and silently degrade +# to NIO. We pass the arch-correct classifier; netty's pom (os-maven- +# plugin) auto-detects and simply ignores the unused property. +# +# The dep cache lives at {{ bench_root }}/maven-repo (-Dmaven.repo.local), +# NOT ~/.m2, so pristine cleanup wipes it with the rest of bench_root. + +- name: "java | {{ competitor_name }} | resolve netty epoll classifier" + ansible.builtin.set_fact: + netty_epoll_classifier: "{{ 'linux-x86_64' if ansible_architecture == 'x86_64' else ('linux-aarch_64' if ansible_architecture == 'aarch64' else 'linux-x86_64') }}" + +- name: "java | {{ competitor_name }} | mvn package (shaded fat jar)" + environment: + JAVA_HOME: "{{ bench_root }}/java/jdk" + PATH: "{{ bench_root }}/java/jdk/bin:{{ bench_root }}/java/maven/bin:{{ ansible_env.PATH }}" + XDG_CACHE_HOME: "{{ bench_root }}/xdg-cache" + XDG_CONFIG_HOME: "{{ bench_root }}/xdg-config" + ansible.builtin.shell: | + set -euo pipefail + cd "{{ competitor_src }}" + mvn -B -DskipTests \ + -Dmaven.repo.local="{{ bench_root }}/maven-repo" \ + -Dnetty.epoll.classifier="{{ netty_epoll_classifier }}" \ + package + args: + executable: /bin/bash + +# Launcher: exec the bench JDK on the shaded jar, forwarding argv. The +# absolute java path means no JAVA_HOME is needed at run time. `exec` +# makes the JVM the direct process-group member so the runner's group- +# SIGTERM reaches the JVM shutdown hook. The jar glob skips the +# shade-plugin's "original-*.jar" sidecar and picks the fat jar. +- name: "java | {{ competitor_name }} | write java launcher script" + ansible.builtin.copy: + dest: "{{ competitor_src }}/server" + mode: '0755' + content: | + #!/bin/sh + # Auto-generated by ansible/roles/java/tasks/build_competitor.yml. + SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" + JAR="$(ls "$SCRIPT_DIR"/target/*.jar 2>/dev/null | grep -v '/original-' | head -n1)" + exec "{{ bench_root }}/java/jdk/bin/java" -jar "$JAR" "$@" diff --git a/ansible/roles/java/tasks/main.yml b/ansible/roles/java/tasks/main.yml new file mode 100644 index 0000000..59bab82 --- /dev/null +++ b/ansible/roles/java/tasks/main.yml @@ -0,0 +1,121 @@ +--- +# Native JVM toolchain for the java competitor adapters (vert.x, netty): +# Eclipse Temurin 21 LTS (Adoptium) + Apache Maven, both as pristine +# tarballs under {{ bench_root }}/java, mirroring the dotnet/bun pattern. +# Nothing touches $HOME, /etc/apt sources, or the system openjdk — +# pristine cleanup wipes {{ bench_root }}/java entirely. +# +# Temurin HotSpot is self-contained (bundles its own ICU/zlib) so the +# only apt deps are curl + ca-certificates for the HTTPS fetch. +# +# Manifest contract: appends to installed_toolchains an entry +# { lang: 'java', path: {{ bench_root }}/java, apt_pkgs: [...] } +# and records the resolved jdk + maven versions into fetched_versions. +# The maven dep cache lives at {{ bench_root }}/maven-repo (build step), +# which sits under the same path so cleanup removes it too. + +- name: java | ensure java install root exists + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: '0755' + loop: + - "{{ bench_root }}/java" + - "{{ bench_root }}/java/jdk" + - "{{ bench_root }}/java/maven" + +- name: java | detect missing system packages (curl + ca-certificates) + ansible.builtin.shell: | + for pkg in curl ca-certificates; do + dpkg -s "$pkg" >/dev/null 2>&1 || echo "$pkg" + done + register: java_missing_pkgs + changed_when: false + +- name: java | install missing apt packages + become: true + ansible.builtin.apt: + name: "{{ java_missing_pkgs.stdout_lines }}" + state: present + update_cache: true + when: java_missing_pkgs.stdout_lines | length > 0 + +- name: java | append apt packages to manifest fact + ansible.builtin.set_fact: + installed_packages: "{{ installed_packages + java_missing_pkgs.stdout_lines }}" + when: java_missing_pkgs.stdout_lines | length > 0 + +# Adoptium's binary API uses x64 / aarch64. +- name: java | resolve jdk arch token + ansible.builtin.set_fact: + jdk_arch: "{{ 'x64' if ansible_architecture == 'x86_64' else ('aarch64' if ansible_architecture == 'aarch64' else ansible_architecture) }}" + +- name: java | check whether the JDK is already installed + ansible.builtin.stat: + path: "{{ bench_root }}/java/jdk/bin/java" + register: jdk_installed + +# Adoptium's latest-binary endpoint 302-redirects to the newest Temurin +# 21 GA tarball; -L follows it. --strip 1 flattens jdk-21.../{bin,lib,...} +# into {{ bench_root }}/java/jdk. +- name: java | download + extract Temurin 21 (always-latest GA) + ansible.builtin.shell: | + set -euo pipefail + cd "{{ bench_root }}/java/jdk" + curl -fsSL "https://api.adoptium.net/v3/binary/latest/21/ga/linux/{{ jdk_arch }}/jdk/hotspot/normal/eclipse" -o jdk.tar.gz + tar -xzf jdk.tar.gz --strip-components=1 + rm -f jdk.tar.gz + args: + executable: /bin/bash + creates: "{{ bench_root }}/java/jdk/bin/java" + when: not jdk_installed.stat.exists + +- name: java | check whether maven is already installed + ansible.builtin.stat: + path: "{{ bench_root }}/java/maven/bin/mvn" + register: maven_installed + +# Resolve the latest Maven 3.9.x from the Apache dist listing (arch- +# independent — Maven is pure Java). +- name: java | resolve latest maven 3.9.x version + ansible.builtin.shell: | + set -euo pipefail + curl -fsSL https://dlcdn.apache.org/maven/maven-3/ \ + | grep -oE '3\.9\.[0-9]+/' | tr -d '/' | sort -V | tail -1 + register: maven_ver + changed_when: false + when: not maven_installed.stat.exists + +- name: java | download + extract Apache Maven + ansible.builtin.shell: | + set -euo pipefail + cd "{{ bench_root }}/java/maven" + V="{{ maven_ver.stdout | trim }}" + curl -fsSL "https://dlcdn.apache.org/maven/maven-3/${V}/binaries/apache-maven-${V}-bin.tar.gz" -o maven.tar.gz + tar -xzf maven.tar.gz --strip-components=1 + rm -f maven.tar.gz + args: + executable: /bin/bash + creates: "{{ bench_root }}/java/maven/bin/mvn" + when: not maven_installed.stat.exists + +- name: java | resolve installed jdk + maven versions + environment: + JAVA_HOME: "{{ bench_root }}/java/jdk" + PATH: "{{ bench_root }}/java/jdk/bin:{{ bench_root }}/java/maven/bin:{{ ansible_env.PATH }}" + ansible.builtin.shell: | + "{{ bench_root }}/java/jdk/bin/java" -version 2>&1 | head -1 + "{{ bench_root }}/java/maven/bin/mvn" -v 2>/dev/null | head -1 || true + register: java_versions + changed_when: false + +- name: java | append toolchain entry to manifest + ansible.builtin.set_fact: + installed_toolchains: >- + {{ installed_toolchains + + [{ 'lang': 'java', + 'path': bench_root + '/java', + 'apt_pkgs': java_missing_pkgs.stdout_lines }] }} + fetched_versions: >- + {{ fetched_versions + | combine({ 'java': java_versions.stdout_lines | join(' / ') }) }} diff --git a/ansible/roles/node/tasks/build_competitor.yml b/ansible/roles/node/tasks/build_competitor.yml new file mode 100644 index 0000000..44ac300 --- /dev/null +++ b/ansible/roles/node/tasks/build_competitor.yml @@ -0,0 +1,50 @@ +--- +# Build one node competitor adapter into a runnable launcher under +# {{ bench_root }}/competitors/. Mirrors the bun role. +# +# Inputs (set by the caller via include_role vars): +# competitor_name the slug used by servers.Registry (e.g. "uws", +# "fastify", "express"). Becomes the symlink filename +# under {{ bench_root }}/competitors/. +# competitor_src absolute path to the extracted source dir on the +# bench host — the directory containing package.json +# + src/server.js. The shared +# ansible/tasks/build_native_competitor.yml passes +# {{ bench_root }}/competitors-src/ here. +# +# Node adapters run src/server.js directly (no transpile/bundle) — pure +# JS or prebuilt N-API addons (uWebSockets.js). So the only build step is +# `npm install`; the launcher execs node on src/server.js, forwarding argv. +# +# Caches stay under bench_root (npm_config_cache) so pristine cleanup +# wipes everything — no $HOME writes. + +- name: "node | {{ competitor_name }} | npm install (always-latest)" + environment: + PATH: "{{ bench_root }}/node/bin:{{ ansible_env.PATH }}" + npm_config_cache: "{{ bench_root }}/npm-cache" + NODE_ENV: production + XDG_CACHE_HOME: "{{ bench_root }}/xdg-cache" + XDG_CONFIG_HOME: "{{ bench_root }}/xdg-config" + ansible.builtin.shell: | + set -euo pipefail + cd "{{ competitor_src }}" + "{{ bench_root }}/node/bin/npm" install --no-audit --no-fund + args: + executable: /bin/bash + +# The "binary" the runner execs is this launcher, symlinked into +# competitors/ by build_native_competitor.yml's node branch. It +# execs the bench-installed node on src/server.js, forwarding the +# runner's -bind/-engine flags. readlink -f resolves the +# competitors/ symlink so dirname lands on the source dir (where +# src/ + node_modules actually live), not the symlink's parent. +- name: "node | {{ competitor_name }} | write node launcher script" + ansible.builtin.copy: + dest: "{{ competitor_src }}/server" + mode: '0755' + content: | + #!/bin/sh + # Auto-generated by ansible/roles/node/tasks/build_competitor.yml. + SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" + exec "{{ bench_root }}/node/bin/node" "$SCRIPT_DIR/src/server.js" "$@" diff --git a/ansible/roles/node/tasks/main.yml b/ansible/roles/node/tasks/main.yml new file mode 100644 index 0000000..072ae9a --- /dev/null +++ b/ansible/roles/node/tasks/main.yml @@ -0,0 +1,109 @@ +--- +# Native Node.js runtime via the official nodejs.org standalone tarball, +# into {{ bench_root }}/node. Pinned to the latest v24 LTS at deploy time +# (always-latest within the 24.x line). Mirrors the bun/dotnet pristine +# tarball pattern: nothing touches $HOME or the apt nodejs package. +# +# Why v24: uWebSockets.js ships prebuilt N-API addons only for Node +# 22/24/26 (ABI 127/137/147); the loader does a literal +# require('uws___.node'), so the +# bench Node major MUST be one of those or uWS throws at startup. 24 LTS +# (ABI 137) is the safe middle. fastify/express are pure JS and version- +# agnostic, so one shared node serves all three node adapters. +# +# Manifest contract: appends to installed_toolchains an entry +# { lang: 'node', path: , apt_pkgs: [] } +# and records the resolved node version into fetched_versions. + +- name: node | ensure node install root exists + ansible.builtin.file: + path: "{{ bench_root }}/node" + state: directory + mode: '0755' + +# curl to fetch the tarball, xz-utils to extract the .tar.xz. +- name: node | detect missing system packages (curl + xz-utils) + ansible.builtin.shell: | + for pkg in curl xz-utils ca-certificates; do + dpkg -s "$pkg" >/dev/null 2>&1 || echo "$pkg" + done + register: node_missing_pkgs + changed_when: false + +- name: node | install missing apt packages + become: true + ansible.builtin.apt: + name: "{{ node_missing_pkgs.stdout_lines }}" + state: present + update_cache: true + when: node_missing_pkgs.stdout_lines | length > 0 + +- name: node | append apt packages to manifest fact + ansible.builtin.set_fact: + installed_packages: "{{ installed_packages + node_missing_pkgs.stdout_lines }}" + when: node_missing_pkgs.stdout_lines | length > 0 + +# nodejs.org names the tarball node-v-linux-.tar.xz with arch +# in node's own vocabulary (x64 / arm64), NOT uname's (x86_64 / aarch64). +- name: node | resolve node arch token + ansible.builtin.set_fact: + node_arch: "{{ 'x64' if ansible_architecture == 'x86_64' else ('arm64' if ansible_architecture == 'aarch64' else ansible_architecture) }}" + +# Resolve the latest v24 tarball filename from the dist directory listing +# (the latest-v24.x/ alias always points at the newest 24.x). Parsing the +# listing avoids needing jq on the bench host. +- name: node | resolve latest v24 tarball name + ansible.builtin.shell: | + set -euo pipefail + curl -fsSL https://nodejs.org/dist/latest-v24.x/ \ + | grep -oE "node-v24\.[0-9]+\.[0-9]+-linux-{{ node_arch }}\.tar\.xz" \ + | head -1 + register: node_tarball + changed_when: false + +- name: node | fail if no v24 tarball resolved + ansible.builtin.fail: + msg: "node role: could not resolve a node-v24.x linux-{{ node_arch }} tarball from nodejs.org/dist/latest-v24.x/" + when: node_tarball.stdout | trim | length == 0 + +- name: node | record resolved node tarball + ansible.builtin.set_fact: + node_tarball_name: "{{ node_tarball.stdout | trim }}" + +# Idempotency: skip the download+extract when node/bin/node already exists. +- name: node | check whether node is already installed + ansible.builtin.stat: + path: "{{ bench_root }}/node/bin/node" + register: node_installed + +# Download + extract in one shell so the tarball never lingers. --strip 1 +# flattens node-v.../{bin,lib,...} straight into {{ bench_root }}/node. +- name: node | download + extract node (always-latest v24) + ansible.builtin.shell: | + set -euo pipefail + cd "{{ bench_root }}/node" + curl -fsSL "https://nodejs.org/dist/latest-v24.x/{{ node_tarball_name }}" -o node.tar.xz + tar -xJf node.tar.xz --strip-components=1 + rm -f node.tar.xz + args: + executable: /bin/bash + creates: "{{ bench_root }}/node/bin/node" + when: not node_installed.stat.exists + +- name: node | resolve installed node version + environment: + PATH: "{{ bench_root }}/node/bin:{{ ansible_env.PATH }}" + ansible.builtin.command: "{{ bench_root }}/node/bin/node --version" + register: node_version + changed_when: false + +- name: node | append toolchain entry to manifest + ansible.builtin.set_fact: + installed_toolchains: >- + {{ installed_toolchains + + [{ 'lang': 'node', + 'path': bench_root + '/node', + 'apt_pkgs': node_missing_pkgs.stdout_lines }] }} + fetched_versions: >- + {{ fetched_versions + | combine({ 'node': node_version.stdout | trim }) }} diff --git a/ansible/roles/python/templates/launcher.sh.j2 b/ansible/roles/python/templates/launcher.sh.j2 index f960852..e7088e2 100644 --- a/ansible/roles/python/templates/launcher.sh.j2 +++ b/ansible/roles/python/templates/launcher.sh.j2 @@ -2,10 +2,17 @@ # Launcher for the {{ competitor_name }} python adapter. # # The orchestrator (servers.StartAdapter) execs this path with: -# server -bind -# We translate that into a uvicorn invocation under the project venv. +# server -bind [-engine h1|h2c] +# We translate that into a server invocation under the project venv. # -# Performance knobs (always on): +# -engine selects the wire protocol: +# h1 (or absent) → uvicorn fast path (knobs below). +# h2c → HTTP/2 cleartext, prior-knowledge, no TLS. uvicorn +# cannot speak HTTP/2, so we exec `python -m app.server +# -engine h2c`, which serves the same ASGI app under +# hypercorn. See app/server.py / pyproject.toml. +# +# Performance knobs (always on, h1 path): # --loop uvloop — uvloop event loop (uvicorn[standard] dep) # --http httptools — httptools parser (uvicorn[standard] dep) # --no-access-log — log per-request line cost killed @@ -34,6 +41,7 @@ SRC_DIR="{{ competitor_src_path }}" APP_TARGET="{{ competitor_module_target }}" bind="" +engine="h1" while [[ $# -gt 0 ]]; do case "$1" in -bind|--bind) @@ -44,6 +52,14 @@ while [[ $# -gt 0 ]]; do bind="${1#*=}" shift ;; + -engine|--engine) + engine="$2" + shift 2 + ;; + -engine=*|--engine=*) + engine="${1#*=}" + shift + ;; *) shift ;; @@ -54,6 +70,10 @@ if [[ -z "$bind" ]]; then bind="127.0.0.1:8080" fi +if [[ -z "$engine" ]]; then + engine="h1" +fi + # IPv6 literal handling: [::1]:8080 → host=::1 port=8080. if [[ "$bind" =~ ^\[(.+)\]:([0-9]+)$ ]]; then host="${BASH_REMATCH[1]}" @@ -74,6 +94,12 @@ export WATCHFILES_FORCE_POLLING=false # packages doesn't need an editable install. export PYTHONPATH="${SRC_DIR}${PYTHONPATH:+:${PYTHONPATH}}" +# The launcher's external TCP probe (below) is the single source of the +# `ready addr=...` banner for BOTH engines. The h2c path runs +# `app.server`, which would otherwise print its own banner — suppress it +# so multi-banner parsing in the orchestrator stays correct. +export PROBATORIUM_SUPPRESS_READY=1 + workers="$(nproc 2>/dev/null || echo 1)" # Emit `ready addr=...` ONCE after uvicorn binds. Polls the TCP port @@ -95,11 +121,29 @@ ready_probe=$! # parent's exit. disown "$ready_probe" 2>/dev/null || true -exec "${VENV_DIR}/bin/python" -m uvicorn "${APP_TARGET}" \ - --host "${host}" \ - --port "${port}" \ - --workers "${workers}" \ - --loop uvloop \ - --http httptools \ - --no-access-log \ - --log-level warning +case "${engine}" in + h2c) + # HTTP/2 cleartext (prior-knowledge, no TLS) under hypercorn. + # uvicorn cannot speak HTTP/2, so route through the app entry + # point, which selects hypercorn for -engine h2c. Single process + # (hypercorn's asyncio.serve runs one worker); the GET-heavy bench + # contract is the same app regardless of server. + exec "${VENV_DIR}/bin/python" -m app.server \ + -bind "${host}:${port}" \ + -engine h2c + ;; + h1|"") + exec "${VENV_DIR}/bin/python" -m uvicorn "${APP_TARGET}" \ + --host "${host}" \ + --port "${port}" \ + --workers "${workers}" \ + --loop uvloop \ + --http httptools \ + --no-access-log \ + --log-level warning + ;; + *) + echo "fastapi launcher: unknown -engine '${engine}' (want h1|h2c)" >&2 + exit 2 + ;; +esac diff --git a/ansible/tasks/build_native_competitor.yml b/ansible/tasks/build_native_competitor.yml index 9463ccf..1c84729 100644 --- a/ansible/tasks/build_native_competitor.yml +++ b/ansible/tasks/build_native_competitor.yml @@ -2,7 +2,7 @@ # Build a single non-Go competitor adapter natively on the bench host. # # Loop var: comp = { key: , value: { -# lang: rust|cpp|dotnet|zig|bun|python, +# lang: rust|cpp|c|dotnet|zig|bun|python, # tarball: , # build_cmd: , # binary_rel: . Identical shape -# across the four; only the PATH + per-language env vars differ. +# across the five; only the PATH + per-language env vars differ. # hyper (rust) rides the rust path with no new toolchain. # # 2. SCRIPT/launcher langs (bun, python) — special-cased: the language @@ -58,12 +58,12 @@ dest: "{{ bench_root }}/competitors-src/{{ comp.key }}/" remote_src: true - # COMPILED-BINARY dispatch (rust, cpp, dotnet, zig). Every toolchain dir + # COMPILED-BINARY dispatch (rust, cpp, c, dotnet, zig). Every toolchain dir # goes on PATH plus the env vars its build expects. We do NOT touch HOME — # every cache resolves under {{ bench_root }} so cleanup wipes it cleanly. # - # The four langs share ONE build+symlink pair (DRY): build_cmd is the - # per-adapter shell (cargo / cmake / dotnet publish / zig build) carried + # The five langs share ONE build+symlink pair (DRY): build_cmd is the + # per-adapter shell (cargo / cmake / make / dotnet publish / zig build) carried # in competitor_sources[].build_cmd, and binary_rel is the produced # artefact. Only env differs, and the per-language knobs below are # inert for the other langs (cargo ignores DOTNET_ROOT, dotnet ignores @@ -74,6 +74,8 @@ # target-cpu=native; CARGO_TARGET_DIR pins output under bench_root. # cpp — CMAKE_PREFIX_PATH + Drogon_DIR point find_package(Drogon) at # the bench-built libdrogon prefix. CXXFLAGS=-march=native. + # c — LIBREACTOR_PREFIX points the adapter Makefile (-I/-L) at the + # bench-built libreactor prefix (passed via `make PREFIX=...`). # dotnet — DOTNET_ROOT + PATH expose the bench-installed SDK; telemetry # + first-run logo off; NUGET/DOTNET caches under bench_root. # zig — {{ bench_root }}/zig/current on PATH; ZIG_GLOBAL/LOCAL cache @@ -91,6 +93,13 @@ CMAKE_PREFIX_PATH: "{{ bench_root }}/drogon/prefix" Drogon_DIR: "{{ bench_root }}/drogon/prefix/lib/cmake/Drogon" CXXFLAGS: "-march=native" + # c / libreactor — the adapter Makefile reads $LIBREACTOR_PREFIX (via + # `make PREFIX=...`) to point -I/-L at the bench-built libreactor. + LIBREACTOR_PREFIX: "{{ bench_root }}/libreactor/prefix" + # c / h2o — the adapter Makefile prefers the libh2o-evloop.pc on + # PKG_CONFIG_PATH, falling back to -I/-L under $H2O_PREFIX. + H2O_PREFIX: "{{ bench_root }}/h2o/prefix" + PKG_CONFIG_PATH: "{{ bench_root }}/h2o/prefix/lib/pkgconfig" # dotnet DOTNET_ROOT: "{{ bench_root }}/dotnet" DOTNET_CLI_TELEMETRY_OPTOUT: "1" @@ -109,7 +118,7 @@ {{ comp.value.build_cmd | default('true') }} args: executable: /bin/bash - when: comp.value.lang in ['rust', 'cpp', 'dotnet', 'zig'] + when: comp.value.lang in ['rust', 'cpp', 'c', 'dotnet', 'zig'] - name: "{{ comp.key }} | symlink compiled binary into competitors/" ansible.builtin.file: @@ -117,7 +126,7 @@ dest: "{{ bench_root }}/competitors/{{ comp.key }}" state: link force: true - when: comp.value.lang in ['rust', 'cpp', 'dotnet', 'zig'] + when: comp.value.lang in ['rust', 'cpp', 'c', 'dotnet', 'zig'] # Bun adapters: dispatch to the bun role's parameterized # build_competitor task list (bun install + bun build → dist/server + @@ -139,6 +148,45 @@ force: true when: comp.value.lang == 'bun' + # Node adapters (uws/fastify/express): dispatch to the node role's + # build_competitor task list (npm install + launcher). Same launcher- + # symlink shape as bun — the launcher lives at competitors-src//server. + - name: "{{ comp.key }} | build node competitor (npm install + launcher)" + ansible.builtin.include_role: + name: node + tasks_from: build_competitor.yml + vars: + competitor_name: "{{ comp.key }}" + competitor_src: "{{ bench_root }}/competitors-src/{{ comp.key }}" + when: comp.value.lang == 'node' + + - name: "{{ comp.key }} | symlink node launcher into competitors/" + ansible.builtin.file: + src: "{{ bench_root }}/competitors-src/{{ comp.key }}/server" + dest: "{{ bench_root }}/competitors/{{ comp.key }}" + state: link + force: true + when: comp.value.lang == 'node' + + # Java adapters (vertx/netty): dispatch to the java role's build_competitor + # task list (mvn package + launcher). Same launcher-symlink shape as bun. + - name: "{{ comp.key }} | build java competitor (mvn package + launcher)" + ansible.builtin.include_role: + name: java + tasks_from: build_competitor.yml + vars: + competitor_name: "{{ comp.key }}" + competitor_src: "{{ bench_root }}/competitors-src/{{ comp.key }}" + when: comp.value.lang == 'java' + + - name: "{{ comp.key }} | symlink java launcher into competitors/" + ansible.builtin.file: + src: "{{ bench_root }}/competitors-src/{{ comp.key }}/server" + dest: "{{ bench_root }}/competitors/{{ comp.key }}" + state: link + force: true + when: comp.value.lang == 'java' + # Python adapters: dispatch to the python role's parameterized # build_competitor task list (venv + uv install + launcher). No symlink # needed — the launcher lives directly at competitors//server. diff --git a/ansible/tasks/run_bench_cell.yml b/ansible/tasks/run_bench_cell.yml index d9553d3..d9d6dfa 100644 --- a/ansible/tasks/run_bench_cell.yml +++ b/ansible/tasks/run_bench_cell.yml @@ -123,6 +123,23 @@ become: true ansible.builtin.shell: | bp={{ bench_port | default(8080) }} + # Kill the prior respawn supervisor FIRST (pid at a fixed path) so it + # cannot relaunch the SUT we are about to reap. Without this the bounded + # loop would re-bind the port after every sweep pass. + # NB: keep this free-form shell block free of apostrophes and unpaired + # quote chars. ansible split_args tokenizes the entire string (it does + # not honor the leading hash as a comment), so a stray quote anywhere + # fails task loading with an unbalanced-quotes error. + sup_pidfile={{ bench_root }}/.sut-supervisor.pid + if [ -f "$sup_pidfile" ]; then + sup=$(cat "$sup_pidfile" 2>/dev/null) + if [ -n "$sup" ]; then + pkill -TERM -P "$sup" 2>/dev/null; kill -TERM "$sup" 2>/dev/null + sleep 0.3 + pkill -KILL -P "$sup" 2>/dev/null; kill -KILL "$sup" 2>/dev/null + fi + rm -f "$sup_pidfile" + fi pkill -TERM -f "[c]ompetitors/" 2>/dev/null pkill -x server 2>/dev/null for i in $(seq 1 40); do @@ -185,8 +202,27 @@ # in TIME_WAIT/accept queues. Launched under root (become), so both # soft+hard rise; best-effort like -l above. ulimit -n 65535 2>/dev/null || true - nohup ./competitors/{{ cell_bin }} -bind 0.0.0.0:{{ bench_port | default(8080) }} {{ ('-engine ' + cell_engine) if cell_engine else '' }} > {{ cell_dir }}/server.log 2>&1 & - echo $! > {{ cell_dir }}/server.pid + # Bounded respawn supervisor (max 5). A SUT that crashes mid-column + # (e.g. http.zig hitting an unreachable under churn-close) self-heals + # within ~0.5s, so only the crashing scenario is lost — NOT the rest of + # the column (httpzig previously cascaded one crash into 16 server-down + # cells). The cap means a true crash-loop still lands as server-down for + # the runner to record rather than spinning forever. The supervisor pid + # (the subshell) is what teardown kills FIRST — see the stop task — so + # the loop can never re-bind after the column ends. + ENGINE_ARG="{{ ('-engine ' + cell_engine) if cell_engine else '' }}" + ( + n=0 + while [ "$n" -lt 5 ]; do + ./competitors/{{ cell_bin }} -bind 0.0.0.0:{{ bench_port | default(8080) }} $ENGINE_ARG + echo "[respawn] SUT exited rc=$? (attempt $n) at $(date -u +%H:%M:%S)" + n=$((n + 1)) + sleep 0.5 + done + ) > {{ cell_dir }}/server.log 2>&1 & + # Fixed location (not cell_dir) so the NEXT column's generic stop task can + # find + kill this supervisor without knowing this column's cell_dir. + echo $! > {{ bench_root }}/.sut-supervisor.pid disown executable: /bin/bash args: diff --git a/budget/budget.go b/budget/budget.go index 11ded3d..23e592b 100644 --- a/budget/budget.go +++ b/budget/budget.go @@ -11,11 +11,11 @@ // // Two named profiles are curated (#166): // -// - headline: the weekly default — ~15 servers x ~12 scenarios, -// trimmed so the realized (capability-gated) grid stays under 24h -// even with both arches serial (ArchParallel is blocked on #168). -// - full: every server x every scenario (capability-gated), the -// occasional exhaustive sweep. +// - headline: the weekly cadence — the FULL grid (every server x every +// scenario, capability-gated) at a shorter per-cell window so it fits +// 24h single-arch (amd64-only today; ArchParallel is blocked on #168). +// - full: the same full grid at a longer window, the occasional +// exhaustive sweep run as a manual dispatch with a raised budget. // // Rated/SLO sweeps (#156) are an ADDITIVE second pass scoped to a curated // subset, so the expensive rated cost is bounded rather than multiplying @@ -219,33 +219,31 @@ func plural(n int) string { } // ForProfile resolves a BENCH_PROFILE env value into a configured -// Profile. The default (empty / "headline" / unknown) is the FULL -// matrix — every registered server × every scenario, capability-gated — -// so a weekly run is never silently scoped down to a curated subset -// that drops servers or scenarios. "headline" stays available as an -// explicit opt-in for the ~3h smoke-test path (15 servers × 12 -// scenarios, faster turnaround, narrower signal), and is the value -// the docs test / smoke workflows continue to use. The caller may -// still mutate Runs / Arches / ArchParallel before asserting -// FitWithin. +// Profile. Both "headline" and "full" now cover the SAME grid — every +// registered server × every scenario, capability-gated (Globs "*/*") — +// so NEITHER silently drops servers or scenarios. They differ only by the +// per-cell window: "headline" (the weekly cadence) uses a shorter 60s/15s +// window so the whole grid fits the 24h budget single-arch; "full" uses a +// longer 90s/20s window for the occasional exhaustive sweep (over 24h, +// run as a manual dispatch with a raised BENCH_BUDGET). The default (empty +// / unknown) is "full". The caller may still mutate Runs / Arches / +// ArchParallel before asserting FitWithin. // -// History: prior versions defaulted to the headline weekly config -// because it was the only profile that fit the 24h budget with both -// arches serial. That default silently dropped ~85% of the registry -// from the weekly publish (e.g. driver-*, chain-*, tls-*, ws-hub-* -// scenarios, and 16 long-tail servers like chi / drogon / elysia / -// fastapi / hono / iris / ntex / zig_zap). Users repeatedly asked -// for a full benchmark and got the headline subset instead because -// no env was set. The default flip here is the fix; the headline -// profile is now an explicit opt-in, not a silent default. +// History: "headline" used to be a curated ~14-server × 12-scenario +// subset that silently dropped ~85% of the registry (driver-*, chain-*, +// tls-*, ws-hub-*, h2/h2c variants, and the long-tail servers) from the +// weekly publish. That curation is gone — the weekly grid is now the full +// grid, fit under 24h by the window rather than by dropping coverage. func ForProfile(name string) Profile { switch strings.ToLower(strings.TrimSpace(name)) { - case "full", "": + case "fast", "": + return Fast() + case "full": return Full() case "headline": return HeadlineWeekly() default: - return Full() + return Fast() } } diff --git a/budget/budget_test.go b/budget/budget_test.go index eed7746..3c16e8e 100644 --- a/budget/budget_test.go +++ b/budget/budget_test.go @@ -24,13 +24,29 @@ func TestWeeklyConfigFitsBudget(t *testing.T) { // every curated profile MUST carry Runs=1. A profile that ships Runs>1 // would silently re-introduce a multi-pass run. func TestProfilesAreSinglePass(t *testing.T) { - for _, p := range []Profile{HeadlineWeekly(), Full()} { + for _, p := range []Profile{Fast(), HeadlineWeekly(), Full()} { if p.Runs != 1 { t.Errorf("profile %q must be single-pass (Runs=1), got Runs=%d", p.Name, p.Runs) } } } +// TestFastFitsWithin24h pins the routine/weekly invariant: the default +// "fast" profile (full grid, saturation-only, 35s/10s) MUST fit the 24h +// budget. If the registry grows the grid past ~1390 cells this fails loudly +// so we shorten the window (or trim coverage) deliberately rather than +// silently overrunning the weekly cluster slot. +func TestFastFitsWithin24h(t *testing.T) { + p := Fast() + log, ok := p.FitWithin(Budget) + if !ok { + t.Fatalf("fast profile must fit the %v budget; log:\n%s", Budget, log) + } + if p.Rated() != 0 { + t.Errorf("fast profile must be saturation-only (Rated()=0), got %v", p.Rated()) + } +} + // TestFitWithinAcceptsSinglePassThatFits proves FitWithin returns ok for // the single-pass config when it fits, and reports the headroom. func TestFitWithinAcceptsSinglePassThatFits(t *testing.T) { @@ -66,11 +82,16 @@ func TestFitWithinFailsLoudlyWhenSinglePassOverflows(t *testing.T) { } // TestArchParallelHalvesWallClock proves the #168 win is modeled: with -// both arches and ArchParallel, wall-clock is half the serial cost. +// both arches and ArchParallel, wall-clock is half the serial cost. The +// halving only applies at Arches==2, so the test forces both arches +// explicitly — the weekly/full profiles ship Arches:1 today (amd64-only), +// which would otherwise make the toggle a no-op. func TestArchParallelHalvesWallClock(t *testing.T) { serial := HeadlineWeekly() + serial.Arches = 2 serial.ArchParallel = false parallel := HeadlineWeekly() + parallel.Arches = 2 parallel.ArchParallel = true if parallel.WallClock() != serial.WallClock()/2 { t.Fatalf("ArchParallel wall-clock %v != serial/2 %v", @@ -79,11 +100,11 @@ func TestArchParallelHalvesWallClock(t *testing.T) { } // TestForProfileResolves checks the BENCH_PROFILE resolution: known -// names map to their config, unknown/empty fall back to the FULL -// matrix (every server × every scenario, capability-gated). Headline -// is the explicit opt-in for the ~3h smoke-test path, not the silent -// default — see ForProfile's docstring for why this flipped from the -// prior behaviour. +// names map to their config, unknown/empty fall back to "full". Both +// "headline" and "full" cover the same full grid (every server × every +// scenario, capability-gated); they differ only by the per-cell window +// (headline's shorter window fits 24h, full's longer window is the +// exhaustive sweep) — see ForProfile's docstring. func TestForProfileResolves(t *testing.T) { if ForProfile("headline").Name != "headline" { t.Errorf("ForProfile(headline) should resolve headline") @@ -91,12 +112,15 @@ func TestForProfileResolves(t *testing.T) { if ForProfile("full").Name != "full" { t.Errorf("ForProfile(full) should resolve full") } - if ForProfile("").Name != "full" { - t.Errorf("ForProfile(empty) should fall back to full, got %q (a weekly run with no env was being silently scoped down to the headline subset, dropping driver-*, chain-*, tls-*, ws-hub-*, h2/h2c variants, and ~16 long-tail servers)", + if ForProfile("fast").Name != "fast" { + t.Errorf("ForProfile(fast) should resolve fast") + } + if ForProfile("").Name != "fast" { + t.Errorf("ForProfile(empty) should fall back to fast (the full-grid, <24h saturation default), got %q (the fallback must still be a full-coverage */* grid, never a curated subset)", ForProfile("").Name) } - if ForProfile("bogus").Name != "full" { - t.Errorf("ForProfile(unknown) should fall back to full, got %q (an unknown name must not silently downgrade to headline)", ForProfile("bogus").Name) + if ForProfile("bogus").Name != "fast" { + t.Errorf("ForProfile(unknown) should fall back to fast, got %q (an unknown name must not silently downgrade coverage)", ForProfile("bogus").Name) } } @@ -107,13 +131,16 @@ func TestForProfileResolves(t *testing.T) { // headline-scoped report without telling the user. func TestForProfileDefaultHasFullCoverage(t *testing.T) { def := ForProfile("") - if def.Name != "full" { - t.Fatalf("ForProfile(\"\").Name: want %q, got %q (the default must be the full matrix)", - "full", def.Name) + if def.Name != "fast" { + t.Fatalf("ForProfile(\"\").Name: want %q, got %q (the default must be the full-coverage saturation matrix)", + "fast", def.Name) + } + if len(def.Globs) == 0 || def.Globs[0] != "*/*" { + t.Fatalf("default profile must cover the full grid (Globs '*/*'), got %v", def.Globs) } if def.Cells < 400 { - t.Errorf("default profile Cells: want >= 400 (the full matrix is ~520 capability-gated), got %d. "+ - "A value this low means the default was silently scoped down to the headline subset (~150 cells).", + t.Errorf("default profile Cells: want >= 400 (the full matrix is ~800 capability-gated), got %d. "+ + "A value this low means the default was silently scoped down to a curated subset.", def.Cells) } } diff --git a/budget/profiles.go b/budget/profiles.go index 03b11c0..8f814bc 100644 --- a/budget/profiles.go +++ b/budget/profiles.go @@ -11,48 +11,14 @@ import "time" // helper's output must match, so a registry change that blows the budget // surfaces as a failing test rather than a silently-overflowing run. -// HeadlineServers is the curated weekly column set (~14): the four celeris -// engine modes worth comparing, the headline Go competitors, and one -// representative per non-Go language. Drops the -h2 duplicate columns, -// the chi/iris mid-pack routers, and the long-tail competitors -// (drogon / zig_zap / ntex / fastapi / hono / elysia / gorilla_ws) the -// full profile keeps. -var HeadlineServers = []string{ - "celeris-iouring-h1-async", - "celeris-iouring-auto+upg-async", - "celeris-epoll-h1-sync", - "celeris-std-h1", - "stdhttp-h1", - "gin-h1", - "echo-h1", - "fiber-h1", - "fasthttp-h1", - "gnet-h1", - "hertz-h1", - "axum", - "aspnet", - "hyper", -} - -// HeadlineScenarios is the curated weekly row set (~12): the static / -// payload-size / concurrency / mix / chain / streaming scenarios that -// carry the most signal. Capability gating means the streaming + chain -// cells only land on servers that declare those capabilities, so the -// realized count is lower than len(servers) x len(scenarios). -var HeadlineScenarios = []string{ - "get-simple", - "get-json", - "get-json-1k", - "get-json-64k", - "post-4k", - "post-64k", - "get-simple-128c", - "get-simple-1024c", - "auto-mix-111", - "chain-fullstack-get-json", - "ws-echo", - "sse-fanout-128", -} +// The weekly (headline) profile no longer curates a SUBSET of servers or +// scenarios: it runs the FULL grid (every registered server x every +// registered scenario, capability-gated) via the "*/*" cells glob, the same +// coverage as the Full profile — only the per-cell window differs (a shorter +// weekly window that still fits the 24h budget). There is therefore no +// HeadlineServers / HeadlineScenarios list anymore; the SATURATION grid is +// "everything". The RATED sweep stays curated (RatedServers x RatedScenarios) +// because it is the expensive additive dimension — see RatedServers below. // RatedScenarios is the curated rated/SLO subset (#156): the SLO-knee // scenarios where throughput-at-SLO carries the most signal. @@ -81,61 +47,105 @@ var RatedServers = []string{ // the mage-tagged realized-count helper validates against the live // registries. // -// Derivation (headline): 14 servers x 12 scenarios = 168 nominal cells. -// Capability gating drops the streaming cells (ws-echo, sse-fanout-128) -// and the chain cell on servers that don't advertise WebSocket / SSE / -// chain support, plus a handful of payload-size cells inapplicable to a -// given adapter — landing the realized grid near ~140. The constant is -// the conservative pinned figure the workflow runs against; the helper -// fails the build if the live count exceeds it (which would invalidate -// the budget assertion). +// Derivation (headline): the weekly SATURATION grid is now the FULL grid +// (every server x every scenario, capability-gated), so its realized count +// is FullRealizedCells — the only thing that keeps weekly under 24h is the +// shorter per-cell window (see HeadlineWeekly), not a curated subset. The +// rated sweep stays curated, so HeadlineRatedRealizedCells is unchanged. const ( - HeadlineRealizedCells = 150 + HeadlineRealizedCells = FullRealizedCells HeadlineRatedRealizedCells = 24 // 8 rated servers x 3 rated scenarios, capability-gated - // Full profile: every server x every scenario, capability-gated. The - // nominal grid is ~25 columns x 33 rows ~ 825; gating lands it near - // ~520. Pinned conservatively high so the budget test catches an - // overflow even after registry growth. - FullRealizedCells = 520 + // Full profile: every server x every scenario, capability-gated. After + // the mid-size payload rows (get/post-json-8k/16k) and the native h2c + // columns (axum/ntex/hyper/aspnet/fastapi/hono/elysia -h2) landed, the + // nominal grid is ~36 columns x 45 rows ~ 1620; capability gating (the + // streaming / driver / chain / TLS cells, plus the h2c-noupg columns + // skipping every H1 row) lands the realized count near ~800. Pinned + // conservatively high so FitWithin over-projects slightly and a registry + // change that blows the budget fails loudly rather than overflowing the + // run. Recompute with the scheduler's Applicable gate when the registry + // grows again. + FullRealizedCells = 820 FullRatedRealizedCells = 24 ) -// HeadlineWeekly is the exact config the benchmark-tier workflow runs on -// the weekly (non-release) schedule: the curated ~15x12 grid at -// 40s/10s, plus the curated rated subset. The bench ALWAYS runs exactly -// one pass (Runs=1) — multi-pass / back-to-back release runs were removed; -// if more passes are wanted, more benchmarks are scheduled. +// HeadlineWeekly is the config the benchmark-tier workflow runs on the +// weekly (non-release) cadence. It now covers the FULL grid — every +// registered server x every registered scenario, capability-gated (Globs +// "*/*") — so no framework or scenario is silently left out of the weekly +// numbers. The ONLY thing distinguishing it from Full() is a shorter +// per-cell window (60s/15s vs 90s/20s) chosen so the whole grid still fits +// the 24h budget. The bench ALWAYS runs exactly one pass (Runs=1). +// +// Arches is 1: the bench runs amd64-only today (BENCH_TARGET=msa2-server; +// msr1/arm64 is out on a firmware bug, celeris#312), and BenchTier already +// overrides Arches to 1 at runtime for any non-"both" target — pinning 1 +// here makes the static FitWithin projection match what actually runs +// instead of over-projecting a non-existent arm64 pass. If arm64 returns +// (BENCH_TARGET=both), BenchTier sets Arches=2 and FitWithin then aborts the +// full grid against the default 24h budget unless BENCH_BUDGET is raised — +// the correct loud failure, since the full grid x 2 serial arches cannot fit +// 24h until ArchParallel (#168, blocked on loadgen linux/arm64) lands. // -// ArchParallel is false: arm64 loadgen federation (#168) is blocked on -// the loadgen repo shipping linux/arm64, so both arches run serially -// today. The aggressive curation is precisely what keeps the serial -// run under 24h until that win lands. +// Budget: ~820 cells x (15+60+5+12)s x 1 arch = ~20.9h saturation + ~0.7h +// curated rated = ~21.6h < 24h. The rated sweep stays curated (RatedGlobs) +// because it is the expensive additive dimension; expanding it to the full +// grid would blow the budget many times over. func HeadlineWeekly() Profile { return Profile{ - Name: "headline", - Cells: HeadlineRealizedCells, - // Per-cell window is 40s/10s (not the nominal 60s/15s) because the - // two arches run SERIALLY today — arm64 loadgen federation (#168, - // ArchParallel) is blocked on the loadgen repo. At 150 cells x 2 - // serial arches, a 60s window plus the rated pass overflows 24h; - // 40s lands the whole run well under budget. When #168 lands and - // ArchParallel flips on, this can grow back. - Duration: 40 * time.Second, - Warmup: 10 * time.Second, + Name: "headline", + Cells: HeadlineRealizedCells, + Duration: 60 * time.Second, + Warmup: 15 * time.Second, Cooldown: defaultCooldown, Runs: 1, - Arches: 2, + Arches: 1, ArchParallel: false, RatedCells: HeadlineRatedRealizedCells, RatedPasses: 4, RatedDuration: 20 * time.Second, RatedWarmup: 10 * time.Second, - Globs: headlineGlobs(), + Globs: []string{"*/*"}, RatedGlobs: ratedGlobs(), } } +// FastRealizedCells is the live capability-gated saturation cell count of +// the full "*/*" grid (every server × every scenario the scheduler keeps). +// Recompute with `cmd/runner -dry-run -cells '*/*' | grep -c '^run0'` when +// the registry grows; FitWithin uses it to assert the fast profile still +// fits 24h, so an over-large grid fails loudly instead of overrunning. +const FastRealizedCells = 1257 + +// Fast is the DEFAULT routine + weekly profile: the FULL grid (every server +// × every scenario, capability-gated, "*/*") in SATURATION ONLY — no rated +// sweep — at a 35s/10s window so the whole grid fits comfortably under 24h +// on one arch. Saturation gives the headline ceiling (max RPS + tail latency +// at saturation) for every cell; the rated/SLO sweep (4 closed-loop passes +// per cell, the dominant cost) is intentionally OFF here and belongs in a +// separate, scoped dispatch when latency-under-controlled-load is the story. +// +// Budget: 1257 cells × (10+35+5+12)s × 1 arch = ~21.6h saturation, rated=0 +// → ~21.6h < 24h. RatedPasses=0 makes BenchTier skip the rated flag entirely +// (rated OFF for every cell), so this is the cheap, full-breadth mode. +func Fast() Profile { + return Profile{ + Name: "fast", + Cells: FastRealizedCells, + Duration: 35 * time.Second, + Warmup: 10 * time.Second, + Cooldown: defaultCooldown, + Runs: 1, + Arches: 1, + ArchParallel: false, + RatedCells: 0, // rated OFF — saturation-only + RatedPasses: 0, + Globs: []string{"*/*"}, + RatedGlobs: nil, + } +} + // Full is the exhaustive sweep: every server x every scenario at a // slightly longer 90s/20s window, single pass and the rated subset. Far // over the 24h weekly budget with the long window — Full is a manual @@ -149,7 +159,7 @@ func Full() Profile { Warmup: 20 * time.Second, Cooldown: defaultCooldown, Runs: 1, - Arches: 2, + Arches: 1, ArchParallel: false, RatedCells: FullRatedRealizedCells, RatedPasses: 4, @@ -160,15 +170,6 @@ func Full() Profile { } } -// headlineGlobs expands the curated headline scenario x server lists into -// the "/" glob set the runner's -cells filter consumes. -// The runner skips capability-inapplicable pairs, so emitting the full -// cartesian product here is correct — the realized count is the gated -// subset, not len(Globs). -func headlineGlobs() []string { - return crossGlobs(HeadlineScenarios, HeadlineServers) -} - // ratedGlobs expands the curated rated scenario x server subset into its // "/" glob set. func ratedGlobs() []string { diff --git a/cmd/runner/main.go b/cmd/runner/main.go index 463effa..1a49b96 100644 --- a/cmd/runner/main.go +++ b/cmd/runner/main.go @@ -185,8 +185,8 @@ var defaultRatedFractions = []float64{0.25, 0.5, 0.75, 0.9} func DefaultConfig() Config { return Config{ Runs: 5, - Duration: 120 * time.Second, - Warmup: 30 * time.Second, + Duration: 45 * time.Second, + Warmup: 10 * time.Second, Cooldown: 5 * time.Second, Services: "local", Seed: 0, diff --git a/cmd/runner/main_test.go b/cmd/runner/main_test.go index 37c36bf..b9306fa 100644 --- a/cmd/runner/main_test.go +++ b/cmd/runner/main_test.go @@ -22,8 +22,8 @@ func TestParseArgs_Defaults(t *testing.T) { if cfg.Runs != 5 { t.Errorf("Runs = %d, want 5", cfg.Runs) } - if cfg.Duration != 120*time.Second { - t.Errorf("Duration = %v, want 120s", cfg.Duration) + if cfg.Duration != 45*time.Second { + t.Errorf("Duration = %v, want 45s", cfg.Duration) } if cfg.Services != "local" { t.Errorf("Services = %q, want local", cfg.Services) @@ -334,6 +334,40 @@ func TestFeatureSetTLSGating(t *testing.T) { } } +// TestNativeH2cColumnsAreH2cOnly locks the native h2c expansion: every +// "-h2" native column carries Engine "h2c-noupg", which +// featureSetFor must project to HTTP2C=true + HTTP1=false so ONLY the H2 +// scenarios schedule against it (the H1 grid stays on the h1 column). It +// also pins which natives gained an h2c column (the ones whose runtimes can +// actually serve cleartext h2c prior-knowledge) and which did NOT (drogon, +// whose drogon build has no server-side HTTP/2 at all). +func TestNativeH2cColumnsAreH2cOnly(t *testing.T) { + wantH2 := []string{ + "axum-h2", "hyper-h2", "aspnet-h2", + "fastapi-h2", "hono-h2", "elysia-h2", + } + for _, name := range wantH2 { + a, ok := servers.Registry[name] + if !ok { + t.Errorf("expected native h2c column %q in registry", name) + continue + } + fs := featureSetFor(a, false) + if !fs.HTTP2C { + t.Errorf("%s: featureSetFor.HTTP2C = false, want true", name) + } + if fs.HTTP1 { + t.Errorf("%s: featureSetFor.HTTP1 = true, want false (h2c-noupg: H1 rows must skip it)", name) + } + } + // drogon genuinely cannot serve cleartext h2c (no server-side HTTP/2 in + // the drogon build), so there must be NO drogon-h2 column — a registered + // one would only ever DNF. + if _, ok := servers.Registry["drogon-h2"]; ok { + t.Error("drogon-h2 must NOT be registered: drogon has no server-side HTTP/2") + } +} + // TestDefaultRatedFractionsMatchBudgetModel pins the runner's default // rated-sweep step count to budget.DefaultRatedPasses. The ansible // per-column hang guard is sized from budget.ColumnWallClock using that diff --git a/go.mod b/go.mod index b0cdaaa..140e680 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,15 @@ go 1.26.4 require ( github.com/HdrHistogram/hdrhistogram-go v1.2.0 - github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf - github.com/goceleris/loadgen v1.4.8 + github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c + github.com/goceleris/loadgen v1.4.9 github.com/google/gofuzz v1.2.0 github.com/jackc/pgx/v5 v5.10.0 github.com/pierrec/lz4/v4 v4.1.27 github.com/redis/go-redis/v9 v9.20.1 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 golang.org/x/crypto v0.53.0 - modernc.org/sqlite v1.52.0 + modernc.org/sqlite v1.53.0 ) require ( @@ -25,10 +25,10 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect - modernc.org/libc v1.72.3 // indirect + modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index ed4c92b..b99101f 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/HdrHistogram/hdrhistogram-go v1.2.0 h1:XMJkDWuz6bM9Fzy7zORuVFKH7ZJY41G2q8KWhVGkNiY= github.com/HdrHistogram/hdrhistogram-go v1.2.0/go.mod h1:CiIeGiHSd06zjX+FypuEJ5EQ07KKtxZ+8J6hszwVQig= -github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I= -github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= +github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c h1:6Gpm9YYUEQx2T9zMsYolQhr6sjwwGtFitSA0pQsa7a8= +github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -15,8 +15,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/goceleris/loadgen v1.4.8 h1:r162NrVoLxuCQ3IlhWp+nqAgeFikHImlUSagJbP98m8= -github.com/goceleris/loadgen v1.4.8/go.mod h1:BtjUHc0ULnqa2LsSoJNzDdBt05xUx5jajeF6XnJfFJA= +github.com/goceleris/loadgen v1.4.9 h1:Kd/AmLHP520Su3azQ9tCNoc6tsaeEf7Nx8ECr4AdYfg= +github.com/goceleris/loadgen v1.4.9/go.mod h1:Olg2awQufUnRemRlCvFPFL6Ww3byUd+UvZYQAMJm6Co= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= @@ -64,8 +64,8 @@ golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -83,20 +83,20 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= -modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= -modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= -modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= -modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -105,8 +105,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= -modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/mage_bench.go b/mage_bench.go index 2cd7b24..2af254a 100644 --- a/mage_bench.go +++ b/mage_bench.go @@ -49,8 +49,8 @@ import ( // // BENCH_TARGET=both msa2-server | msr1 | both // BENCH_COMPETITORS=all all | ; matches Deploy filter -// BENCH_DURATION=120s per-cell active duration -// BENCH_WARMUP=30s per-cell warmup +// BENCH_DURATION=45s per-cell active duration +// BENCH_WARMUP=10s per-cell warmup // BENCH_CONNECTIONS=256 loadgen concurrent conns // BENCH_CELLS=* cell glob forwarded to the runner's // -cells over "/"; @@ -161,8 +161,8 @@ func Bench() error { return fmt.Errorf("BENCH_TARGET must be msa2-server, msr1, or both (got %q)", target) } competitors := envOrDefault("BENCH_COMPETITORS", "all") - duration := envOrDefault("BENCH_DURATION", "120s") - warmup := envOrDefault("BENCH_WARMUP", "30s") + duration := envOrDefault("BENCH_DURATION", "45s") + warmup := envOrDefault("BENCH_WARMUP", "10s") conns := envOrDefault("BENCH_CONNECTIONS", "256") cells := envOrDefault("BENCH_CELLS", "*") // BENCH_SKIP_FILE is a JSON list of (server, scenario) pairs to @@ -211,10 +211,12 @@ func Bench() error { // full registry. This is the source of truth for which competitors the // bench will produce non-empty data for — the playbook's outer loop must // iterate exactly this set, otherwise the bench wastes time on columns - // whose (server, scenario) cell glob is empty (regression: profile=headline - // glob is 15 servers, but `competitors` defaults to "all" = 31 → 16 no-op - // columns of wasted ansible outer-loop overhead per back-to-back - // iteration). Returns nil (→ use full registry) for cells == "*" / "". + // whose (server, scenario) cell glob is empty (regression: a narrow + // BENCH_CELLS like "get-*/celeris-*" matches only the celeris columns, + // but `competitors` defaults to "all" = the full registry → every other + // column is a no-op of wasted ansible outer-loop overhead per pass). The + // weekly/full profiles both use "*/*" so they correctly derive the whole + // registry here. Returns nil (→ use full registry) for cells == "*" / "". // // Computed BEFORE the auto-deploy block so the deploy gets the same // trimmed scope the bench will use — installing rust + bun + python @@ -535,6 +537,16 @@ type benchColumn struct { // truth. Engine == the registry Adapter.Engine field for Go adapters (the // two are identical by construction); gorilla_ws is the lone Go binary with // no -engine flag, and natives are launched with -bind only. +// engineFlagValue maps a registry Adapter.Engine (the FEATURE-SET tag the +// runner's featureSetFor reads) to the value passed to the SUT's -engine +// flag. The only translation is stripping the "-noupg" suffix: the runner +// needs "h2c-noupg" to gate HTTP1=false, but every adapter's -engine parser +// only knows "h2c" (prior-knowledge h2c). An empty Engine yields "" so the +// playbook omits the flag entirely. +func engineFlagValue(engine string) string { + return strings.TrimSuffix(engine, "-noupg") +} + func resolveBenchColumns(arg string) ([]benchColumn, error) { all := servers.Names() // sorted, stable names := all @@ -572,7 +584,6 @@ func resolveBenchColumns(arg string) ([]benchColumn, error) { // iris/hertz/celeris) or accepts-and-ignores it (gnet/fasthttp/ // fiber), so passing the registry Engine value is always safe there. if col.Bin != "gorilla_ws" { - col.Engine = a.Engine // Strip a "-noupg" suffix from the SUT-facing engine flag. // The registry's Engine field is the FEATURE-SET tag // (cmd/runner/featureSetFor reads it to decide HTTP1 / @@ -590,11 +601,24 @@ func resolveBenchColumns(arg string) ([]benchColumn, error) { // never invoked because the SUT exited immediately on // "unknown -engine h2c-noupg", so the bind gate timed // out and the whole column was skipped. - col.Engine = strings.TrimSuffix(col.Engine, "-noupg") + col.Engine = engineFlagValue(a.Engine) } + } else if nb, ok := a.Bin.(servers.NativeBinary); ok { + // NativeBinary (rust/cpp/dotnet/bun/python). Staged under + // competitors/; an h2c column reuses its h1 sibling's build + // via Bin.BinName (axum-h2 → competitors/axum). The -engine flag + // is passed for every native carrying a registry Engine so the + // adapter selects the right wire protocol AND the run_bench_cell + // port-ownership guard can tell two columns sharing one binary + // apart (it greps "-engine " in the live cmdline). A + // native with no Engine (bun: hono/elysia) passes no flag — the + // guard accepts the language-runtime argv loosely either way. + col.Bin = n + if nb.BinName != "" { + col.Bin = nb.BinName + } + col.Engine = engineFlagValue(a.Engine) } else { - // NativeBinary (rust/cpp/dotnet/zig/bun/python) — staged under - // competitors/, launched with -bind only. col.Bin = n } cols = append(cols, col) @@ -612,9 +636,9 @@ func resolveBenchColumns(arg string) ([]benchColumn, error) { // data" — the bench must pass the resulting set to the playbook's // competitor_set, otherwise the outer ansible loop iterates columns whose // runner invocation finds zero matching cells and exits in ~1m as a no-op -// (regression: profile=headline glob is 15 servers, but the default -// competitors="all" feeds 31 columns → 16 wasted no-ops per back-to-back -// iteration). Keep this in sync with cmd/runner/main.go's filterCells — +// (regression: a narrow BENCH_CELLS would match only a few servers, but the +// default competitors="all" feeds the whole registry → wasted no-op columns +// per pass). Keep this in sync with cmd/runner/main.go's filterCells — // the parser and the include/exclude semantics are deliberately identical // so a glob like "*/celeris-*" produces the same set here and in the // runner. @@ -1302,7 +1326,7 @@ func summarizeCells(cells []cellRecord) (map[string]competitorStats, error) { MedianP99Ns: medianInt(p99[key]), TotalRequests: reqs[key], TotalErrors: errs[key], - Resources: reduceResources(res[key]), + Resources: report.ReduceResources(res[key]), LatencyAtSLO: reduceLatencyAtSLO(rated[key]), } } @@ -1347,68 +1371,6 @@ func reduceLatencyAtSLO(runs [][]ratedPassWire) map[int]int { return slo } -// reduceResources folds a bucket's per-run ResourceStats into one -// representative (#154): each summary scalar is the median across runs -// (so a single GC spike or RSS blip does not skew the headline), and the -// last run's series is kept verbatim as the illustrative trajectory. A -// metric stays null in the result iff it was null in EVERY run, so a -// non-Go competitor keeps goroutine/GC null while RSS/CPU/FD survive. -func reduceResources(runs []*report.ResourceStats) *report.ResourceStats { - if len(runs) == 0 { - return nil - } - out := &report.ResourceStats{Series: runs[len(runs)-1].Series} - out.Summary.PeakRSSBytes = medianIntPtr(collectI64(runs, func(s report.ResourceSummary) *int64 { return s.PeakRSSBytes })) - out.Summary.SteadyRSSBytes = medianIntPtr(collectI64(runs, func(s report.ResourceSummary) *int64 { return s.SteadyRSSBytes })) - out.Summary.GCPauseP99Ns = medianIntPtr(collectI64(runs, func(s report.ResourceSummary) *int64 { return s.GCPauseP99Ns })) - out.Summary.GoroutineHWM = medianIntPtr(collectI64(runs, func(s report.ResourceSummary) *int64 { return s.GoroutineHWM })) - out.Summary.FDHWM = medianIntPtr(collectI64(runs, func(s report.ResourceSummary) *int64 { return s.FDHWM })) - out.Summary.MeanCPUPct = medianFloatPtr(collectF64(runs, func(s report.ResourceSummary) *float64 { return s.MeanCPUPct })) - return out -} - -// collectI64 gathers the non-nil values a selector pulls from each run's -// summary. -func collectI64(runs []*report.ResourceStats, sel func(report.ResourceSummary) *int64) []int64 { - var out []int64 - for _, r := range runs { - if v := sel(r.Summary); v != nil { - out = append(out, *v) - } - } - return out -} - -// collectF64 is collectI64 for float metrics. -func collectF64(runs []*report.ResourceStats, sel func(report.ResourceSummary) *float64) []float64 { - var out []float64 - for _, r := range runs { - if v := sel(r.Summary); v != nil { - out = append(out, *v) - } - } - return out -} - -// medianIntPtr returns the median of xs as a fresh pointer, or nil when -// xs is empty (every run had the metric null). -func medianIntPtr(xs []int64) *int64 { - if len(xs) == 0 { - return nil - } - v := medianInt(xs) - return &v -} - -// medianFloatPtr is medianIntPtr for floats. -func medianFloatPtr(xs []float64) *float64 { - if len(xs) == 0 { - return nil - } - v := medianFloat(xs) - return &v -} - // summaryKey joins a competitor and scenario into the per-bucket key for // the `summary` map. Scenario is tolerated empty (pre-#152 cells) so the // key degrades to the bare competitor rather than a dangling "comp/". @@ -1623,6 +1585,18 @@ func mergeBenchResults(resultsDir, target string, p benchParams) (string, error) cell.Competitor, cell.RunIndex, e.Name(), err) } cr.Samples = append(cr.Samples, res) + // Server-side resource sidecar (#154): the per-cell observer.sqlite + // + cpu.log were reduced into cell.Resources by + // aggregatePerCellResults. Thread it onto the CellResult so + // report.Aggregate reduces it across runs into the typed Document's + // benchmarks[].resources — the merge step used to drop it on the + // floor (CellResult had no Resources field), so every published + // document carried an empty resources map despite the raw payloads + // holding the data. Skip nil so a run without an observer never + // nil-panics ReduceResources. + if cell.Resources != nil { + cr.Resources = append(cr.Resources, cell.Resources) + } // loadgen.Result.Histogram is the V2-compressed HdrHistogram // payload as raw bytes; CellResult wants base64 strings so // report.Aggregate can decode + merge them across runs. @@ -1710,9 +1684,10 @@ func mergeBenchResults(resultsDir, target string, p benchParams) (string, error) // so v1 of #155 synthesises Environment from the known fabric // constants and leaves the sysctl list empty. Capturing the live // sysctls is a cluster-side ansible follow-up. - KernelSysctlsApplied: []string{}, - LoadgenHost: "msa2-client", - Fabric: benchFabric(), + KernelSysctlsApplied: []string{}, + LoadgenHost: "msa2-client", + Fabric: benchFabric(), + FabricLineRateBitsPerSec: benchFabricLineRate(), } doc := report.BuildDocument(report.BuildInput{ @@ -1806,6 +1781,18 @@ func benchFabric() string { return "3-host Tailscale overlay" } +// benchFabricLineRate returns the fabric's theoretical egress ceiling in +// bits/sec for the network-bound annotation (#schema-5.5): 20 Gbps on the +// 2x10G LACP LAN, 0 (unknown) on the Tailscale overlay — where the report +// flags no cell, since the WireGuard overlay's throughput is not a fixed +// line rate worth ranking CPU efficiency against. +func benchFabricLineRate() int64 { + if os.Getenv("CLUSTER_USE_LAN") == "1" { + return 20_000_000_000 + } + return 0 +} + // benchTargetArch maps a bench_target host to its CPU arch for the // HostArchPair tag. msa2-server is amd64; msr1 is arm64. BENCH_TARGET= // both has no single arch, so it reports "multi". diff --git a/mage_bench_cellsglob_test.go b/mage_bench_cellsglob_test.go index 33b272c..71b81e2 100644 --- a/mage_bench_cellsglob_test.go +++ b/mage_bench_cellsglob_test.go @@ -4,43 +4,50 @@ package main import ( "reflect" - "sort" "testing" "github.com/goceleris/probatorium/budget" + "github.com/goceleris/probatorium/servers" ) -// TestCellsGlobServersDerivesFromHeadlineCells pins the regression: when -// the bench is launched via BenchTier with profile=headline, the cells -// glob is the headline 168-cell set (14 servers x 12 scenarios), but -// BENCH_COMPETITORS defaults to "all" (= full registry). The default -// (no explicit user narrowing) must derive competitor_set from the cells -// glob, yielding EXACTLY the 14 HeadlineServers — never the full-column -// registry, never an empty set, never a partial subset. -// -// If this test fails, the v3.5 bug is back: 16 of the 31 columns are -// no-op (the runner's filterCells finds zero matching (server, scenario) -// cells for those servers and exits in ~1m), wasting ~16m of ansible -// outer-loop overhead per back-to-back iteration. +// TestCellsGlobServersDerivesFromHeadlineCells pins that the WEEKLY +// (headline) profile covers the FULL column set. The weekly profile no +// longer curates a subset — it runs every registered server x scenario via +// the "*/*" cells glob — so deriving competitor_set from that glob must +// yield EVERY registered adapter, never a narrowed subset and never an +// empty set. Guards two ways the weekly run could silently lose coverage: +// the v3.5 no-op-column regression (a glob that drops servers), and any +// future re-curation that quietly shrinks the weekly grid back to a +// "headline" subset — which the user explicitly does not want. func TestCellsGlobServersDerivesFromHeadlineCells(t *testing.T) { + // The weekly profile now runs the FULL grid (Globs "*/*"), so its cells + // glob must derive the COMPLETE server set — every registered adapter, + // the same coverage as the full profile. The old behaviour (a curated + // ~14-server headline subset) is gone: "weekly should include all of + // them, not a headline." This test guards that the weekly never silently + // narrows the column set back to a subset. cells := budget.CellsGlob(budget.HeadlineWeekly()) got, err := cellsGlobServers(cells) if err != nil { t.Fatalf("cellsGlobServers(%q): %v", cells, err) } - want := append([]string{}, budget.HeadlineServers...) - sort.Strings(want) - + want := servers.Names() // every registered adapter, sorted if !reflect.DeepEqual(got, want) { - t.Errorf("cellsGlobServers(HeadlineWeekly) = %v, want %v "+ - "(the default BENCH_COMPETITORS=path must derive competitor_set from "+ - "the cells glob, not from the 31-column full registry)", + t.Errorf("cellsGlobServers(HeadlineWeekly) = %v, want the FULL registry %v "+ + "(the weekly grid must cover every server, never a curated subset)", got, want) } - if len(got) != 14 { - t.Errorf("len: want 14 HeadlineServers, got %d (the cells glob is 12 scenarios "+ - "x 14 servers = 168 cells; the unique-server derivation must yield 14)", len(got)) + + // The weekly and full profiles now derive the same column set — they + // differ only by per-cell window. Pin that equivalence. + fullCells := budget.CellsGlob(budget.Full()) + fullGot, err := cellsGlobServers(fullCells) + if err != nil { + t.Fatalf("cellsGlobServers(full %q): %v", fullCells, err) + } + if !reflect.DeepEqual(got, fullGot) { + t.Errorf("weekly server set %v != full server set %v; they must match now", got, fullGot) } } @@ -146,9 +153,6 @@ func TestCellsGlobServersFullProfileWildcardGlobs(t *testing.T) { if err != nil { t.Fatalf("cellsGlobServers(%q): %v", cells, err) } - want := append([]string{}, budget.HeadlineServers...) // sanity seed - _ = want - // Derive the expected set the same way: every (scenario, server) // pair from the registry, deduplicated to the server half. This // pins the FULL profile's "no missing tests" invariant: if a @@ -181,3 +185,46 @@ func TestCellsGlobServersFullProfileWildcardGlobs(t *testing.T) { } } } + +// TestResolveBenchColumnsNativeH2cSharesBuild pins the native h2c column +// plumbing: an "-h2" native column reuses its h1 sibling's staged +// binary (Bin.BinName → competitors/) and passes the SUT-facing +// -engine value with the feature-set "-noupg" suffix stripped. It also pins +// that the existing h1 native columns now carry an explicit -engine h1 (so +// the run_bench_cell port-ownership guard can tell two columns sharing one +// binary apart). +func TestResolveBenchColumnsNativeH2cSharesBuild(t *testing.T) { + cols, err := resolveBenchColumns("axum,axum-h2,aspnet,aspnet-h2,fastapi,fastapi-h2,hono,hono-h2") + if err != nil { + t.Fatalf("resolveBenchColumns: %v", err) + } + by := map[string]benchColumn{} + for _, c := range cols { + by[c.Slug] = c + } + cases := []struct { + slug, wantBin, wantEngine string + }{ + {"axum", "axum", "h1"}, + {"axum-h2", "axum", "h2c"}, // shares competitors/axum, -engine h2c + {"aspnet", "aspnet", "h1"}, + {"aspnet-h2", "aspnet", "h2c"}, + {"fastapi", "fastapi", "h1"}, + {"fastapi-h2", "fastapi", "h2c"}, // shares the python launcher + {"hono", "hono", ""}, // bun h1 column has no Engine → no flag + {"hono-h2", "hono", "h2c"}, // shares the bun launcher + } + for _, c := range cases { + got, ok := by[c.slug] + if !ok { + t.Errorf("column %q missing from resolveBenchColumns output", c.slug) + continue + } + if got.Bin != c.wantBin { + t.Errorf("%s: Bin = %q, want %q", c.slug, got.Bin, c.wantBin) + } + if got.Engine != c.wantEngine { + t.Errorf("%s: Engine = %q, want %q", c.slug, got.Engine, c.wantEngine) + } + } +} diff --git a/mage_cluster.go b/mage_cluster.go index 59b3153..ad9080f 100644 --- a/mage_cluster.go +++ b/mage_cluster.go @@ -626,6 +626,44 @@ var nativeBuildSpecs = map[string]nativeBuildSpec{ "hono": {lang: "bun"}, "elysia": {lang: "bun"}, "fastapi": {lang: "python", moduleTarget: "app.server:app"}, + + // ---- wave-6 native competitors ---- + "actix": { + lang: "rust", + buildCmd: "cargo build --profile release-fat", + binaryRel: "target/release-fat/probatorium-actix-server", + }, + "starlette": {lang: "python", moduleTarget: "app.server:app"}, + "bunraw": {lang: "bun"}, + "httpzig": { + lang: "zig", + // ReleaseSafe (not ReleaseFast): http.zig's NonBlocking worker hits an + // `unreachable` under connection churn (churn-close) that becomes a + // silent process-killing UB in ReleaseFast; ReleaseSafe makes it a + // recoverable panic instead. This is the actual build the cluster uses. + buildCmd: "zig build -Doptimize=ReleaseSafe", + binaryRel: "zig-out/bin/httpzig", + }, + "lithium": { + lang: "cpp", + buildCmd: "cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j", + binaryRel: "build/lithium-adapter", + }, + // h2o — c role builds libh2o into {bench}/h2o/prefix; the adapter Makefile + // reads $H2O_PREFIX (env in build_native_competitor.yml) for -I/-L. + "h2o": { + lang: "c", + buildCmd: "make H2O_PREFIX=\"$H2O_PREFIX\" CFLAGS_EXTRA=-march=native", + binaryRel: "h2o-adapter", + }, + // node + java are launcher langs (like bun): the role's build_competitor.yml + // owns npm install / mvn package + launcher rendering, so the spec carries + // only lang (buildCmd/binaryRel empty). + "uws": {lang: "node"}, + "fastify": {lang: "node"}, + "express": {lang: "node"}, + "vertx": {lang: "java"}, + "netty": {lang: "java"}, } // selectNativeCompetitors returns the list of non-Go competitor slugs diff --git a/mage_tier.go b/mage_tier.go index 6de2e6b..f78c80e 100644 --- a/mage_tier.go +++ b/mage_tier.go @@ -53,13 +53,14 @@ import ( // // Env knobs (in addition to every BENCH_*/PUBLISH_*/DOCS_TOKEN knob): // -// BENCH_PROFILE=full full | headline (default: full — every -// server × every scenario, capability-gated. -// headline is the explicit opt-in for the -// ~3h smoke-test path; never the silent -// default, because users asked repeatedly -// for "no missing tests" and got the -// curated subset instead.) +// BENCH_PROFILE=full full | headline. Both cover the SAME full +// grid (every server × every scenario, +// capability-gated); they differ ONLY by the +// per-cell window. headline (the weekly +// cadence) uses 60s/15s so the whole grid fits +// 24h single-arch (~21.6h); full uses 90s/20s +// for the exhaustive sweep (~30h on one arch, +// needs a raised BENCH_BUDGET). Default: full. // BENCH_TARGET=both msa2-server | msr1 | both (both = 2 arches) // BENCH_SKIP_RATED= "1" runs saturation passes only. Every // cell still runs the saturation pass; the @@ -88,8 +89,9 @@ func BenchTier() error { // The fit budget defaults to the 24h weekly cluster invariant // (budget.Budget) but can be raised per-invocation via BENCH_BUDGET for // a manual full-matrix dispatch that intentionally runs longer than the - // weekly headline — the full profile is ~58h on one arch and cannot fit - // 24h. The CI job's timeout-minutes must exceed this budget (+ Deploy / + // weekly headline — the full profile is ~30h on one arch (the same full + // grid as headline, at the longer 90s/20s window) and cannot fit 24h. + // The CI job's timeout-minutes must exceed this budget (+ Deploy / // Cleanup overhead). fitBudget := budget.Budget if v := os.Getenv("BENCH_BUDGET"); v != "" { @@ -124,7 +126,11 @@ func BenchTier() error { // benchmarks[].latency_at_slo alongside the per-scenario saturation // data, so the dashboard's headline reads "for this server × this // scenario: RPS, p99-at-1s, SLO" all from one Document. - skipRated := os.Getenv("BENCH_SKIP_RATED") == "1" || os.Getenv("BENCH_SKIP_RATED") == "true" + // Rated OFF when the caller asks (BENCH_SKIP_RATED) OR the profile ships + // no rated sweep (RatedPasses==0, e.g. the "fast" routine/weekly profile). + // Rated is the dominant per-cell cost (4 closed-loop passes), so the + // default fast profile leaves it off and runs the full grid saturation-only. + skipRated := os.Getenv("BENCH_SKIP_RATED") == "1" || os.Getenv("BENCH_SKIP_RATED") == "true" || p.RatedPasses == 0 if skipRated { _ = os.Unsetenv("BENCH_RATED") } else { diff --git a/report/aggregate.go b/report/aggregate.go index df756a6..84ef236 100644 --- a/report/aggregate.go +++ b/report/aggregate.go @@ -56,6 +56,15 @@ type CellResult struct { // sees no signal for this cell. RatedSamples [][]RatedSample + // Resources is the per-run server-side resource aggregate (#154): + // one [ResourceStats] per run that captured an observer.sqlite + cpu.log + // sidecar (cluster path), reduced across runs by [Aggregate] into + // CellAggregate.Resources. Unlike Samples this is NOT strictly parallel: + // nil-resource runs are simply absent, so a run with loadgen samples but + // no observer data contributes a sample without a resource entry. Nil + // for the in-process loopback runner (no observer sidecar). + Resources []*ResourceStats + // Status is the per-cell outcome classification (schema v5.3+). The // zero value ("") is treated as [CellOK] when Samples are present — // [Aggregate] derives the effective status from ErrorMsg via @@ -180,6 +189,16 @@ type CellAggregate struct { // is better — this is the leaf the regression gate keys on. Nil when // rated mode was off, so a non-rated run emits no fake gate signal. LatencyAtSLO map[int]int + + // Resources is the across-runs reduction of the cell's server-side + // resource sampling (#154): median of each scalar across the runs that + // reported it, plus the last run's downsampled series. Nil when no run + // carried observer data (e.g. the in-process loopback path, or a cell + // whose observer sidecar produced nothing). BuildDocument surfaces it on + // ServerResult.Resources so the report can rank by CPU/RSS efficiency — + // the key lever for the network-bound large-payload cells, where raw RPS + // converges at the NIC ceiling but CPU cost per byte still differs. + Resources *ResourceStats } // ErrNotImplemented is returned by scaffold stubs that have not yet been @@ -276,6 +295,10 @@ func Aggregate(cells []CellResult) map[string]CellAggregate { reduceRated(cell.RatedSamples, &agg) + // Server-side resource reduction (#154): median each scalar across + // the runs that captured an observer sidecar. Nil when none did. + agg.Resources = ReduceResources(cell.Resources) + out[CellID(cell.ScenarioName, cell.ServerName)] = agg } return out diff --git a/report/document.go b/report/document.go index 146a30d..7616f2c 100644 --- a/report/document.go +++ b/report/document.go @@ -172,6 +172,34 @@ func BuildDocument(in BuildInput) *Document { if c.MergedHistogramB64 != "" { sr.HdrHistogramB64[c.ScenarioName] = c.MergedHistogramB64 } + + // Server-side resource aggregate (#154). Surfaced for every + // data-bearing cell that captured an observer sidecar so the report + // can rank adapters by CPU/RSS efficiency — the differentiator for + // the NIC-bound large-payload cells where raw RPS converges at the + // fabric ceiling. Nil for runs with no observer (in-process loopback) + // so the field stays omitted there. The map is lazily allocated so a + // resource-free run emits no empty "resources":{} object. + if c.Resources != nil { + if sr.Resources == nil { + sr.Resources = map[string]*ResourceStats{} + } + sr.Resources[c.ScenarioName] = c.Resources + } + + // Network-bound annotation (schema v5.5): a large-payload cell whose + // achieved egress bandwidth sat at the fabric ceiling while the + // loadgen still had CPU headroom is NIC-limited, not server-limited. + // Its saturation RPS converges across every fast adapter and must not + // be read as a ranking — the CPU efficiency in Resources is the real + // signal. Only flagged when the fabric's line rate is known (the LAN; + // the Tailscale overlay reports 0 and flags nothing). + if isNetworkBound(c.BytesMedian, c.LoadgenCPUP95, in.Environment.FabricLineRateBitsPerSec) { + if sr.NetworkBound == nil { + sr.NetworkBound = map[string]bool{} + } + sr.NetworkBound[c.ScenarioName] = true + } } out := &Document{ @@ -193,6 +221,41 @@ func BuildDocument(in BuildInput) *Document { return out } +const ( + // networkBoundBandwidthFraction is the share of the fabric line rate a + // cell's achieved egress bandwidth must reach to be called network-bound. + // 0.80 leaves headroom below the theoretical ceiling: the 2x10G LACP + // fabric tops out near 18.8/20 Gbps in practice (per-flow hashing keeps a + // single TCP flow on one 10G member), and only the large-payload cells + // (64k/1m, ~18.8 Gbps) ever approach it — every small-response cell stays + // orders of magnitude below, so there are no false positives. + networkBoundBandwidthFraction = 0.80 + + // networkBoundLoadgenCPUCeiling guards against mislabelling a + // loadgen-bottlenecked cell as NIC-bound. LoadgenCPUP95 is a fraction of + // one core; a value at/above this means the load generator itself was the + // limit, so the cell's ceiling is a client artefact, not the fabric. + // (Realistically a NIC-bound cell shows LOW loadgen CPU — the client is + // blocked on the wire, not burning cycles.) + networkBoundLoadgenCPUCeiling = 8.0 +) + +// isNetworkBound reports whether a cell's achieved egress bandwidth sat at +// the fabric line rate (NIC-limited) rather than the server's CPU limit. +// bytesPerSec is the median across-runs throughput; loadgenCPUP95 is the +// loadgen self-CPU fraction; lineRateBits is the fabric ceiling in bits/sec +// (0 when unknown → never flagged). +func isNetworkBound(bytesPerSec, loadgenCPUP95 float64, lineRateBits int64) bool { + if lineRateBits <= 0 || bytesPerSec <= 0 { + return false + } + if loadgenCPUP95 >= networkBoundLoadgenCPUCeiling { + return false + } + achievedBits := bytesPerSec * 8 + return achievedBits >= networkBoundBandwidthFraction*float64(lineRateBits) +} + // recordRunStatuses copies a cell's per-run outcome sequence into // ServerResult.CellRunStatuses (schema v5.4) when at least one run was // non-OK. All-OK cells are skipped so the field stays absent for the diff --git a/report/markdown.go b/report/markdown.go index d1b81b3..e413808 100644 --- a/report/markdown.go +++ b/report/markdown.go @@ -84,6 +84,12 @@ func WriteMarkdown(w io.Writer, doc *Document, agg map[string]CellAggregate, met } } + if doc != nil && len(agg) > 0 { + if err := writeNetworkBoundSection(w, doc, agg); err != nil { + return err + } + } + if meta.BaselinePath != "" && doc != nil { if err := writeRegressionSection(w, doc, meta.BaselinePath); err != nil { return err @@ -589,6 +595,98 @@ func writeResourceSection(w io.Writer, doc *Document) error { return nil } +// writeNetworkBoundSection renders the large-payload cells flagged +// network-bound (schema v5.5): cells whose achieved egress bandwidth sat at +// the fabric line rate, so their saturation RPS converged across every fast +// adapter and is NOT a ranking. The honest comparison for these cells is CPU +// efficiency at the shared ceiling — bandwidth delivered per unit of +// server CPU — so the table ranks by Gbps-per-CPU% (higher is better: the +// adapter pushing the same wire with less CPU has the most headroom). Emits +// nothing when no cell was network-bound (every run below the ceiling, or a +// fabric with no known line rate). +func writeNetworkBoundSection(w io.Writer, doc *Document, agg map[string]CellAggregate) error { + type row struct { + scenario, adapter string + rps float64 + gbps float64 + cpuPct *float64 + eff float64 // gbps per CPU%; <0 sentinel when CPU unknown + } + var rows []row + for _, a := range doc.Benchmarks { + for sc, bound := range a.NetworkBound { + if !bound { + continue + } + c, ok := agg[CellID(sc, a.Name)] + if !ok { + continue + } + gbps := c.BytesMedian * 8 / 1e9 + r := row{scenario: sc, adapter: a.Name, rps: c.RPSMedian, gbps: gbps, eff: -1} + if res := a.Resources[sc]; res != nil && res.Summary.MeanCPUPct != nil && *res.Summary.MeanCPUPct > 0 { + r.cpuPct = res.Summary.MeanCPUPct + r.eff = gbps / *res.Summary.MeanCPUPct + } + rows = append(rows, r) + } + } + if len(rows) == 0 { + return nil + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].scenario != rows[j].scenario { + return rows[i].scenario < rows[j].scenario + } + if rows[i].eff != rows[j].eff { + return rows[i].eff > rows[j].eff // most efficient first + } + return rows[i].adapter < rows[j].adapter + }) + + if _, err := io.WriteString(w, "\n## Network-bound cells — ranked by CPU efficiency, not RPS\n\n"); err != nil { + return err + } + if _, err := io.WriteString(w, + "These cells hit the fabric line rate: their saturation RPS converges across "+ + "fast adapters and is **not** a ranking. The adapter delivering the same "+ + "bandwidth with less server CPU has the most headroom — higher Gbps/CPU%% wins.\n\n"); err != nil { + return err + } + header := []string{"scenario", "adapter", "RPS", "Gbps", "Server CPU%", "Gbps / CPU% (↑ better)"} + if _, err := io.WriteString(w, "| "+strings.Join(header, " | ")+" |\n"); err != nil { + return err + } + sep := make([]string, len(header)) + for i := range sep { + sep[i] = "---" + } + if _, err := io.WriteString(w, "| "+strings.Join(sep, " | ")+" |\n"); err != nil { + return err + } + for _, r := range rows { + effStr := "—" + if r.eff >= 0 { + effStr = fmt.Sprintf("%.3f", r.eff) + } + cells := []string{ + r.scenario, + r.adapter, + fmt.Sprintf("%.0f", r.rps), + fmt.Sprintf("%.2f", r.gbps), + fmtF64p(r.cpuPct, "%.1f"), + effStr, + } + if _, err := io.WriteString(w, "| "+strings.Join(cells, " | ")+" |\n"); err != nil { + return err + } + } + if _, err := io.WriteString(w, "\n"); err != nil { + return err + } + return nil +} + // fmtI64p renders a nullable int64 pointer, "—" for nil. func fmtI64p(p *int64) string { if p == nil { diff --git a/report/netbound_test.go b/report/netbound_test.go new file mode 100644 index 0000000..907043c --- /dev/null +++ b/report/netbound_test.go @@ -0,0 +1,129 @@ +package report + +import ( + "bytes" + "strings" + "testing" + + "github.com/goceleris/loadgen" +) + +// bwCell builds a one-run data-bearing CellResult whose throughput is +// bytesPerSec and whose loadgen self-CPU is loadgenCPUPct (a percent, as +// loadgen emits it). Optionally carries a server resource sample. +func bwCell(scenario, server string, bytesPerSec, loadgenCPUPct, rps float64, serverCPU *float64) CellResult { + cr := CellResult{ + ScenarioName: scenario, + ServerName: server, + Samples: []loadgen.Result{{ + RequestsPerSec: rps, + ThroughputBPS: bytesPerSec, + CPUPctP95: loadgenCPUPct, + Requests: 1_000_000, + }}, + } + if serverCPU != nil { + cr.Resources = []*ResourceStats{rstat(*serverCPU, 100, 12)} + } + return cr +} + +// TestNetworkBoundFlaggedAtLineRate pins the network-bound detection: a +// large-payload cell at ~19 Gbps over the 20 Gbps fabric (with the loadgen +// not CPU-bound) is flagged; an identical-shape small cell well below the +// ceiling is not; and a run with no known line rate flags nothing. +func TestNetworkBoundFlaggedAtLineRate(t *testing.T) { + t.Parallel() + const lineRate = int64(20_000_000_000) // 20 Gbps + cpu := 42.0 + + // ~19.2 Gbps = 2.4e9 bytes/sec — above 0.80 * 20 Gbps. + hot := bwCell("get-json-64k", "celeris-iouring-h1-async", 2.4e9, 30, 35000, &cpu) + // ~0.29 Gbps — a small-response cell, nowhere near the ceiling. + cold := bwCell("get-json", "celeris-iouring-h1-async", 3.6e7, 30, 350000, &cpu) + + agg := Aggregate([]CellResult{hot, cold}) + doc := BuildDocument(BuildInput{ + HostArchPair: "linux/amd64", + Environment: Environment{Fabric: "3-host LACP 20G", FabricLineRateBitsPerSec: lineRate}, + Servers: map[string]ServerMeta{"celeris-iouring-h1-async": {Language: "go"}}, + Agg: agg, + }) + if len(doc.Benchmarks) != 1 { + t.Fatalf("benchmarks=%d want 1", len(doc.Benchmarks)) + } + nb := doc.Benchmarks[0].NetworkBound + if !nb["get-json-64k"] { + t.Error("get-json-64k should be flagged network-bound at 19.2 Gbps") + } + if nb["get-json"] { + t.Error("get-json (0.29 Gbps) must NOT be flagged network-bound") + } + + // No line rate → nothing flagged, even for the hot cell. + docNoRate := BuildDocument(BuildInput{ + HostArchPair: "linux/amd64", + Environment: Environment{Fabric: "3-host Tailscale overlay"}, + Servers: map[string]ServerMeta{"celeris-iouring-h1-async": {Language: "go"}}, + Agg: Aggregate([]CellResult{hot}), + }) + if len(docNoRate.Benchmarks[0].NetworkBound) != 0 { + t.Errorf("no line rate must flag nothing, got %v", docNoRate.Benchmarks[0].NetworkBound) + } +} + +// TestNetworkBoundLoadgenSaturatedNotFlagged guards the loadgen-bottleneck +// exclusion: a cell at the bandwidth ceiling but with the loadgen itself +// pegged is a client artefact, not a fabric ceiling, so it is not flagged. +func TestNetworkBoundLoadgenSaturatedNotFlagged(t *testing.T) { + t.Parallel() + cpu := 42.0 + // 19.2 Gbps but loadgen self-CPU p95 = 900% (9 cores) → above the ceiling. + hot := bwCell("post-64k", "axum", 2.4e9, 900, 35000, &cpu) + agg := Aggregate([]CellResult{hot}) + doc := BuildDocument(BuildInput{ + Environment: Environment{FabricLineRateBitsPerSec: 20_000_000_000}, + Servers: map[string]ServerMeta{"axum": {Language: "rust"}}, + Agg: agg, + }) + if doc.Benchmarks[0].NetworkBound["post-64k"] { + t.Error("loadgen-saturated cell must not be flagged network-bound") + } +} + +// TestNetworkBoundMarkdownSection asserts the markdown renders the +// efficiency table for flagged cells and ranks by Gbps/CPU%. +func TestNetworkBoundMarkdownSection(t *testing.T) { + t.Parallel() + const lineRate = int64(20_000_000_000) + lowCPU := 30.0 // efficient: more Gbps per CPU% + highCPU := 90.0 // less efficient + + eff := bwCell("get-json-64k", "celeris-iouring-h1-async", 2.4e9, 25, 35000, &lowCPU) + ineff := bwCell("get-json-64k", "fastapi", 2.3e9, 25, 33000, &highCPU) + + agg := Aggregate([]CellResult{eff, ineff}) + doc := BuildDocument(BuildInput{ + Environment: Environment{FabricLineRateBitsPerSec: lineRate}, + Servers: map[string]ServerMeta{ + "celeris-iouring-h1-async": {Language: "go"}, + "fastapi": {Language: "python"}, + }, + Agg: agg, + }) + + var buf bytes.Buffer + if err := WriteMarkdown(&buf, doc, agg, Meta{GitRef: "t"}); err != nil { + t.Fatalf("WriteMarkdown: %v", err) + } + out := buf.String() + if !strings.Contains(out, "Network-bound cells") { + t.Fatal("markdown missing network-bound section") + } + // The efficient (low-CPU) adapter must be ranked above the inefficient one. + effIdx := strings.Index(out, "celeris-iouring-h1-async |") + ineffIdx := strings.Index(out, "fastapi |") + if effIdx < 0 || ineffIdx < 0 || effIdx > ineffIdx { + t.Errorf("expected celeris ranked above fastapi in NIC-bound table (eff=%d ineff=%d)", effIdx, ineffIdx) + } +} diff --git a/report/resources.go b/report/resources.go index 22abf5f..cdcac1b 100644 --- a/report/resources.go +++ b/report/resources.go @@ -307,3 +307,78 @@ func percentileI64(xs []int64, p float64) int64 { func ptrI64(v int64) *int64 { return &v } func ptrF64(v float64) *float64 { return &v } + +// ReduceResources folds a cell's per-run [ResourceStats] into one +// representative (#154): each summary scalar is the median across the runs +// that reported it (so a single GC spike or RSS blip does not skew the +// headline), and the last reporting run's series is kept verbatim as the +// illustrative trajectory. A metric stays null in the result iff it was +// null in EVERY run, so a non-Go competitor keeps goroutine/GC null while +// RSS/CPU/FD survive. nil run entries are skipped; the result is nil when +// no run carried resources. +// +// This is the single source of truth for the per-run reduction shared by +// the report-side [Aggregate] (the typed Document path) and the cluster +// per-host summary (mage_bench.go summarizeCells). +func ReduceResources(runs []*ResourceStats) *ResourceStats { + present := make([]*ResourceStats, 0, len(runs)) + for _, r := range runs { + if r != nil { + present = append(present, r) + } + } + if len(present) == 0 { + return nil + } + out := &ResourceStats{Series: present[len(present)-1].Series} + out.Summary.PeakRSSBytes = medianI64Ptr(collectResI64(present, func(s ResourceSummary) *int64 { return s.PeakRSSBytes })) + out.Summary.SteadyRSSBytes = medianI64Ptr(collectResI64(present, func(s ResourceSummary) *int64 { return s.SteadyRSSBytes })) + out.Summary.GCPauseP99Ns = medianI64Ptr(collectResI64(present, func(s ResourceSummary) *int64 { return s.GCPauseP99Ns })) + out.Summary.GoroutineHWM = medianI64Ptr(collectResI64(present, func(s ResourceSummary) *int64 { return s.GoroutineHWM })) + out.Summary.FDHWM = medianI64Ptr(collectResI64(present, func(s ResourceSummary) *int64 { return s.FDHWM })) + out.Summary.MeanCPUPct = medianF64Ptr(collectResF64(present, func(s ResourceSummary) *float64 { return s.MeanCPUPct })) + return out +} + +// collectResI64 gathers the non-nil int64 values a selector pulls from +// each run's summary. +func collectResI64(runs []*ResourceStats, sel func(ResourceSummary) *int64) []int64 { + var out []int64 + for _, r := range runs { + if v := sel(r.Summary); v != nil { + out = append(out, *v) + } + } + return out +} + +// collectResF64 is collectResI64 for float metrics. +func collectResF64(runs []*ResourceStats, sel func(ResourceSummary) *float64) []float64 { + var out []float64 + for _, r := range runs { + if v := sel(r.Summary); v != nil { + out = append(out, *v) + } + } + return out +} + +// medianI64Ptr returns the median of xs as a fresh pointer, or nil when xs +// is empty (every run had the metric null). +func medianI64Ptr(xs []int64) *int64 { + if len(xs) == 0 { + return nil + } + v := medianInt64(xs) + return &v +} + +// medianF64Ptr is medianI64Ptr for floats (uses the p50 of the percentile +// helper so the tie-break matches the RPS/CPU aggregation in this package). +func medianF64Ptr(xs []float64) *float64 { + if len(xs) == 0 { + return nil + } + v := percentile(xs, 50) + return &v +} diff --git a/report/resources_test.go b/report/resources_test.go index bfda500..8e28f1b 100644 --- a/report/resources_test.go +++ b/report/resources_test.go @@ -309,3 +309,116 @@ func TestResourceJSONRoundTrip(t *testing.T) { t.Errorf("Series drift: %+v", got.Series) } } + +// rstat is a tiny constructor for a ResourceStats with a CPU + RSS scalar +// summary and one series point, for the aggregation/document flow tests. +func rstat(cpuPct float64, rssBytes, fdHWM int64) *ResourceStats { + return &ResourceStats{ + Summary: ResourceSummary{ + MeanCPUPct: ptrF64(cpuPct), + PeakRSSBytes: ptrI64(rssBytes), + FDHWM: ptrI64(fdHWM), + }, + Series: []ResourcePoint{{TSUnix: 1, CPUPct: ptrF64(cpuPct), RSSBytes: ptrI64(rssBytes)}}, + } +} + +// TestReduceResourcesMediansAndSkipsNil pins the per-run reducer: medians +// each scalar across the runs that reported it, drops nil run entries, and +// keeps the last reporting run's series. +func TestReduceResourcesMediansAndSkipsNil(t *testing.T) { + t.Parallel() + runs := []*ResourceStats{ + rstat(40, 100, 10), + nil, // a run with no observer sidecar — must be skipped, not panic + rstat(60, 300, 30), + rstat(50, 200, 20), + } + got := ReduceResources(runs) + if got == nil { + t.Fatal("ReduceResources returned nil for non-empty input") + } + if got.Summary.MeanCPUPct == nil || *got.Summary.MeanCPUPct != 50 { + t.Errorf("MeanCPUPct=%v want 50 (median of 40,60,50)", got.Summary.MeanCPUPct) + } + if got.Summary.PeakRSSBytes == nil || *got.Summary.PeakRSSBytes != 200 { + t.Errorf("PeakRSSBytes=%v want 200", got.Summary.PeakRSSBytes) + } + // Series is the LAST reporting run's (the 200/50 entry). + if len(got.Series) != 1 || got.Series[0].CPUPct == nil || *got.Series[0].CPUPct != 50 { + t.Errorf("Series drift: %+v", got.Series) + } + if ReduceResources(nil) != nil { + t.Error("ReduceResources(nil) should be nil") + } + if ReduceResources([]*ResourceStats{nil, nil}) != nil { + t.Error("ReduceResources of all-nil should be nil") + } +} + +// TestResourcesFlowAggregateToDocument is the regression guard for the bug +// the next bench round must not reship: server-side resources were captured +// per cell (observer.sqlite + cpu.log) but the merge → Aggregate → +// BuildDocument path dropped them, so every published Document carried an +// empty resources map. This drives a CellResult carrying per-run Resources +// all the way to ServerResult.Resources. +func TestResourcesFlowAggregateToDocument(t *testing.T) { + t.Parallel() + cell := CellResult{ + ScenarioName: "get-json-64k", + ServerName: "celeris-iouring-h1-async", + // Two OK runs with real RPS so the cell is data-bearing. + Samples: makeSamples([]float64{35000, 35200}, 0), + Resources: []*ResourceStats{ + rstat(45, 100, 12), + rstat(55, 120, 14), + }, + } + + agg := Aggregate([]CellResult{cell}) + a, ok := agg[CellID(cell.ScenarioName, cell.ServerName)] + if !ok { + t.Fatal("aggregate missing cell") + } + if a.Resources == nil { + t.Fatal("CellAggregate.Resources is nil — reduction dropped it") + } + if a.Resources.Summary.MeanCPUPct == nil || *a.Resources.Summary.MeanCPUPct != 50 { + t.Errorf("aggregate MeanCPUPct=%v want 50", a.Resources.Summary.MeanCPUPct) + } + + doc := BuildDocument(BuildInput{ + HostArchPair: "linux/amd64", + Servers: map[string]ServerMeta{ + cell.ServerName: {Category: "celeris", Language: "go", Framework: "celeris"}, + }, + Agg: agg, + }) + if len(doc.Benchmarks) != 1 { + t.Fatalf("benchmarks=%d want 1", len(doc.Benchmarks)) + } + sr := doc.Benchmarks[0] + if sr.Resources == nil { + t.Fatal("ServerResult.Resources is nil — BuildDocument dropped resources") + } + r, ok := sr.Resources[cell.ScenarioName] + if !ok || r == nil { + t.Fatalf("no resources for scenario %q", cell.ScenarioName) + } + if r.Summary.MeanCPUPct == nil || *r.Summary.MeanCPUPct != 50 { + t.Errorf("document MeanCPUPct=%v want 50", r.Summary.MeanCPUPct) + } + + // And it must survive a JSON round-trip under the documented tag. + b, err := json.Marshal(sr) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var back ServerResult + if err := json.Unmarshal(b, &back); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if back.Resources[cell.ScenarioName] == nil { + t.Error("resources lost across JSON round-trip") + } +} diff --git a/report/schema.go b/report/schema.go index 090713b..21548d6 100644 --- a/report/schema.go +++ b/report/schema.go @@ -43,7 +43,17 @@ import ( // Also additive within 5.4: ServerResult.ConnectErrors, the // per-scenario dial/handshake-failure subset of the loadgen error // total (loadgen Result.ConnectErrors), emitted only when nonzero. -const SchemaVersion = "5.4" +// - 5.5 — network-bound annotation for large-payload cells. Adds +// Environment.FabricLineRateBitsPerSec (the fabric's theoretical +// egress ceiling) and ServerResult.NetworkBound: scenario → true for +// cells whose achieved bandwidth sat at/near the fabric line rate +// while the loadgen still had CPU headroom — i.e. the NIC, not the +// server, was the bottleneck, so raw RPS converges across fast +// adapters and must NOT be read as a ranking. The CPU/RSS efficiency +// in ServerResult.Resources (now populated, finally) is the +// differentiator for those cells. Both additive and omitted when +// absent: a Tailscale-overlay run (no known line rate) emits neither. +const SchemaVersion = "5.5" // CellStatus classifies the OUTCOME of a single (scenario, server) // cell. It is the single source of truth for whether a cell ran and @@ -212,6 +222,14 @@ type Environment struct { // Fabric describes the wire fabric (e.g. "3-host LACP 20G", or // "loopback" for a single-host smoke run). Fabric string `json:"fabric"` + + // FabricLineRateBitsPerSec is the fabric's theoretical egress ceiling + // in bits/sec (e.g. 20e9 for the 2x10G LACP LAN). Used by BuildDocument + // to flag large-payload cells whose achieved bandwidth sat at the NIC + // ceiling (network-bound) rather than the server's CPU limit. Zero/ + // omitted when the line rate is unknown (the Tailscale overlay), in + // which case no cell is flagged. Schema v5.5+. + FabricLineRateBitsPerSec int64 `json:"fabric_line_rate_bits_per_sec,omitempty"` } // BenchmarkConfig records the orchestrator flags + tunables that @@ -311,6 +329,17 @@ type ServerResult struct { // RSS/CPU/FD only, leaving goroutine/GC/heap null. Resources map[string]*ResourceStats `json:"resources,omitempty"` + // NetworkBound flags, per scenario, the cells whose achieved egress + // bandwidth sat at/near the fabric line rate while the loadgen still + // had CPU headroom — the NIC, not the server, capped throughput. For + // these cells the saturation RPS converges across every fast adapter + // and is NOT a ranking signal; compare ServerResult.Resources (CPU/RSS + // at the shared ceiling) instead. Schema v5.5+. Omitted when no cell + // for this adapter was network-bound (every small-payload run, and + // every run on a fabric with no known line rate). Older readers ignore + // it. + NetworkBound map[string]bool `json:"network_bound,omitempty"` + // CellStatuses records the non-OK outcome of every scenario this // adapter did NOT produce a clean number for, keyed by // Scenario.Name(); the value is "not_applicable" (route/protocol diff --git a/report/schema_test.go b/report/schema_test.go index 9155e7b..1fe25fd 100644 --- a/report/schema_test.go +++ b/report/schema_test.go @@ -395,8 +395,8 @@ func TestBuildDocument(t *testing.T) { if doc.SchemaVersion != SchemaVersion { t.Errorf("SchemaVersion: want %q got %q", SchemaVersion, doc.SchemaVersion) } - if doc.SchemaVersion != "5.4" { - t.Errorf("SchemaVersion drift: want 5.4 got %q", doc.SchemaVersion) + if doc.SchemaVersion != "5.5" { + t.Errorf("SchemaVersion drift: want 5.5 got %q", doc.SchemaVersion) } if len(doc.Benchmarks) != 2 { t.Fatalf("Benchmarks: want 2 got %d", len(doc.Benchmarks)) diff --git a/scenarios/concurrency.go b/scenarios/concurrency.go index 584a8de..77d176c 100644 --- a/scenarios/concurrency.go +++ b/scenarios/concurrency.go @@ -7,14 +7,16 @@ import ( ) // ConcurrencyProfile enumerates the per-target concurrency profiles the -// matrix sweeps: 1 connection, 128 connections, 1024 connections, and an -// auto-mix blend (H1 + H2 + H2C-upgrade, 1:1:1) produced via loadgen's -// -mix mode. +// matrix sweeps: 1, 128, 256, 512, and 1024 connections. The 256/512 +// mid-high points make the engine crossover visible — celeris's io_uring +// engine ties through ~256c and pulls ahead by 512c, peaking at 1024c; +// without them the sweep jumps 128c→1024c and hides the inflection. const ( - ProfileSingle = "single-conn" - ProfileMid = "128-conn" - ProfileHigh = "1024-conn" - ProfileAutoMix = "auto-mix-h1:h2:upgrade=1:1:1" + ProfileSingle = "single-conn" + ProfileMid = "128-conn" + ProfileMidHi = "256-conn" + ProfileHi512 = "512-conn" + ProfileHigh = "1024-conn" ) // ConcurrencyScenario parameterises a static workload with one of the @@ -29,15 +31,8 @@ type ConcurrencyScenario struct { // Path is the request path ("/" or "/json"). Path string - // Connections is the TCP connection count passed to loadgen. For the - // auto-mix profile, loadgen spreads these connections across the three - // protocol buckets according to Mix. + // Connections is the TCP connection count passed to loadgen. Connections int - - // Mix, when non-nil, enables loadgen's weighted-draw protocol mixer - // and MUST NOT be combined with HTTP2 / H2CUpgrade per loadgen's - // Config godoc. Only the auto-mix scenario sets it. - Mix *loadgen.MixRatio } // NewConcurrencyScenario constructs a [ConcurrencyScenario]. Kept for @@ -53,7 +48,7 @@ func (s *ConcurrencyScenario) Name() string { return s.name } func (s *ConcurrencyScenario) Category() string { return CategoryConcurrency } // Profile returns the concurrency profile identifier (one of [ProfileSingle], -// [ProfileMid], [ProfileHigh], [ProfileAutoMix]). +// [ProfileMid], [ProfileMidHi], [ProfileHi512], [ProfileHigh]). func (s *ConcurrencyScenario) Profile() string { return s.profile } // Workload returns the loadgen.Config for this scenario. The orchestrator @@ -73,24 +68,13 @@ func (s *ConcurrencyScenario) Workload(target string) loadgen.Config { Method: method, Connections: conns, } - // Mix is mutually exclusive with HTTP2 / H2CUpgrade per loadgen's - // Config godoc — we deliberately do not set those flags here. - if s.Mix != nil { - mix := *s.Mix - cfg.Mix = &mix - } return cfg } -// Applicable gates the auto-mix profile to servers that expose every -// wire-format the mixer draws from (H1, H2C prior-knowledge, and H2C -// upgrade). Every other profile drives plain H1 on the wire and is -// inapplicable to H2C-prior-knowledge-only servers (h2c-noupg) — those -// would silently record 0 RPS. +// Applicable: every concurrency profile drives plain H1 on the wire, so a +// column is in scope iff it speaks HTTP/1.1. H2C-prior-knowledge-only +// servers (h2c-noupg) are excluded — they would silently record 0 RPS. func (s *ConcurrencyScenario) Applicable(fs servers.FeatureSet) bool { - if s.profile == ProfileAutoMix { - return fs.HTTP1 && fs.HTTP2C && fs.H2CUpgrade - } return fs.HTTP1 } @@ -101,8 +85,9 @@ var _ Scenario = (*ConcurrencyScenario)(nil) var ConcurrencyProfiles = []string{ ProfileSingle, ProfileMid, + ProfileMidHi, + ProfileHi512, ProfileHigh, - ProfileAutoMix, } func init() { @@ -121,18 +106,24 @@ func init() { Connections: 128, }) Register(&ConcurrencyScenario{ - name: "get-simple-1024c", - profile: ProfileHigh, + name: "get-simple-256c", + profile: ProfileMidHi, Method: "GET", Path: "/", - Connections: 1024, + Connections: 256, }) Register(&ConcurrencyScenario{ - name: "auto-mix-111", - profile: ProfileAutoMix, + name: "get-simple-512c", + profile: ProfileHi512, Method: "GET", Path: "/", - Connections: 64, - Mix: &loadgen.MixRatio{H1: 1, H2: 1, Upgrade: 1}, + Connections: 512, + }) + Register(&ConcurrencyScenario{ + name: "get-simple-1024c", + profile: ProfileHigh, + Method: "GET", + Path: "/", + Connections: 1024, }) } diff --git a/scenarios/scenarios_test.go b/scenarios/scenarios_test.go index f645abd..feba60e 100644 --- a/scenarios/scenarios_test.go +++ b/scenarios/scenarios_test.go @@ -12,14 +12,18 @@ import ( // files and are deliberately excluded here — this test guards the slice // we own, not the ones we don't. var expectedRegistry = []string{ - // static H1 (8) + // static H1 (12) "churn-close", "get-json", "get-json-1k", + "get-json-8k", + "get-json-16k", "get-json-64k", "get-simple", "post-1m", "post-4k", + "post-8k", + "post-16k", "post-64k", // static H2-prior-knowledge (4) — exercise h2c-noupg and other @@ -29,11 +33,12 @@ var expectedRegistry = []string{ "post-4k-h2", "post-64k-h2", - // concurrency (4) - "auto-mix-111", + // concurrency (5) "get-json-1c", - "get-simple-1024c", "get-simple-128c", + "get-simple-256c", + "get-simple-512c", + "get-simple-1024c", } func TestRegistryContainsExpectedScenarios(t *testing.T) { @@ -192,49 +197,11 @@ func TestErrorBudgets(t *testing.T) { } } -func TestAutoMixApplicableGating(t *testing.T) { - t.Parallel() - s := findScenario(t, "auto-mix-111") - checks := []struct { - name string - fs servers.FeatureSet - want bool - }{ - {"empty", servers.FeatureSet{}, false}, - {"only-h1", servers.FeatureSet{HTTP1: true}, false}, - {"h1+h2c", servers.FeatureSet{HTTP1: true, HTTP2C: true}, false}, - {"h1+h2c+upgrade", servers.FeatureSet{HTTP1: true, HTTP2C: true, H2CUpgrade: true}, true}, - {"everything", servers.FeatureSet{ - HTTP1: true, HTTP2C: true, Auto: true, H2CUpgrade: true, - Drivers: true, Middleware: true, AsyncHandlers: true, - }, true}, - } - for _, c := range checks { - if got := s.Applicable(c.fs); got != c.want { - t.Errorf("auto-mix-111 Applicable(%s) = %v, want %v", c.name, got, c.want) - } - } - - cfg := s.Workload("http://x") - if cfg.Mix == nil { - t.Fatalf("auto-mix-111: Workload.Mix == nil, want *loadgen.MixRatio{1,1,1}") - } - if cfg.Mix.H1 != 1 || cfg.Mix.H2 != 1 || cfg.Mix.Upgrade != 1 { - t.Errorf("auto-mix-111: Mix = %+v, want {1,1,1}", *cfg.Mix) - } - if cfg.HTTP2 { - t.Errorf("auto-mix-111: HTTP2 = true, must be false when Mix is set") - } - if cfg.H2CUpgrade { - t.Errorf("auto-mix-111: H2CUpgrade = true, must be false when Mix is set") - } -} - -func TestConcurrencyNonAutoMixRequireHTTP1(t *testing.T) { +func TestConcurrencyRequireHTTP1(t *testing.T) { t.Parallel() h1Only := servers.FeatureSet{HTTP1: true} h2cOnly := servers.FeatureSet{HTTP2C: true} - for _, name := range []string{"get-json-1c", "get-simple-128c", "get-simple-1024c"} { + for _, name := range []string{"get-json-1c", "get-simple-128c", "get-simple-256c", "get-simple-512c", "get-simple-1024c"} { s := findScenario(t, name) if !s.Applicable(h1Only) { t.Errorf("%q: unexpectedly skipped for HTTP1-only server", name) @@ -273,7 +240,7 @@ func TestCategories(t *testing.T) { } } for _, name := range []string{ - "auto-mix-111", "get-json-1c", "get-simple-128c", "get-simple-1024c", + "get-json-1c", "get-simple-128c", "get-simple-256c", "get-simple-512c", "get-simple-1024c", } { s := findScenario(t, name) if got := s.Category(); got != CategoryConcurrency { diff --git a/scenarios/static.go b/scenarios/static.go index 0860680..137dedc 100644 --- a/scenarios/static.go +++ b/scenarios/static.go @@ -140,8 +140,12 @@ var StaticScenarioNames = []string{ "get-simple", "get-json", "get-json-1k", + "get-json-8k", + "get-json-16k", "get-json-64k", "post-4k", + "post-8k", + "post-16k", "post-64k", "post-1m", "churn-close", @@ -162,6 +166,8 @@ var StaticScenarioNames = []string{ // size leaves the others byte-identical. var ( post4KBody = makeRandomBody(4*1024, 0xA11CE_4000) + post8KBody = makeRandomBody(8*1024, 0xA11CE_8000) + post16KBody = makeRandomBody(16*1024, 0xB0B_16000) post64KBody = makeRandomBody(64*1024, 0xB0B_64000) post1MBody = makeRandomBody(1024*1024, 0xC0DE_10000) ) @@ -220,6 +226,24 @@ func init() { Path: "/json-1k", Connections: 128, }) + // Mid-size GET payloads (8k/16k). The 64k cells are NIC-bound on the 20G + // LACP fabric — every fast adapter converges at the line rate, so raw RPS + // stops differentiating them. 8k/16k responses stay well under the + // ceiling (a server doing 100k RPS of 16k is ~13 Gbps, still CPU-bound), + // so these rows recover the response-serialisation throughput signal the + // 64k row loses to the wire. + Register(&StaticScenario{ + name: "get-json-8k", + Method: "GET", + Path: "/json-8k", + Connections: 128, + }) + Register(&StaticScenario{ + name: "get-json-16k", + Method: "GET", + Path: "/json-16k", + Connections: 128, + }) Register(&StaticScenario{ name: "get-json-64k", Method: "GET", @@ -233,6 +257,24 @@ func init() { Body: post4KBody, Connections: 128, }) + // Mid-size POST bodies (8k/16k). Same NIC-ceiling rationale as the + // mid-size GET rows, on the upload (request-body parse) axis — and free + // of any per-adapter route work, since every adapter already serves + // /upload. + Register(&StaticScenario{ + name: "post-8k", + Method: "POST", + Path: "/upload", + Body: post8KBody, + Connections: 128, + }) + Register(&StaticScenario{ + name: "post-16k", + Method: "POST", + Path: "/upload", + Body: post16KBody, + Connections: 128, + }) Register(&StaticScenario{ name: "post-64k", Method: "POST", diff --git a/servers/actix/Cargo.toml b/servers/actix/Cargo.toml new file mode 100644 index 0000000..19d3ff1 --- /dev/null +++ b/servers/actix/Cargo.toml @@ -0,0 +1,59 @@ +# probatorium actix-web adapter — wave 4a (rust-actix). +# +# Build / run / lifecycle reference (no markdown docs; everything the +# operator needs lives here or in the source). +# +# build (cluster, ansible-driven, RUSTFLAGS="-C target-cpu=native"): +# cargo build --profile release-fat +# +# produced binary: +# target/release-fat/probatorium-actix-server +# (ansible/tasks/build_native_competitor.yml symlinks this to +# ${bench_root}/competitors/actix so servers.StartAdapter resolves +# it via the standard staging path.) +# +# run (the runner invokes the symlink with): +# probatorium-actix-server -bind 127.0.0.1:8080 +# The binary prints `ready addr=` on stdout once +# the listener is up; SIGTERM triggers graceful shutdown within 5s. +# +# Always-latest version policy: every dependency is expressed as a `>=` +# floor with no upper bound so `cargo update` resolves the newest +# compatible release at deploy time. Do NOT pin to exact versions — the +# whole probatorium contract is to bench whatever stable cuts the upstream +# world has at the moment the cluster runs. + +[package] +name = "probatorium-actix-server" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "probatorium-actix-server" +path = "src/main.rs" + +[dependencies] +# actix-web — the actix-rt / tokio HTTP framework. The "http2" feature +# pulls in the auto-h2c listener (HttpServer::listen_auto_h2c) the +# -engine h2c mode uses; "macros" provides #[actix_web::main]. We disable +# default features we don't need (compression codecs, cookies) to keep the +# static fast path lean, then re-add macros + http2 explicitly. +actix-web = { version = ">=4", default-features = false, features = ["macros", "http2"] } + +[profile.release] +opt-level = 3 +lto = "thin" + +# release-fat is the profile the cluster uses for benching. It trades +# compile time for the tightest codegen (full LTO, single codegen unit, +# panic=abort, strip). Cargo command at deploy time: +# cargo build --profile release-fat +# with RUSTFLAGS="-C target-cpu=native" set by the ansible role. +[profile.release-fat] +inherits = "release" +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true diff --git a/servers/actix/src/main.rs b/servers/actix/src/main.rs new file mode 100644 index 0000000..d137eb7 --- /dev/null +++ b/servers/actix/src/main.rs @@ -0,0 +1,256 @@ +// probatorium actix-web adapter — wave 4a (rust-actix). +// +// Serves the canonical contract endpoints declared in +// servers/common/contract.go: +// +// GET / -> "Hello, World!" text/plain +// GET /json -> {"message":"Hello, World!"} application/json +// GET /json-1k -> deterministic 1026-byte JSON page +// GET /json-8k -> deterministic 8286-byte JSON page +// GET /json-16k -> deterministic 16463-byte JSON page +// GET /json-64k -> deterministic 65618-byte JSON page +// GET /users/:id -> "User ID: " text/plain +// POST /upload -> read-and-discard body, reply "OK" text/plain +// +// Driver-backed (/db, /cache, /mc, /session) and chain-* endpoints are +// OUT OF SCOPE: the scenario applicability filter (servers/servers.go) +// skips Rust cells for those scenarios via the Static-only capability +// manifest, so the unhandled paths are never observed by loadgen. +// +// CLI: +// -bind default 127.0.0.1:8080. Pass `:0` (or any +// `host:0`) to let the kernel allocate a port; the +// bound address is reported on stdout via the +// `ready addr=` line the runner waits for +// before opening loadgen. We bind a std TcpListener +// ourselves and hand it to actix via `.listen()` / +// `.listen_auto_h2c()` so `local_addr()` resolves +// the kernel-assigned port for the ready line. +// -engine default "h1". One of: +// h1 — plain HTTP/1.x (HttpServer::listen). +// h2c — HTTP/2 cleartext. actix-web only exposes +// AUTO h2c through HttpServer +// (listen_auto_h2c): one listener that +// serves H1 AND prior-knowledge h2c, sniffing +// the h2 preface per connection. There is no +// HttpServer API for strict h2c-only +// (preface-or-reject); the strict path lives +// in the lower-level actix-http service +// builder, which would mean abandoning the +// HttpServer ergonomics this adapter is built +// on. So -engine h2c here is h2c-WITH-h1 +// fallback, not h2c-noupg. Unknown values +// exit non-zero. +// +// Lifecycle: actix-web's built-in signal handling performs the graceful +// shutdown on SIGTERM / SIGINT — it stops accepting, drains in-flight +// requests, then exits within shutdown_timeout (set to 3s, well below the +// runner's 5-second SIGKILL fallback in servers/start.go). We deliberately +// do NOT call disable_signals(): the custom-handler + ServerHandle::stop() +// path has a documented hang (actix-net#419), and the built-in handler +// already does exactly what the contract requires. + +mod payload; + +use std::net::TcpListener; +use std::process::ExitCode; + +use actix_web::http::header::ContentType; +use actix_web::{web, App, HttpResponse, HttpServer}; + +// Engine names the wire protocol the listener speaks. Mirrors the other +// Rust adapters' -engine vocabulary. NOTE: H2c here is actix's AUTO h2c +// (h1 + prior-knowledge h2c on one socket), not the strict h2c-noupg the +// hyper/ntex/axum adapters offer — see the -engine doc block above. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Engine { + H1, + H2c, +} + +// Static bodies are computed once at startup and shared by every worker. +// actix clones the App factory per worker thread; capturing &'static +// slices (payload::*) and a &'static str keeps the handlers allocation- +// free on the hot path. +const HELLO: &str = "Hello, World!"; +const JSON_HELLO: &[u8] = br#"{"message":"Hello, World!"}"#; + +#[actix_web::main] +async fn main() -> ExitCode { + let engine = match parse_engine_arg() { + Ok(e) => e, + Err(msg) => { + eprintln!("{msg}"); + return ExitCode::FAILURE; + } + }; + let bind = parse_bind_arg().unwrap_or_else(|| "127.0.0.1:8080".to_string()); + + // Bind a std listener ourselves so local_addr() resolves the kernel- + // assigned port for the ready line when -bind ends in :0. set_nonblocking + // is required: actix-server drives the listener inside its async accept + // loop and a blocking socket would stall the worker. + let listener = match TcpListener::bind(&bind) { + Ok(l) => l, + Err(e) => { + eprintln!("actix: bind {bind:?}: {e}"); + return ExitCode::FAILURE; + } + }; + if let Err(e) = listener.set_nonblocking(true) { + eprintln!("actix: set_nonblocking: {e}"); + return ExitCode::FAILURE; + } + let local = match listener.local_addr() { + Ok(a) => a, + Err(e) => { + eprintln!("actix: local_addr: {e}"); + return ExitCode::FAILURE; + } + }; + + let factory = || { + App::new() + // actix's web::Bytes extractor caps the buffered body at 256 KiB + // by default (web::PayloadConfig default limit). The contract's + // POST /upload drains the body — including the 1 MiB post-1m + // scenario (scenarios/static.go: post1MBody = 1024*1024) — so the + // server must accept up to ~2 MiB. Without raising this limit + // post-1m returns 400 Payload Overflow and the cell is classified + // not_applicable (the exact "post-1m body limit" capability gap + // mage_bench.go calls out for ntex). PayloadConfig applies to the + // built-in Bytes/String extractors and is registered via app_data. + .app_data(web::PayloadConfig::new(2 * 1024 * 1024)) + .route("/", web::get().to(root)) + .route("/json", web::get().to(json_static)) + .route("/json-1k", web::get().to(json_1k)) + .route("/json-8k", web::get().to(json_8k)) + .route("/json-16k", web::get().to(json_16k)) + .route("/json-64k", web::get().to(json_64k)) + // actix uses `{id}` path-capture syntax; the contract's `:id` + // template is translated here once at registration. + .route("/users/{id}", web::get().to(users_id)) + .route("/upload", web::post().to(upload)) + }; + + // shutdown_timeout caps the graceful drain; the runner SIGKILLs at 5s + // (servers/start.go), so 3s leaves margin to exit clean. + let server = HttpServer::new(factory).shutdown_timeout(3); + + let server = match engine { + Engine::H1 => server.listen(listener), + Engine::H2c => server.listen_auto_h2c(listener), + }; + let server = match server { + Ok(s) => s, + Err(e) => { + eprintln!("actix: listen {local}: {e}"); + return ExitCode::FAILURE; + } + }; + + // The runner's TCP probe waits for this exact line on stdout. Print and + // flush before running so the probe never races the listener. + println!("ready addr={local}"); + use std::io::Write; + let _ = std::io::stdout().flush(); + + // run() returns a Server future; awaiting it serves until actix's + // built-in SIGTERM/SIGINT handler triggers graceful shutdown. + match server.run().await { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("actix: serve: {e}"); + ExitCode::FAILURE + } + } +} + +// parse_bind_arg walks argv for `-bind ` (Go-flag style, the +// convention every adapter shares so the runner invokes them identically). +// No clap — two string flags do not justify the dependency. +fn parse_bind_arg() -> Option { + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + if a == "-bind" || a == "--bind" { + return args.next(); + } + if let Some(rest) = a.strip_prefix("-bind=") { + return Some(rest.to_string()); + } + if let Some(rest) = a.strip_prefix("--bind=") { + return Some(rest.to_string()); + } + } + None +} + +// parse_engine_arg reads `-engine ` (default "h1"). Accepts "h1" +// and "h2c"; any other value is a hard error so a typo in the runner's +// invocation fails fast rather than silently serving h1. +fn parse_engine_arg() -> Result { + let mut value: Option = None; + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + if a == "-engine" || a == "--engine" { + value = args.next(); + } else if let Some(rest) = a.strip_prefix("-engine=") { + value = Some(rest.to_string()); + } else if let Some(rest) = a.strip_prefix("--engine=") { + value = Some(rest.to_string()); + } + } + match value.as_deref() { + None | Some("h1") => Ok(Engine::H1), + Some("h2c") => Ok(Engine::H2c), + Some(other) => Err(format!("actix: unknown -engine {other:?} (want h1|h2c)")), + } +} + +// ---- handlers ---- + +async fn root() -> HttpResponse { + HttpResponse::Ok().content_type(ContentType::plaintext()).body(HELLO) +} + +async fn json_static() -> HttpResponse { + json_response(JSON_HELLO) +} + +async fn json_1k() -> HttpResponse { + json_response(payload::json_1k()) +} + +async fn json_8k() -> HttpResponse { + json_response(payload::json_8k()) +} + +async fn json_16k() -> HttpResponse { + json_response(payload::json_16k()) +} + +async fn json_64k() -> HttpResponse { + json_response(payload::json_64k()) +} + +async fn users_id(id: web::Path) -> HttpResponse { + HttpResponse::Ok() + .content_type(ContentType::plaintext()) + .body(format!("User ID: {id}")) +} + +// upload reads-and-discards the request body via the Bytes extractor (which +// collects the full payload stream) so /upload exercises the body parser +// without dominating the cell with allocator pressure. The reply is the +// literal "OK" the contract demands. +async fn upload(_body: web::Bytes) -> HttpResponse { + HttpResponse::Ok().content_type(ContentType::plaintext()).body("OK") +} + +// ---- response helpers ---- + +// json_response writes a &'static [u8] verbatim with application/json. The +// body bytes are shared across every request/worker (no copy of the corpus). +fn json_response(body: &'static [u8]) -> HttpResponse { + HttpResponse::Ok().content_type(ContentType::json()).body(body) +} diff --git a/servers/actix/src/payload.rs b/servers/actix/src/payload.rs new file mode 100644 index 0000000..636e9f8 --- /dev/null +++ b/servers/actix/src/payload.rs @@ -0,0 +1,148 @@ +// Deterministic 1 KiB / 8 KiB / 16 KiB / 64 KiB JSON payload generator. +// +// Byte-identical port of probatorium/servers/common/payload.go. The Go +// reference uses encoding/json on the (paginatedResponse, paginatedItem) +// struct pair, which emits compact JSON with field order matching struct +// declaration order: id, name, email, status, created_at for each item; +// page, per_page, total, total_pages, data for the wrapper. +// +// We emit the bytes by hand rather than via serde_json + a derived +// Serialize impl. Reasons: +// +// 1. Byte-for-byte equivalence with the Go reference is a hard +// conformance requirement (cmd/conformance does bytes::Equal). Any +// formatting drift between serde_json and encoding/json would fail +// the harness — even though both libraries claim "compact JSON", +// there is no formal cross-language guarantee on numeric or string +// escaping for the exact corpus we emit. +// 2. The payload corpus is tiny and entirely ASCII (no escapes needed +// beyond what manual formatting handles), so the manual path is both +// correct and trivially auditable. +// 3. Generated once at startup and reused for every request, so the +// build cost is irrelevant. +// +// The Go termination rule is "append items until the marshalled length +// crosses targetSize" — we mirror that. Resulting sizes: +// 1 KiB target → 1026 bytes ending at item 9 +// 8 KiB target → 8286 bytes +// 16 KiB target → 16463 bytes +// 64 KiB target → 65618 bytes ending at item 583 + +use std::sync::OnceLock; + +static JSON_1K: OnceLock> = OnceLock::new(); +static JSON_8K: OnceLock> = OnceLock::new(); +static JSON_16K: OnceLock> = OnceLock::new(); +static JSON_64K: OnceLock> = OnceLock::new(); + +pub fn json_1k() -> &'static [u8] { + JSON_1K.get_or_init(|| generate(1024)).as_slice() +} + +pub fn json_8k() -> &'static [u8] { + JSON_8K.get_or_init(|| generate(8192)).as_slice() +} + +pub fn json_16k() -> &'static [u8] { + JSON_16K.get_or_init(|| generate(16384)).as_slice() +} + +pub fn json_64k() -> &'static [u8] { + JSON_64K.get_or_init(|| generate(65536)).as_slice() +} + +// generate builds a paginated-response payload of at least target_size +// bytes using the same termination rule as the Go reference. +fn generate(target_size: usize) -> Vec { + // Header is a constant prefix — identical for every payload size. + let header = br#"{"page":1,"per_page":50,"total":1000,"total_pages":20,"data":["#; + // Footer closes the data array and the wrapper object. + let footer = b"]}"; + + let mut buf: Vec = Vec::with_capacity(target_size + 256); + buf.extend_from_slice(header); + + let mut i: u64 = 1; + loop { + if i > 1 { + buf.push(b','); + } + append_item(&mut buf, i); + // Tentative size = current buf + footer length. The Go code marshals + // the whole thing on every iteration, but we can avoid the recompute + // since the footer is fixed-length. + if buf.len() + footer.len() >= target_size { + break; + } + i += 1; + } + buf.extend_from_slice(footer); + buf +} + +// append_item writes one paginatedItem in the exact byte form +// encoding/json produces for the Go struct: +// {"id":,"name":"User ","email":"user@example.com", +// "status":"active","created_at":"2024-01-15T09:30:00Z"} +fn append_item(buf: &mut Vec, n: u64) { + buf.extend_from_slice(br#"{"id":"#); + push_u64(buf, n); + buf.extend_from_slice(br#","name":"User "#); + push_u64(buf, n); + buf.extend_from_slice(br#"","email":"user"#); + push_u64(buf, n); + buf.extend_from_slice(br#"@example.com","status":"active","created_at":"2024-01-15T09:30:00Z"}"#); +} + +// push_u64 appends the decimal representation of n. Allocation-free vs +// format!("{}", n) — the only "hot" path here on startup, so worth being +// tidy about. +fn push_u64(buf: &mut Vec, mut n: u64) { + if n == 0 { + buf.push(b'0'); + return; + } + let mut tmp = [0u8; 20]; + let mut idx = tmp.len(); + while n > 0 { + idx -= 1; + tmp[idx] = b'0' + (n % 10) as u8; + n /= 10; + } + buf.extend_from_slice(&tmp[idx..]); +} + +#[cfg(test)] +mod tests { + use super::*; + + // The Go reference produces these exact lengths. If a future change + // alters the byte layout, this test breaks before the conformance + // harness has a chance to surface it on the cluster. + #[test] + fn json_1k_matches_go_size() { + assert_eq!(json_1k().len(), 1026); + } + + #[test] + fn json_8k_matches_go_size() { + assert_eq!(json_8k().len(), 8286); + } + + #[test] + fn json_16k_matches_go_size() { + assert_eq!(json_16k().len(), 16463); + } + + #[test] + fn json_64k_matches_go_size() { + assert_eq!(json_64k().len(), 65618); + } + + #[test] + fn json_1k_starts_with_header() { + let p = json_1k(); + assert!(p.starts_with(br#"{"page":1,"per_page":50,"total":1000,"total_pages":20,"data":["#)); + assert!(p.ends_with(b"}")); + } +} diff --git a/servers/aspnet/Payload.cs b/servers/aspnet/Payload.cs index d4392df..1f3e9fc 100644 --- a/servers/aspnet/Payload.cs +++ b/servers/aspnet/Payload.cs @@ -5,7 +5,8 @@ namespace AspnetAdapter; // Deterministic JSON payload generator — must match servers/common/payload.go // byte for byte so the loadgen fixtures compare equal across every adapter // (verified with cmp against the live common.Endpoints bytes: /json-1k = -// 1026 bytes, /json-64k = 65618 bytes). +// 1026 bytes, /json-8k = 8286 bytes, /json-16k = 16463 bytes, +// /json-64k = 65618 bytes). // // The Go reference builds a paginated envelope // {"page":1,"per_page":50,"total":1000,"total_pages":20,"data":[ ...items... ]} @@ -18,6 +19,15 @@ namespace AspnetAdapter; internal static class Payload { internal static readonly byte[] Json1k = Generate(1024); + + // Mid-size payloads bridge the 1k→64k gap: on the 20G LACP fabric the + // 64k cells are NIC-bound (fast adapters converge at line rate), so the + // 8k/16k cells stay under the ceiling and keep differentiating adapters + // by CPU throughput. Same parametric generator as the Go reference + // (generateJSONPayload with targets 8192 / 16384). + internal static readonly byte[] Json8k = Generate(8192); + internal static readonly byte[] Json16k = Generate(16384); + internal static readonly byte[] Json64k = Generate(65536); private static byte[] Generate(int target) diff --git a/servers/aspnet/Program.cs b/servers/aspnet/Program.cs index 52052e6..4208852 100644 --- a/servers/aspnet/Program.cs +++ b/servers/aspnet/Program.cs @@ -6,10 +6,10 @@ // ASP.NET Core (Kestrel, minimal APIs) competitor adapter for the // probatorium benchmark matrix. // -// Implements the same 6-endpoint contract as every other adapter -// (servers/common/contract.go): GET / /json /json-1k /json-64k -// /users/:id and POST /upload. The JSON payloads are produced by the -// deterministic generator in Payload.cs, byte-identical to the Go and +// Implements the same canonical contract as every other adapter +// (servers/common/contract.go): GET / /json /json-1k /json-8k /json-16k +// /json-64k /users/:id and POST /upload. The JSON payloads are produced by +// the deterministic generator in Payload.cs, byte-identical to the Go and // Rust adapters so loadgen fixtures compare equal across languages. // // The server prints "ready addr=" on stdout once it is listening so @@ -19,8 +19,16 @@ // Configured for raw throughput: no logging providers, no dev middleware, // no HTTPS redirection, no response buffering — endpoints write bytes // straight to the response body. +// +// Wire protocol is selected by -engine (default "h1"): +// - h1 → HTTP/1.1 cleartext only on the listener. +// - h2c → HTTP/2 cleartext prior-knowledge only (no TLS, no HTTP/1.1 +// upgrade dance), mirroring stdhttp-h2's h2c-noupg mode. +// The two modes are strictly separated: an h1 listener never speaks h2 and +// an h2c listener never speaks h1. var bind = "127.0.0.1:8080"; +var engine = "h1"; for (var i = 0; i < args.Length; i++) { if (args[i] == "-bind" && i + 1 < args.Length) @@ -28,8 +36,25 @@ bind = args[i + 1]; i++; } + else if (args[i] == "-engine" && i + 1 < args.Length) + { + engine = args[i + 1]; + i++; + } } +if (engine != "h1" && engine != "h2c") +{ + Console.Error.Write($"aspnet: unknown -engine \"{engine}\" (want \"h1\" or \"h2c\")\n"); + Environment.Exit(2); +} + +// Prior-knowledge protocol selection. h2c serves HTTP/2 cleartext ONLY; +// h1 serves HTTP/1.1 ONLY. Neither falls back to the other (no Http1AndHttp2 +// negotiation), so the listener's wire behaviour is unambiguous for the +// conformance probe and the loadgen client. +var protocols = engine == "h2c" ? HttpProtocols.Http2 : HttpProtocols.Http1; + var (host, port) = SplitBind(bind); var builder = WebApplication.CreateSlimBuilder(args); @@ -44,11 +69,11 @@ options.AllowSynchronousIO = false; if (host is null) { - options.ListenAnyIP(port); + options.ListenAnyIP(port, listenOptions => listenOptions.Protocols = protocols); } else { - options.Listen(System.Net.IPAddress.Parse(host), port); + options.Listen(System.Net.IPAddress.Parse(host), port, listenOptions => listenOptions.Protocols = protocols); } }); @@ -61,12 +86,16 @@ var helloBytes = hello.ToArray(); var jsonHelloBytes = jsonHello.ToArray(); var json1k = Payload.Json1k; +var json8k = Payload.Json8k; +var json16k = Payload.Json16k; var json64k = Payload.Json64k; var okBytes = "OK"u8.ToArray(); app.MapGet("/", (HttpContext ctx) => WriteBytes(ctx, "text/plain", helloBytes)); app.MapGet("/json", (HttpContext ctx) => WriteBytes(ctx, "application/json", jsonHelloBytes)); app.MapGet("/json-1k", (HttpContext ctx) => WriteBytes(ctx, "application/json", json1k)); +app.MapGet("/json-8k", (HttpContext ctx) => WriteBytes(ctx, "application/json", json8k)); +app.MapGet("/json-16k", (HttpContext ctx) => WriteBytes(ctx, "application/json", json16k)); app.MapGet("/json-64k", (HttpContext ctx) => WriteBytes(ctx, "application/json", json64k)); app.MapGet("/users/{id}", (HttpContext ctx, string id) => diff --git a/servers/axum/Cargo.toml b/servers/axum/Cargo.toml index fa07cdd..077ccfe 100644 --- a/servers/axum/Cargo.toml +++ b/servers/axum/Cargo.toml @@ -36,8 +36,21 @@ path = "src/main.rs" [dependencies] # axum — tower-stack HTTP framework. Pulls hyper transitively. axum = ">=0.7" +# hyper — declared explicitly so the h2c engine can drive each accepted +# TCP connection with hyper's HTTP/2 prior-knowledge server builder +# (hyper::server::conn::http2::Builder). The "http2" feature is what +# pulls that builder in; "server" + "http1" keep the H1 serve path for +# the default -engine h1 mode. axum already pulls hyper transitively, but +# we name it here to control the feature set. +hyper = { version = ">=1", features = ["server", "http1", "http2"] } +# hyper-util — TokioExecutor (the Http2ServerConnExec the http2 builder +# needs), TokioIo (adapts a tokio TcpStream to hyper's I/O traits), +# TowerToHyperService (wraps axum's tower Router into a hyper Service for +# serve_connection), and the graceful-shutdown watcher used to drain +# in-flight h2c connections on SIGTERM. +hyper-util = { version = ">=0.1", features = ["tokio", "server", "server-graceful", "service", "http1", "http2"] } # tokio — async runtime. multi-thread + signal + macros (#[tokio::main]). -tokio = { version = ">=1", features = ["rt-multi-thread", "macros", "signal", "net", "io-util", "sync"] } +tokio = { version = ">=1", features = ["rt-multi-thread", "macros", "signal", "net", "io-util", "sync", "time"] } # serde_json — only needed for one tiny ad-hoc map encoding; the # deterministic 1k/64k payloads are emitted by hand to guarantee # byte-identical output to the Go reference (see src/payload.rs). diff --git a/servers/axum/src/main.rs b/servers/axum/src/main.rs index f988ed7..b0058f1 100644 --- a/servers/axum/src/main.rs +++ b/servers/axum/src/main.rs @@ -6,6 +6,8 @@ // GET / -> "Hello, World!" text/plain // GET /json -> {"message":"Hello, World!"} application/json // GET /json-1k -> deterministic 1026-byte JSON page +// GET /json-8k -> deterministic 8286-byte JSON page +// GET /json-16k -> deterministic 16463-byte JSON page // GET /json-64k -> deterministic 65618-byte JSON page // GET /users/:id -> "User ID: " text/plain // POST /upload -> read-and-discard body, reply "OK" text/plain @@ -21,15 +23,26 @@ // bound address is reported on stdout via the // `ready addr=` line that the runner waits // for before opening loadgen. +// -engine default "h1". One of: +// h1 — plain HTTP/1.1, served strictly (hyper's +// http1::Builder per accepted conn — see +// serve_h1 for why not axum::serve). +// h2c — HTTP/2 cleartext, PRIOR-KNOWLEDGE only: +// each accepted TCP conn is driven straight +// through hyper's HTTP/2 server builder, so +// the client must open with the h2 preface +// (curl --http2-prior-knowledge). No TLS, no +// h1->h2 upgrade. Mirrors stdhttp-h2's +// h2c-noupg mode. Unknown values exit non-zero. // -// Lifecycle: SIGTERM (or SIGINT) triggers axum's graceful shutdown, -// which finishes in-flight requests within the runner's 5-second grace -// window, well below the spawn() SIGKILL fallback in -// servers/start.go. +// Lifecycle: SIGTERM (or SIGINT) triggers graceful shutdown, which +// finishes in-flight requests within the runner's 5-second grace window, +// well below the spawn() SIGKILL fallback in servers/start.go. mod payload; use std::net::SocketAddr; +use std::process::ExitCode; use axum::{ body::Bytes, @@ -39,44 +52,166 @@ use axum::{ routing::{get, post}, Router, }; +use hyper::server::conn::{http1, http2}; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use hyper_util::server::graceful::GracefulShutdown; +use hyper_util::service::TowerToHyperService; use tokio::net::TcpListener; use tokio::signal::unix::{signal, SignalKind}; +// Engine names the wire protocol the listener speaks. Mirrors the +// stdhttp adapter's -engine vocabulary (minus the h1+h2 "hybrid" mode, +// which prior-knowledge h2c deliberately does not offer). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Engine { + H1, + H2c, +} + #[tokio::main(flavor = "multi_thread")] -async fn main() -> std::io::Result<()> { +async fn main() -> ExitCode { + let engine = match parse_engine_arg() { + Ok(e) => e, + Err(msg) => { + eprintln!("{msg}"); + return ExitCode::FAILURE; + } + }; let bind = parse_bind_arg().unwrap_or_else(|| "127.0.0.1:8080".to_string()); let app = Router::new() .route("/", get(root)) .route("/json", get(json_static)) .route("/json-1k", get(json_1k)) + .route("/json-8k", get(json_8k)) + .route("/json-16k", get(json_16k)) .route("/json-64k", get(json_64k)) // axum 0.8+ uses `{id}` capture groups; older `:id` is rejected // at router-build time. See axum CHANGELOG 0.8 for the rationale. .route("/users/{id}", get(users_id)) .route("/upload", post(upload)); - let addr: SocketAddr = bind - .parse() - .unwrap_or_else(|e| panic!("axum: bad -bind {bind:?}: {e}")); - let listener = TcpListener::bind(addr).await?; - let local = listener.local_addr()?; + let addr: SocketAddr = match bind.parse() { + Ok(a) => a, + Err(e) => { + eprintln!("axum: bad -bind {bind:?}: {e}"); + return ExitCode::FAILURE; + } + }; + let listener = match TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + eprintln!("axum: bind {addr}: {e}"); + return ExitCode::FAILURE; + } + }; + let local = match listener.local_addr() { + Ok(a) => a, + Err(e) => { + eprintln!("axum: local_addr: {e}"); + return ExitCode::FAILURE; + } + }; // The runner's TCP probe waits for this exact line on stdout. Print - // and flush before serve() so the probe never races the listener. + // and flush before serving so the probe never races the listener. println!("ready addr={local}"); use std::io::Write; let _ = std::io::stdout().flush(); - axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal()) - .await + let result = match engine { + Engine::H1 => serve_h1(listener, app).await, + Engine::H2c => serve_h2c(listener, app).await, + }; + if let Err(e) = result { + eprintln!("axum: serve: {e}"); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS +} + +// serve_h1 serves strict HTTP/1.1. We drive hyper's http1::Builder +// directly rather than axum::serve: axum::serve uses hyper-util's +// auto::Builder, which sniffs the h2 connection preface and would serve +// h2 too whenever hyper's "http2" feature is on — and that feature IS on +// in this crate (the h2c path needs it), so Cargo feature-unification +// would otherwise make -engine h1 accept h2 as well. http1::Builder keeps +// h1 strictly h1, matching the pre-h2c behaviour and the stdhttp h1 mode. +async fn serve_h1(listener: TcpListener, app: Router) -> std::io::Result<()> { + let svc = TowerToHyperService::new(app); + serve_loop(listener, move |io, graceful| { + let conn = http1::Builder::new().serve_connection(io, svc.clone()); + graceful.watch(conn) + }) + .await +} + +// serve_h2c drives every accepted TCP connection straight through hyper's +// HTTP/2 server builder — prior-knowledge h2c cleartext. There is no TLS +// and no HTTP/1.1 on this listener: a client that does not open with the +// h2 connection preface gets no h1 fallback (this is the strict +// h2c-noupg interpretation, matching stdhttp-h2). The axum Router is a +// tower Service>; TowerToHyperService adapts it to the +// hyper Service the http2 builder expects, cloned per connection. +async fn serve_h2c(listener: TcpListener, app: Router) -> std::io::Result<()> { + let svc = TowerToHyperService::new(app); + let builder = http2::Builder::new(TokioExecutor::new()); + serve_loop(listener, move |io, graceful| { + let conn = builder.serve_connection(io, svc.clone()); + graceful.watch(conn) + }) + .await +} + +// serve_loop owns the accept loop + graceful-shutdown lifecycle shared by +// both engines. `spawn_conn` builds the per-connection future (already +// wrapped by GracefulShutdown::watch) so the only difference between h1 +// and h2c is which hyper builder serves the socket. The future is spawned +// detached; per-connection errors are expected churn under load. +async fn serve_loop(listener: TcpListener, spawn_conn: F) -> std::io::Result<()> +where + F: Fn(TokioIo, &GracefulShutdown) -> Fut, + Fut: std::future::Future + Send + 'static, + Fut::Output: Send, +{ + let graceful = GracefulShutdown::new(); + let shutdown = shutdown_signal(); + tokio::pin!(shutdown); + + loop { + tokio::select! { + accepted = listener.accept() => { + let (stream, _peer) = match accepted { + Ok(pair) => pair, + // A single accept error must not tear the server down — + // skip and keep serving, matching the hyper adapter. + Err(_) => continue, + }; + let io = TokioIo::new(stream); + let fut = spawn_conn(io, &graceful); + tokio::spawn(async move { + // Per-connection errors (client resets, GOAWAY races, + // partial sends) are expected churn under load; drop them. + let _ = fut.await; + }); + } + _ = &mut shutdown => break, + } + } + + // Drain in-flight connections. The runner's SIGKILL fallback fires at + // 5s (servers/start.go), so a hard 3s cap leaves margin to exit clean. + tokio::select! { + _ = graceful.shutdown() => {} + _ = tokio::time::sleep(std::time::Duration::from_secs(3)) => {} + } + Ok(()) } // parse_bind_arg walks argv looking for `-bind ` (Go-flag style, // matches the convention used by every Go adapter so the runner can // invoke this binary identically). We deliberately do not use `clap` — -// the dependency is overkill for a single string flag. +// the dependency is overkill for two string flags. fn parse_bind_arg() -> Option { let mut args = std::env::args().skip(1); while let Some(a) = args.next() { @@ -93,6 +228,30 @@ fn parse_bind_arg() -> Option { None } +// parse_engine_arg reads `-engine ` (default "h1"). Accepts "h1" +// and "h2c"; any other value is a hard error so a typo in the runner's +// invocation fails fast and visibly rather than silently serving h1. +fn parse_engine_arg() -> Result { + let mut value: Option = None; + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + if a == "-engine" || a == "--engine" { + value = args.next(); + } else if let Some(rest) = a.strip_prefix("-engine=") { + value = Some(rest.to_string()); + } else if let Some(rest) = a.strip_prefix("--engine=") { + value = Some(rest.to_string()); + } + } + match value.as_deref() { + None | Some("h1") => Ok(Engine::H1), + Some("h2c") => Ok(Engine::H2c), + Some(other) => Err(format!( + "axum: unknown -engine {other:?} (want h1|h2c)" + )), + } +} + // shutdown_signal awaits SIGTERM or SIGINT. Returning from this future // triggers axum's graceful shutdown. async fn shutdown_signal() { @@ -118,6 +277,14 @@ async fn json_1k() -> Response { json_response(payload::json_1k()) } +async fn json_8k() -> Response { + json_response(payload::json_8k()) +} + +async fn json_16k() -> Response { + json_response(payload::json_16k()) +} + async fn json_64k() -> Response { json_response(payload::json_64k()) } diff --git a/servers/axum/src/payload.rs b/servers/axum/src/payload.rs index 7561af6..c398346 100644 --- a/servers/axum/src/payload.rs +++ b/servers/axum/src/payload.rs @@ -29,12 +29,22 @@ use std::sync::OnceLock; static JSON_1K: OnceLock> = OnceLock::new(); +static JSON_8K: OnceLock> = OnceLock::new(); +static JSON_16K: OnceLock> = OnceLock::new(); static JSON_64K: OnceLock> = OnceLock::new(); pub fn json_1k() -> &'static [u8] { JSON_1K.get_or_init(|| generate(1024)).as_slice() } +pub fn json_8k() -> &'static [u8] { + JSON_8K.get_or_init(|| generate(8192)).as_slice() +} + +pub fn json_16k() -> &'static [u8] { + JSON_16K.get_or_init(|| generate(16384)).as_slice() +} + pub fn json_64k() -> &'static [u8] { JSON_64K.get_or_init(|| generate(65536)).as_slice() } @@ -112,6 +122,16 @@ mod tests { assert_eq!(json_1k().len(), 1026); } + #[test] + fn json_8k_matches_go_size() { + assert_eq!(json_8k().len(), 8286); + } + + #[test] + fn json_16k_matches_go_size() { + assert_eq!(json_16k().len(), 16463); + } + #[test] fn json_64k_matches_go_size() { assert_eq!(json_64k().len(), 65618); diff --git a/servers/bunraw/package.json b/servers/bunraw/package.json new file mode 100644 index 0000000..3aa8390 --- /dev/null +++ b/servers/bunraw/package.json @@ -0,0 +1,17 @@ +{ + "name": "probatorium-bunraw-server", + "private": true, + "type": "module", + "//purpose": "Raw Bun.serve baseline — NO web framework. A single hand-rolled fetch(req) router over Bun.serve, the Bun analogue of the rust 'hyper' baseline. Hence zero runtime dependencies; only the Bun/TypeScript dev tooling.", + "//build": "Bundles src/server.ts into a single dist/server file via Bun's native bundler. --target=bun keeps Bun built-ins (Bun.serve, node:http2, ...) external; --minify shaves bytes; --sourcemap=none keeps deploys lean. The wrapper script written by the ansible build step then execs `bun run dist/server` so the runtime is Bun, not node.", + "//run": "`bun run dist/server` is the canonical entry — invoked by the launcher symlink the ansible build_native_competitor task creates. CLI args after `--` are forwarded to the script (process.argv).", + "//versions": "Always-latest policy: deps use unbounded `latest` so `bun install` resolves the newest cut at deploy time. Do NOT pin — the bench is meant to track upstream as-is.", + "scripts": { + "build": "bun build --target=bun --minify --sourcemap=none ./src/server.ts --outfile=./dist/server", + "start": "bun run dist/server" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "latest" + } +} diff --git a/servers/bunraw/src/h2c.ts b/servers/bunraw/src/h2c.ts new file mode 100644 index 0000000..aef04b5 --- /dev/null +++ b/servers/bunraw/src/h2c.ts @@ -0,0 +1,159 @@ +// HTTP/2 cleartext (h2c) prior-knowledge server for the Bun runtime. +// +// Why this exists +// --------------- +// Bun.serve speaks HTTP/1.1 natively and offers HTTP/2 ONLY behind TLS +// (the `tls:` option) — and HTTP/3 only behind TLS+QUIC. There is no +// cleartext-h2 switch on Bun.serve as of Bun 1.3.14 — verified via +// `bun --help` (the only http2 flag is `--experimental-http2-fetch`, +// which is the *client* h2-over-TLS-ALPN path, not a server option). So +// to serve h2c prior-knowledge we drop to Bun's node:http2 compatibility +// layer, whose `http2.createServer()` stands up a cleartext h2 listener. +// We then bridge each h2 stream to the raw WHATWG `fetch` handler so the +// route table and payloads stay byte-identical to the Bun.serve (h1) +// fast path. +// +// Bun-specific gotcha +// ------------------- +// Converting the inbound h2 request stream to a web ReadableStream via +// `Readable.toWeb(stream)` and handing it to `new Request(..., { body })` +// HANGS under Bun's node:http2 (the body never drains, /upload stalls). +// We therefore collect the request body manually off the node stream's +// "data"/"end" events into a Buffer before constructing the Request. GET +// requests carry no body and skip this entirely. +// +// This path is only taken for `-engine h2c`. `-engine h1` (or no flag) +// stays on Bun.serve and never imports this module's runtime cost. + +import http2 from "node:http2"; +import type { + Http2Server, + ServerHttp2Stream, + IncomingHttpHeaders, +} from "node:http2"; + +// FetchHandler is the WHATWG handler shape the raw-Bun server exposes: a +// Request in, a Response (or Promise) out. Same shape Hono (app.fetch) +// and Elysia (app.fetch) hand the h2c bridge in the framework adapters. +export type FetchHandler = (req: Request) => Response | Promise; + +export interface H2CListenResult { + hostname: string; + port: number; + stop: () => void; +} + +// serveH2C stands up a cleartext HTTP/2 (prior-knowledge) listener on +// host:port that dispatches every stream through `handler`. The returned +// promise resolves once the socket is listening, mirroring the synchronous +// readiness Bun.serve gives the h1 path. `port: 0` is honoured (the kernel +// assigns one) and the resolved port is reported back. +export function serveH2C( + host: string, + port: number, + handler: FetchHandler, +): Promise { + const server: Http2Server = http2.createServer(); + + server.on("stream", (stream, headers) => { + // node's "stream" event types the first arg as the base Http2Stream, + // but a server stream is always a ServerHttp2Stream (it carries + // respond()/headersSent — the half we use). Narrow it here. + void dispatch(stream as ServerHttp2Stream, headers, handler); + }); + + // A stream-level error (client RST, malformed frame) must not crash the + // process — the bench probes edge cases. Swallow it; the stream is gone. + server.on("error", () => {}); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => { + server.removeListener("error", reject); + const addr = server.address(); + const resolvedPort = + addr && typeof addr === "object" ? addr.port : port; + resolve({ + hostname: host, + port: resolvedPort, + stop: () => server.close(), + }); + }); + }); +} + +async function dispatch( + stream: ServerHttp2Stream, + headers: IncomingHttpHeaders, + handler: FetchHandler, +): Promise { + try { + const method = (headers[":method"] as string | undefined) ?? "GET"; + const path = (headers[":path"] as string | undefined) ?? "/"; + const authority = + (headers[":authority"] as string | undefined) ?? "127.0.0.1"; + const scheme = (headers[":scheme"] as string | undefined) ?? "http"; + const url = scheme + "://" + authority + path; + + // Copy non-pseudo headers across so the handler sees the real + // request headers (Content-Type on /upload, etc.). + const reqHeaders = new Headers(); + for (const key of Object.keys(headers)) { + if (key.startsWith(":")) continue; + const value = headers[key]; + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const v of value) reqHeaders.append(key, v); + } else { + reqHeaders.set(key, String(value)); + } + } + + const init: RequestInit = { method, headers: reqHeaders }; + const hasBody = method !== "GET" && method !== "HEAD"; + if (hasBody) { + // Manual drain — Readable.toWeb(stream) hangs under Bun's + // node:http2 (see module header). + init.body = await collectBody(stream); + } + + const response = await handler(new Request(url, init)); + + const outHeaders: Record = { + ":status": response.status, + }; + response.headers.forEach((value, key) => { + // node:http2 forbids connection-specific headers on h2 frames. + if (key === "connection" || key === "keep-alive") return; + outHeaders[key] = value; + }); + + const body = Buffer.from(await response.arrayBuffer()); + stream.respond(outHeaders); + stream.end(body); + } catch { + if (!stream.headersSent) { + try { + stream.respond({ ":status": 500 }); + } catch { + // stream already torn down — nothing to do. + } + } + try { + stream.end(); + } catch { + // ignore + } + } +} + +// collectBody pulls the inbound h2 request body off the node stream into a +// single Buffer. Used only for body-bearing methods (POST /upload). +function collectBody(stream: ServerHttp2Stream): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.on("end", () => resolve(Buffer.concat(chunks))); + stream.on("error", reject); + }); +} diff --git a/servers/bunraw/src/payload.ts b/servers/bunraw/src/payload.ts new file mode 100644 index 0000000..2aab169 --- /dev/null +++ b/servers/bunraw/src/payload.ts @@ -0,0 +1,91 @@ +// Deterministic 1/8/16/64 KiB JSON payload generator. +// +// Byte-identical port of probatorium/servers/common/payload.go. The Go +// reference uses encoding/json on (paginatedResponse, paginatedItem), +// which emits compact JSON with field order matching struct declaration +// order: page, per_page, total, total_pages, data for the wrapper, and +// id, name, email, status, created_at per item. +// +// We emit the bytes by hand here rather than via JSON.stringify on a +// JS object. Reasons: +// +// 1. Byte-for-byte equivalence with the Go reference is a hard +// conformance requirement (cmd/conformance does bytes::Equal). +// JSON.stringify and encoding/json agree on most things but offer +// no formal cross-language guarantee, so we own the bytes. +// 2. The corpus is tiny and pure-ASCII (no escape hazards), so the +// manual path is correct and trivially auditable. +// 3. Generated once at startup and reused for every request, so the +// build cost is irrelevant. +// +// Termination rule from the Go reference: append items until the +// marshalled length is at least targetSize. Resulting sizes: +// 1 KiB target -> 1026 bytes ending at item 9 +// 8 KiB target -> 8286 bytes ending at item 75 +// 16 KiB target -> 16463 bytes ending at item 147 +// 64 KiB target -> 65618 bytes ending at item 583 + +const HEADER = '{"page":1,"per_page":50,"total":1000,"total_pages":20,"data":['; +const FOOTER = "]}"; + +let json1k: Uint8Array | undefined; +let json8k: Uint8Array | undefined; +let json16k: Uint8Array | undefined; +let json64k: Uint8Array | undefined; + +export function json1KPayload(): Uint8Array { + if (!json1k) json1k = generate(1024); + return json1k; +} + +export function json8KPayload(): Uint8Array { + if (!json8k) json8k = generate(8192); + return json8k; +} + +export function json16KPayload(): Uint8Array { + if (!json16k) json16k = generate(16384); + return json16k; +} + +export function json64KPayload(): Uint8Array { + if (!json64k) json64k = generate(65536); + return json64k; +} + +function generate(targetSize: number): Uint8Array { + // Emit straight into a string then encode once at the end. Bun's + // string concatenation is rope-backed so per-iteration append is + // cheap; we'd lose nothing by switching to a Bun.ArrayBufferSink, but + // this runs exactly four times at process startup. + let buf = HEADER; + let i = 1; + // Match the Go termination rule: stop once buf + footer length is at + // least targetSize. The Go code does a full Marshal per iteration + // which is equivalent — the wrapper struct serialises to a fixed + // footer length, so length(buf) + length(footer) is exact. + while (true) { + if (i > 1) buf += ","; + buf += item(i); + if (buf.length + FOOTER.length >= targetSize) break; + i += 1; + } + buf += FOOTER; + // Pure-ASCII so byte length == char length; TextEncoder is correct + // and idiomatic regardless. + return new TextEncoder().encode(buf); +} + +function item(n: number): string { + // Order MUST match the Go struct declaration exactly: + // id, name, email, status, created_at. + return ( + '{"id":' + + n + + ',"name":"User ' + + n + + '","email":"user' + + n + + '@example.com","status":"active","created_at":"2024-01-15T09:30:00Z"}' + ); +} diff --git a/servers/bunraw/src/server.ts b/servers/bunraw/src/server.ts new file mode 100644 index 0000000..ec72e15 --- /dev/null +++ b/servers/bunraw/src/server.ts @@ -0,0 +1,279 @@ +// Probatorium bunraw adapter (wave 4b, bun runtime). +// +// The raw-Bun baseline: Bun.serve driven by a single hand-rolled +// `fetch(req)` handler with a manual (method, path) router — NO web +// framework (no Hono, no Elysia). This column is the floor the Bun +// framework adapters (hono, elysia) add their routing/abstraction cost +// on top of, the Bun analogue of the rust `hyper` baseline under axum / +// ntex. +// +// The router is deliberately minimal: an exact-match switch for the +// static endpoints plus two prefix checks for the only dynamic shapes in +// the contract (/users/:id, /upload). It does NOT use Bun.serve's +// `routes` table (Bun v1.2.3+) — that is Bun's own router and would make +// this a "Bun router" column rather than a raw fetch baseline. Hand +// matching keeps the measured cost to a string compare + a Response +// constructor and stays portable across Bun versions. +// +// CLI contract (matched by every probatorium adapter): +// -bind address:port to listen on (default 0.0.0.0:8080). +// Port 0 is supported and the resolved port is echoed +// back via the "ready addr=..." line. +// -engine "h1" (or absent) → Bun.serve HTTP/1.1 fast path. +// "h2c" → HTTP/2 cleartext prior-knowledge (node:http2). +// stdout "ready addr=" once listening. +// SIGTERM/SIGINT graceful shutdown within 5s (Bun.serve.stop(true)). +// +// argv parsing is hand-rolled because Bun supports `process.argv` but +// the canonical CLI form `bun run dist/server -- -bind 127.0.0.1:0` +// puts our flags after `--`; we walk the array looking for -bind / +// -engine to stay robust to either invocation shape. +// +// -engine flag: the bench passes `-engine ` only when the registry +// gives the adapter an Engine. The h1 column (bunraw) has none, so no +// -engine arrives — but we parse it defensively so the bunraw-h2 column +// (engine h2c-noupg, sharing this same launcher) works without a code +// change. "h1" (or absent) stays on the Bun.serve fast path. "h2c" serves +// HTTP/2 cleartext prior-knowledge via the node:http2 bridge in h2c.ts. +// Any other value exits non-zero with a clear message. + +import { + json1KPayload, + json8KPayload, + json16KPayload, + json64KPayload, +} from "./payload"; +import { serveH2C } from "./h2c"; + +const HELLO = new TextEncoder().encode("Hello, World!"); +const JSON_HELLO = new TextEncoder().encode('{"message":"Hello, World!"}'); +const OK = new TextEncoder().encode("OK"); +const JSON_1K = json1KPayload(); +const JSON_8K = json8KPayload(); +const JSON_16K = json16KPayload(); +const JSON_64K = json64KPayload(); + +const TEXT = "text/plain"; +const JSON_CT = "application/json"; + +// Frozen header objects reused across requests. A new Response is needed +// per request (the body stream is single-shot), but the header init +// objects are immutable and shared — no per-request header allocation +// beyond what the Response constructor copies internally. +const HDR_HELLO = { + "Content-Type": TEXT, + "Content-Length": String(HELLO.length), +} as const; +const HDR_JSON_HELLO = { + "Content-Type": JSON_CT, + "Content-Length": String(JSON_HELLO.length), +} as const; +const HDR_JSON_1K = { + "Content-Type": JSON_CT, + "Content-Length": String(JSON_1K.length), +} as const; +const HDR_JSON_8K = { + "Content-Type": JSON_CT, + "Content-Length": String(JSON_8K.length), +} as const; +const HDR_JSON_16K = { + "Content-Type": JSON_CT, + "Content-Length": String(JSON_16K.length), +} as const; +const HDR_JSON_64K = { + "Content-Type": JSON_CT, + "Content-Length": String(JSON_64K.length), +} as const; +const HDR_OK = { + "Content-Type": TEXT, + "Content-Length": String(OK.length), +} as const; + +const NOT_FOUND = new Response("Not Found", { status: 404 }); +const METHOD_NOT_ALLOWED = new Response("Method Not Allowed", { status: 405 }); + +// handle is the raw fetch router. Routes mirror servers/common/contract.go +// exactly. Static responses are pre-encoded Uint8Array buffers so the +// per-request work is just URL.pathname extraction, a switch, and a +// Response constructor — no JSON.stringify, no framework dispatch. +async function handle(req: Request): Promise { + // URL parsing is the documented way to read the path under Bun.serve. + // new URL(req.url).pathname strips the query string and decodes the + // path, matching how the Go router sees the request-target. + const path = new URL(req.url).pathname; + const method = req.method; + + if (method === "GET") { + switch (path) { + case "/": + return new Response(HELLO, { status: 200, headers: HDR_HELLO }); + case "/json": + return new Response(JSON_HELLO, { status: 200, headers: HDR_JSON_HELLO }); + case "/json-1k": + return new Response(JSON_1K, { status: 200, headers: HDR_JSON_1K }); + case "/json-8k": + return new Response(JSON_8K, { status: 200, headers: HDR_JSON_8K }); + case "/json-16k": + return new Response(JSON_16K, { status: 200, headers: HDR_JSON_16K }); + case "/json-64k": + return new Response(JSON_64K, { status: 200, headers: HDR_JSON_64K }); + } + // /users/:id — the one parametrised GET route. Echo the path segment + // verbatim, matching WritePath in servers/common/common.go + // ("User ID: "). + if (isUserPath(path)) { + const id = path.slice("/users/".length); + const body = "User ID: " + id; + return new Response(body, { status: 200, headers: { "Content-Type": TEXT } }); + } + } else if (method === "POST" && path === "/upload") { + // Drain request body so the body parser is part of the measured cost + // (every framework does this deliberately). Bun's Request.body is a + // ReadableStream; consuming arrayBuffer() exhausts it. + await req.arrayBuffer(); + return new Response(OK, { status: 200, headers: HDR_OK }); + } + + // No handler matched. Distinguish a wrong-method hit on a known path + // (405) from a genuinely unknown path (404), method-independently, so + // GET /upload and POST / both report 405 like the Go radix router's + // allowedMethods 405 detection. + if (isKnownPath(path)) return METHOD_NOT_ALLOWED; + return NOT_FOUND; +} + +// isUserPath reports whether path is a valid /users/:id capture: exactly +// one non-empty segment after /users/ (no trailing slash, no nesting), +// matching the Go radix router's :param semantics. +function isUserPath(path: string): boolean { + if (!path.startsWith("/users/")) return false; + const id = path.slice("/users/".length); + return id.length > 0 && !id.includes("/"); +} + +// isKnownPath reports whether path is one of the contract's routes, +// regardless of method — used to choose 405 vs 404 for an unmatched +// (method, path) pair. +function isKnownPath(path: string): boolean { + switch (path) { + case "/": + case "/json": + case "/json-1k": + case "/json-8k": + case "/json-16k": + case "/json-64k": + case "/upload": + return true; + } + return isUserPath(path); +} + +const { host, port } = parseBind(process.argv); +const engine = parseEngine(process.argv); + +if (engine === "h2c") { + // HTTP/2 cleartext prior-knowledge via node:http2 (see h2c.ts). Bun.serve + // has no cleartext-h2 server option as of Bun 1.3.14, so we bridge the h2 + // streams to the same `handle` fetch handler the h1 path uses. + const h2c = await serveH2C(host, port, handle); + console.log(`ready addr=${h2c.hostname}:${h2c.port}`); + + const shutdownH2C = (signal: string): void => { + console.log(`bunraw: received ${signal}, shutting down`); + h2c.stop(); + setTimeout(() => process.exit(0), 50); + }; + process.on("SIGTERM", () => shutdownH2C("SIGTERM")); + process.on("SIGINT", () => shutdownH2C("SIGINT")); +} else { + const server = Bun.serve({ + hostname: host, + port, + // reusePort lets the kernel SO_REUSEPORT load-balance across + // multiple Bun.serve workers if a future operator launches more + // than one process — harmless on a single-process bench. + reusePort: true, + fetch: handle, + }); + + // Bun.serve.port is the resolved port (kernel-assigned when the + // caller passed 0). Print the ready line in the exact shape every + // other adapter uses so the runner's TCP-probe loop can attach. + console.log(`ready addr=${server.hostname}:${server.port}`); + + const shutdown = (signal: string): void => { + console.log(`bunraw: received ${signal}, shutting down`); + // stop(true) closes idle keep-alives immediately; in-flight + // requests still get to drain. Bun resolves the returned promise + // when the listener is fully torn down, but we don't await it — + // the runner's 5s SIGKILL backstop is the upper bound. + server.stop(true); + // Give Bun's loop a tick to finish flushing logs, then exit. + setTimeout(() => process.exit(0), 50); + }; + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); +} + +// parseBind walks argv looking for -bind (the canonical probatorium +// flag). Falls back to BIND env var, then 0.0.0.0:8080. Accepts both +// `-bind 127.0.0.1:0` and `-bind=127.0.0.1:0`. Returns a {host, port} +// pair Bun.serve consumes directly — Bun resolves the hostname via +// getaddrinfo, so passing it as a string is fine. +function parseBind(argv: readonly string[]): { host: string; port: number } { + let raw = process.env["BIND"] ?? "0.0.0.0:8080"; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === undefined) continue; + if (a === "-bind" || a === "--bind") { + const v = argv[i + 1]; + if (v !== undefined) raw = v; + break; + } + if (a.startsWith("-bind=") || a.startsWith("--bind=")) { + raw = a.slice(a.indexOf("=") + 1); + break; + } + } + const idx = raw.lastIndexOf(":"); + if (idx < 0) { + throw new Error(`bunraw: invalid -bind value ${JSON.stringify(raw)}`); + } + const host = raw.slice(0, idx); + const port = Number(raw.slice(idx + 1)); + if (!Number.isFinite(port) || port < 0 || port > 65535) { + throw new Error(`bunraw: invalid port in -bind ${JSON.stringify(raw)}`); + } + return { host, port }; +} + +// parseEngine walks argv for -engine (accepts `-engine h1` and +// `-engine=h1`). Recognised values: +// "" / absent / "h1" → Bun.serve HTTP/1.1 fast path. +// "h2c" → HTTP/2 cleartext prior-knowledge (node:http2). +// Any other value is a hard error: better to fail loudly than silently +// serve the wrong protocol and skew the bench. The bunraw column gives no +// Engine, so this returns "h1" in practice — but the bunraw-h2 column +// (h2c-noupg) flows through here without further changes. +function parseEngine(argv: readonly string[]): "h1" | "h2c" { + let raw = ""; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === undefined) continue; + if (a === "-engine" || a === "--engine") { + raw = argv[i + 1] ?? ""; + break; + } + if (a.startsWith("-engine=") || a.startsWith("--engine=")) { + raw = a.slice(a.indexOf("=") + 1); + break; + } + } + if (raw === "" || raw === "h1") return "h1"; + if (raw === "h2c") return "h2c"; + console.error( + `bunraw: unsupported -engine ${JSON.stringify(raw)} ` + + `(supported: h1, h2c)`, + ); + process.exit(2); +} diff --git a/servers/bunraw/tsconfig.json b/servers/bunraw/tsconfig.json new file mode 100644 index 0000000..6baad07 --- /dev/null +++ b/servers/bunraw/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2024"], + "types": ["bun-types"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/servers/celeris/go.mod b/servers/celeris/go.mod index 3c04cef..a46cc7a 100644 --- a/servers/celeris/go.mod +++ b/servers/celeris/go.mod @@ -3,7 +3,7 @@ module github.com/goceleris/probatorium/servers/celeris go 1.26.4 require ( - github.com/goceleris/celeris v1.5.2 + github.com/goceleris/celeris v1.5.3 github.com/goceleris/probatorium v0.0.0-00010101000000-000000000000 ) diff --git a/servers/celeris/go.sum b/servers/celeris/go.sum index 54d6e42..f317e88 100644 --- a/servers/celeris/go.sum +++ b/servers/celeris/go.sum @@ -1,5 +1,5 @@ -github.com/goceleris/celeris v1.5.2 h1:oQLMkpoRDKB40kyk5m10QnrwMEVYxc1SVOGvUu9Cw8Q= -github.com/goceleris/celeris v1.5.2/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= +github.com/goceleris/celeris v1.5.3 h1:n3/2xAqwqyMG0c9g1bP+F2KdjZngCOgGFHup1f3fRY4= +github.com/goceleris/celeris v1.5.3/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= diff --git a/servers/celeris/server.go b/servers/celeris/server.go index 708592b..50d584c 100644 --- a/servers/celeris/server.go +++ b/servers/celeris/server.go @@ -67,6 +67,36 @@ var engineSpecs = map[string]engineSpec{ protocol: celeris.HTTP1, async: false, }, + // Adaptive meta-engine (the v1.5.x focus): starts epoll, promotes new + // conns to io_uring under sustained load. h1 + auto(h2c) variants. + "adaptive-h1-async": { + engineType: celeris.Adaptive, + protocol: celeris.HTTP1, + async: true, + }, + "adaptive-auto+upg-async": { + engineType: celeris.Adaptive, + protocol: celeris.Auto, + async: true, + }, + // Engine × sync/async grid completers so the matrix can isolate the + // sync-vs-async axis on a fixed engine (and h2c on epoll, which the + // original 4 columns could not express). + "epoll-h1-async": { + engineType: celeris.Epoll, + protocol: celeris.HTTP1, + async: true, + }, + "epoll-auto+upg-async": { + engineType: celeris.Epoll, + protocol: celeris.Auto, + async: true, + }, + "iouring-h1-sync": { + engineType: celeris.IOUring, + protocol: celeris.HTTP1, + async: false, + }, } func main() { @@ -133,6 +163,12 @@ func registerRoutes(srv *celeris.Server) { srv.GET("/json-1k", func(c *celeris.Context) error { return c.Blob(200, "application/json", common.JSON1KPayload()) }) + srv.GET("/json-8k", func(c *celeris.Context) error { + return c.Blob(200, "application/json", common.JSON8KPayload()) + }) + srv.GET("/json-16k", func(c *celeris.Context) error { + return c.Blob(200, "application/json", common.JSON16KPayload()) + }) srv.GET("/json-64k", func(c *celeris.Context) error { return c.Blob(200, "application/json", common.JSON64KPayload()) }) diff --git a/servers/chi/go.mod b/servers/chi/go.mod index e2099b9..7161b78 100644 --- a/servers/chi/go.mod +++ b/servers/chi/go.mod @@ -3,7 +3,7 @@ module github.com/goceleris/probatorium/servers/chi go 1.26.4 require ( - github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf + github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c github.com/go-chi/chi/v5 v5.3.0 github.com/goceleris/probatorium v0.0.0-00010101000000-000000000000 github.com/google/uuid v1.6.0 diff --git a/servers/chi/go.sum b/servers/chi/go.sum index bfe98b4..bf46763 100644 --- a/servers/chi/go.sum +++ b/servers/chi/go.sum @@ -1,5 +1,5 @@ -github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I= -github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= +github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c h1:6Gpm9YYUEQx2T9zMsYolQhr6sjwwGtFitSA0pQsa7a8= +github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= diff --git a/servers/chi/server.go b/servers/chi/server.go index f137b32..1ccd785 100644 --- a/servers/chi/server.go +++ b/servers/chi/server.go @@ -69,6 +69,12 @@ func registerRoutes(r chi.Router) { r.Get("/json-1k", func(w http.ResponseWriter, _ *http.Request) { common.WriteJSON1K(w) }) + r.Get("/json-8k", func(w http.ResponseWriter, _ *http.Request) { + common.WriteJSON8K(w) + }) + r.Get("/json-16k", func(w http.ResponseWriter, _ *http.Request) { + common.WriteJSON16K(w) + }) r.Get("/json-64k", func(w http.ResponseWriter, _ *http.Request) { common.WriteJSON64K(w) }) diff --git a/servers/common/common.go b/servers/common/common.go index cbadc41..0e7f695 100644 --- a/servers/common/common.go +++ b/servers/common/common.go @@ -49,6 +49,23 @@ func WriteJSON1K(w http.ResponseWriter) { _, _ = w.Write(json1KPayload) } +// WriteJSON8K writes the pre-computed ~8 KiB JSON payload (mid-size, below +// the 20G fabric ceiling — see [JSON8KPayload]). +func WriteJSON8K(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(json8KPayload))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(json8KPayload) +} + +// WriteJSON16K writes the pre-computed ~16 KiB JSON payload (mid-size). +func WriteJSON16K(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(json16KPayload))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(json16KPayload) +} + // WriteJSON64K writes the pre-computed ~64 KiB JSON payload. func WriteJSON64K(w http.ResponseWriter) { w.Header().Set("Content-Type", "application/json") diff --git a/servers/common/common_test.go b/servers/common/common_test.go index f6dc2d2..57ba166 100644 --- a/servers/common/common_test.go +++ b/servers/common/common_test.go @@ -109,7 +109,7 @@ func TestEndpoints_ContractStable(t *testing.T) { // Adapters iterate Endpoints in stable order at registration time. // Any reorder breaks adapter authors' assumptions; any new path // without a corresponding adapter helper breaks the contract. - wantPaths := []string{"/", "/json", "/json-1k", "/json-64k", "/users/:id", "/upload"} + wantPaths := []string{"/", "/json", "/json-1k", "/json-8k", "/json-16k", "/json-64k", "/users/:id", "/upload"} if len(Endpoints) != len(wantPaths) { t.Fatalf("Endpoints count: got %d, want %d", len(Endpoints), len(wantPaths)) } diff --git a/servers/common/contract.go b/servers/common/contract.go index 53fb609..5c5b8ba 100644 --- a/servers/common/contract.go +++ b/servers/common/contract.go @@ -68,6 +68,20 @@ var Endpoints = []Endpoint{ // generator so the slice header is byte-identical to what the // per-framework helpers serve. }, + { + Method: "GET", + Path: "/json-8k", + ResponseContentType: "application/json", + // ResponseBody filled in init(). Mid-size payload: bridges the + // 1k→64k gap so the bench keeps differentiating adapters below the + // 20G fabric ceiling that makes the 64k cells NIC-bound. + }, + { + Method: "GET", + Path: "/json-16k", + ResponseContentType: "application/json", + // ResponseBody filled in init(). Mid-size payload (see /json-8k). + }, { Method: "GET", Path: "/json-64k", @@ -106,6 +120,10 @@ func init() { switch Endpoints[i].Path { case "/json-1k": Endpoints[i].ResponseBody = generateJSONPayload(1024) + case "/json-8k": + Endpoints[i].ResponseBody = generateJSONPayload(8192) + case "/json-16k": + Endpoints[i].ResponseBody = generateJSONPayload(16384) case "/json-64k": Endpoints[i].ResponseBody = generateJSONPayload(65536) } diff --git a/servers/common/payload.go b/servers/common/payload.go index 07411e2..0d6698c 100644 --- a/servers/common/payload.go +++ b/servers/common/payload.go @@ -12,11 +12,15 @@ import ( // throughput axis of the report. var ( json1KPayload []byte + json8KPayload []byte + json16KPayload []byte json64KPayload []byte ) func init() { json1KPayload = generateJSONPayload(1024) + json8KPayload = generateJSONPayload(8192) + json16KPayload = generateJSONPayload(16384) json64KPayload = generateJSONPayload(65536) } @@ -69,6 +73,18 @@ func generateJSONPayload(targetSize int) []byte { // is shared — callers must not mutate it. func JSON1KPayload() []byte { return json1KPayload } +// JSON8KPayload returns the pre-computed ~8 KiB JSON payload. The mid-size +// payloads (8K/16K) bridge the 1K→64K gap: on the 20G LACP fabric the 64K +// cells are NIC-bound (all fast adapters converge at the line rate), so the +// mid sizes stay under the ceiling and keep differentiating adapters by CPU +// throughput. The slice is shared — callers must not mutate it. +func JSON8KPayload() []byte { return json8KPayload } + +// JSON16KPayload returns the pre-computed ~16 KiB JSON payload. See +// [JSON8KPayload] for the mid-size rationale. The slice is shared — callers +// must not mutate it. +func JSON16KPayload() []byte { return json16KPayload } + // JSON64KPayload returns the pre-computed ~64 KiB JSON payload. The // slice is shared — callers must not mutate it. func JSON64KPayload() []byte { return json64KPayload } diff --git a/servers/drogon/src/main.cc b/servers/drogon/src/main.cc index 8b0470a..2003ebf 100644 --- a/servers/drogon/src/main.cc +++ b/servers/drogon/src/main.cc @@ -9,6 +9,8 @@ // GET / -> "Hello, World!" text/plain // GET /json -> {"message":"Hello, World!"} application/json // GET /json-1k -> deterministic 1026-byte JSON page +// GET /json-8k -> deterministic 8286-byte JSON page +// GET /json-16k -> deterministic 16463-byte JSON page // GET /json-64k -> deterministic 65618-byte JSON page // GET /users/{id} -> "User ID: " text/plain // POST /upload -> read-and-discard body, reply "OK" text/plain @@ -17,8 +19,17 @@ // out of scope (Capabilities all-false: static + concurrency scenarios // only), so they intentionally 404 — the scenario applicability filter in // servers/servers.go skips drogon for those classes. +// +// CLI: `{bin} -bind [-engine h1|h2c]`. -engine selects the wire +// protocol: "h1" (default) serves plain HTTP/1.1; "h2c" would serve +// HTTP/2 cleartext prior-knowledge, but drogon does not support it (see +// the -engine handling in main() for the API investigation) so it +// fails fast with a non-zero exit, letting the bench's bind-gate record +// the drogon-h2 column as not-applicable/DNF instead of silently +// serving h1. #include +#include #include #include @@ -39,6 +50,8 @@ namespace { // Termination rule mirrors Go: append items until the full marshalled // length (header + items + footer) crosses targetSize. Resulting sizes: // 1 KiB target -> 1026 bytes +// 8 KiB target -> 8286 bytes +// 16 KiB target -> 16463 bytes // 64 KiB target -> 65618 bytes std::string generateJSONPayload(std::size_t targetSize) { static const std::string header = @@ -81,6 +94,7 @@ HttpResponsePtr makeResponse(std::string body, drogon::ContentType ct) { int main(int argc, char *argv[]) { std::string bind = "127.0.0.1:8080"; + std::string engine = "h1"; for (int i = 1; i < argc; ++i) { const std::string arg = argv[i]; if ((arg == "-bind" || arg == "--bind") && i + 1 < argc) { @@ -89,10 +103,62 @@ int main(int argc, char *argv[]) { bind = arg.substr(6); } else if (arg.rfind("--bind=", 0) == 0) { bind = arg.substr(7); + } else if ((arg == "-engine" || arg == "--engine") && i + 1 < argc) { + engine = argv[++i]; + } else if (arg.rfind("-engine=", 0) == 0) { + engine = arg.substr(8); + } else if (arg.rfind("--engine=", 0) == 0) { + engine = arg.substr(9); } } + // Wire-protocol selection. The bench launches + // ./drogon -bind -engine + // and gates "up" on the `ready addr=` stdout line. Anything other + // than a successful bind+ready must exit non-zero so the bind-gate fails + // fast rather than recording a mislabelled result. + // + // h1 (or absent) -> plain HTTP/1.1, exactly as before. + // h2c -> HTTP/2 cleartext prior-knowledge — NOT supported. + // + // h2c investigation (drogon 1.9.13, the version the cpp role installs): + // drogon has no server-side HTTP/2 of any kind, cleartext or TLS. The + // public API was inspected directly: + // * `enum class Version` contains only kHttp10 and + // kHttp11 — there is no kHttp2, so the request parser / response + // serializer cannot speak the HTTP/2 framing layer at all. + // * addListener() exposes only + // useSSL/certFile/keyFile/useOldTLS/sslConfCmds — no protocol toggle, + // no h2c / prior-knowledge / "enableH2" listener flag, and there is no + // app-level enableHttp2()/setHttp2()-style method anywhere in the + // header. enableServerHeader()/setServerHeaderField() only touch the + // `Server:` response header, not the protocol. + // * The only HTTP/2-adjacent surface in the whole include tree is + // setAlpnProtocols(), i.e. TLS/ALPN + // negotiation — TLS-oriented and irrelevant to cleartext h2c + // prior-knowledge (which by definition skips ALPN and TLS entirely). + // Conclusion: this drogon build cannot serve h2c prior-knowledge, so we + // fail fast instead of silently downgrading to h1 (which would corrupt + // the drogon-h2 column by reporting h1 numbers under an h2 label). + if (engine == "h2c") { + std::fprintf(stderr, + "drogon: h2c not supported by this drogon build " + "(drogon %s exposes no server-side HTTP/2; " + "Version enum is kHttp10/kHttp11 only, addListener has no " + "h2c flag) — refusing to serve h1 under an h2c label\n", + DROGON_VERSION); + return 2; + } + if (engine != "h1") { + std::fprintf(stderr, + "drogon: unknown -engine value %s (want h1 or h2c)\n", + engine.c_str()); + return 2; + } + const std::string json1k = generateJSONPayload(1024); + const std::string json8k = generateJSONPayload(8192); + const std::string json16k = generateJSONPayload(16384); const std::string json64k = generateJSONPayload(65536); std::string host = bind; @@ -133,6 +199,22 @@ int main(int argc, char *argv[]) { }, {drogon::Get}); + app.registerHandler( + "/json-8k", + [&json8k](const HttpRequestPtr &, + std::function &&callback) { + callback(makeResponse(json8k, drogon::CT_APPLICATION_JSON)); + }, + {drogon::Get}); + + app.registerHandler( + "/json-16k", + [&json16k](const HttpRequestPtr &, + std::function &&callback) { + callback(makeResponse(json16k, drogon::CT_APPLICATION_JSON)); + }, + {drogon::Get}); + app.registerHandler( "/json-64k", [&json64k](const HttpRequestPtr &, diff --git a/servers/echo/server.go b/servers/echo/server.go index d88aaeb..0080215 100644 --- a/servers/echo/server.go +++ b/servers/echo/server.go @@ -76,6 +76,12 @@ func registerRoutes(e *echo.Echo) { e.GET("/json-1k", func(c echo.Context) error { return c.Blob(http.StatusOK, "application/json", common.JSON1KPayload()) }) + e.GET("/json-8k", func(c echo.Context) error { + return c.Blob(http.StatusOK, "application/json", common.JSON8KPayload()) + }) + e.GET("/json-16k", func(c echo.Context) error { + return c.Blob(http.StatusOK, "application/json", common.JSON16KPayload()) + }) e.GET("/json-64k", func(c echo.Context) error { return c.Blob(http.StatusOK, "application/json", common.JSON64KPayload()) }) diff --git a/servers/elysia/src/h2c.ts b/servers/elysia/src/h2c.ts new file mode 100644 index 0000000..9d0a50b --- /dev/null +++ b/servers/elysia/src/h2c.ts @@ -0,0 +1,157 @@ +// HTTP/2 cleartext (h2c) prior-knowledge server for the Bun runtime. +// +// Why this exists +// --------------- +// Bun.serve speaks HTTP/1.1 natively and offers HTTP/2 ONLY behind TLS +// (the `tls:` option). There is no cleartext-h2 switch on Bun.serve as of +// Bun 1.3.14 — verified via `bun --help` (the only http2 flag is +// `--experimental-http2-fetch`, which is the *client* h2-over-TLS-ALPN +// path, not a server option). So to serve h2c prior-knowledge we drop to +// Bun's node:http2 compatibility layer, whose `http2.createServer()` +// stands up a cleartext h2 listener. We then bridge each h2 stream to the +// framework's WHATWG `fetch` handler so the route table and payloads stay +// byte-identical to the Bun.serve (h1) fast path. +// +// Bun-specific gotcha +// ------------------- +// Converting the inbound h2 request stream to a web ReadableStream via +// `Readable.toWeb(stream)` and handing it to `new Request(..., { body })` +// HANGS under Bun's node:http2 (the body never drains, /upload stalls). +// We therefore collect the request body manually off the node stream's +// "data"/"end" events into a Buffer before constructing the Request. GET +// requests carry no body and skip this entirely. +// +// This path is only taken for `-engine h2c`. `-engine h1` (or no flag) +// stays on Bun.serve and never imports this module's runtime cost. + +import http2 from "node:http2"; +import type { + Http2Server, + ServerHttp2Stream, + IncomingHttpHeaders, +} from "node:http2"; + +// FetchHandler is the WHATWG handler shape both Hono (app.fetch) and the +// other Bun adapters expose: a Request in, a Response (or Promise) out. +export type FetchHandler = (req: Request) => Response | Promise; + +export interface H2CListenResult { + hostname: string; + port: number; + stop: () => void; +} + +// serveH2C stands up a cleartext HTTP/2 (prior-knowledge) listener on +// host:port that dispatches every stream through `handler`. The returned +// promise resolves once the socket is listening, mirroring the synchronous +// readiness Bun.serve gives the h1 path. `port: 0` is honoured (the kernel +// assigns one) and the resolved port is reported back. +export function serveH2C( + host: string, + port: number, + handler: FetchHandler, +): Promise { + const server: Http2Server = http2.createServer(); + + server.on("stream", (stream, headers) => { + // node's "stream" event types the first arg as the base Http2Stream, + // but a server stream is always a ServerHttp2Stream (it carries + // respond()/headersSent — the half we use). Narrow it here. + void dispatch(stream as ServerHttp2Stream, headers, handler); + }); + + // A stream-level error (client RST, malformed frame) must not crash the + // process — the bench probes edge cases. Swallow it; the stream is gone. + server.on("error", () => {}); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => { + server.removeListener("error", reject); + const addr = server.address(); + const resolvedPort = + addr && typeof addr === "object" ? addr.port : port; + resolve({ + hostname: host, + port: resolvedPort, + stop: () => server.close(), + }); + }); + }); +} + +async function dispatch( + stream: ServerHttp2Stream, + headers: IncomingHttpHeaders, + handler: FetchHandler, +): Promise { + try { + const method = (headers[":method"] as string | undefined) ?? "GET"; + const path = (headers[":path"] as string | undefined) ?? "/"; + const authority = + (headers[":authority"] as string | undefined) ?? "127.0.0.1"; + const scheme = (headers[":scheme"] as string | undefined) ?? "http"; + const url = scheme + "://" + authority + path; + + // Copy non-pseudo headers across so the framework sees the real + // request headers (Content-Type on /upload, etc.). + const reqHeaders = new Headers(); + for (const key of Object.keys(headers)) { + if (key.startsWith(":")) continue; + const value = headers[key]; + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const v of value) reqHeaders.append(key, v); + } else { + reqHeaders.set(key, String(value)); + } + } + + const init: RequestInit = { method, headers: reqHeaders }; + const hasBody = method !== "GET" && method !== "HEAD"; + if (hasBody) { + // Manual drain — Readable.toWeb(stream) hangs under Bun's + // node:http2 (see module header). + init.body = await collectBody(stream); + } + + const response = await handler(new Request(url, init)); + + const outHeaders: Record = { + ":status": response.status, + }; + response.headers.forEach((value, key) => { + // node:http2 forbids connection-specific headers on h2 frames. + if (key === "connection" || key === "keep-alive") return; + outHeaders[key] = value; + }); + + const body = Buffer.from(await response.arrayBuffer()); + stream.respond(outHeaders); + stream.end(body); + } catch { + if (!stream.headersSent) { + try { + stream.respond({ ":status": 500 }); + } catch { + // stream already torn down — nothing to do. + } + } + try { + stream.end(); + } catch { + // ignore + } + } +} + +// collectBody pulls the inbound h2 request body off the node stream into a +// single Buffer. Used only for body-bearing methods (POST /upload). +function collectBody(stream: ServerHttp2Stream): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.on("end", () => resolve(Buffer.concat(chunks))); + stream.on("error", reject); + }); +} diff --git a/servers/elysia/src/payload.ts b/servers/elysia/src/payload.ts index c8e3683..2788a45 100644 --- a/servers/elysia/src/payload.ts +++ b/servers/elysia/src/payload.ts @@ -1,4 +1,4 @@ -// Deterministic 1 KiB / 64 KiB JSON payload generator. +// Deterministic 1/8/16/64 KiB JSON payload generator. // // Byte-identical port of probatorium/servers/common/payload.go. The Go // reference uses encoding/json on (paginatedResponse, paginatedItem), @@ -21,12 +21,16 @@ // Termination rule from the Go reference: append items until the // marshalled length is at least targetSize. Resulting sizes: // 1 KiB target -> 1026 bytes ending at item 9 +// 8 KiB target -> 8286 bytes ending at item 75 +// 16 KiB target -> 16463 bytes ending at item 147 // 64 KiB target -> 65618 bytes ending at item 583 const HEADER = '{"page":1,"per_page":50,"total":1000,"total_pages":20,"data":['; const FOOTER = "]}"; let json1k: Uint8Array | undefined; +let json8k: Uint8Array | undefined; +let json16k: Uint8Array | undefined; let json64k: Uint8Array | undefined; export function json1KPayload(): Uint8Array { @@ -34,6 +38,16 @@ export function json1KPayload(): Uint8Array { return json1k; } +export function json8KPayload(): Uint8Array { + if (!json8k) json8k = generate(8192); + return json8k; +} + +export function json16KPayload(): Uint8Array { + if (!json16k) json16k = generate(16384); + return json16k; +} + export function json64KPayload(): Uint8Array { if (!json64k) json64k = generate(65536); return json64k; diff --git a/servers/elysia/src/server.ts b/servers/elysia/src/server.ts index 6e8fc3a..e5abff0 100644 --- a/servers/elysia/src/server.ts +++ b/servers/elysia/src/server.ts @@ -18,14 +18,29 @@ // "ready addr=...". // stdout "ready addr=" once listening. // SIGTERM graceful shutdown within 5s. +// +// -engine flag: the bench passes `-engine ` only when the registry +// gives the adapter an Engine. Today elysia has none, so no -engine arrives +// — but we parse it defensively so an added Engine works without a code +// change. "h1" (or absent) stays on the Bun.serve fast path. "h2c" serves +// HTTP/2 cleartext prior-knowledge via the node:http2 bridge in h2c.ts. +// Any other value exits non-zero with a clear message. import { Elysia } from "elysia"; -import { json1KPayload, json64KPayload } from "./payload"; +import { + json1KPayload, + json8KPayload, + json16KPayload, + json64KPayload, +} from "./payload"; +import { serveH2C } from "./h2c"; const HELLO = new TextEncoder().encode("Hello, World!"); const JSON_HELLO = new TextEncoder().encode('{"message":"Hello, World!"}'); const OK = new TextEncoder().encode("OK"); const JSON_1K = json1KPayload(); +const JSON_8K = json8KPayload(); +const JSON_16K = json16KPayload(); const JSON_64K = json64KPayload(); const TEXT = "text/plain"; @@ -64,6 +79,22 @@ const app = new Elysia() headers: { ...headersJSON, "Content-Length": String(JSON_1K.length) }, }), ) + .get( + "/json-8k", + () => + new Response(JSON_8K, { + status: 200, + headers: { ...headersJSON, "Content-Length": String(JSON_8K.length) }, + }), + ) + .get( + "/json-16k", + () => + new Response(JSON_16K, { + status: 200, + headers: { ...headersJSON, "Content-Length": String(JSON_16K.length) }, + }), + ) .get( "/json-64k", () => @@ -92,23 +123,40 @@ const app = new Elysia() }); const { host, port } = parseBind(process.argv); +const engine = parseEngine(process.argv); -const server = Bun.serve({ - hostname: host, - port, - reusePort: true, - fetch: app.fetch, -}); +if (engine === "h2c") { + // HTTP/2 cleartext prior-knowledge via node:http2 (see h2c.ts). Bun.serve + // has no cleartext-h2 server option as of Bun 1.3.14, so we bridge the h2 + // streams to the same app.fetch handler the h1 path uses. + const h2c = await serveH2C(host, port, app.fetch); + console.log(`ready addr=${h2c.hostname}:${h2c.port}`); -console.log(`ready addr=${server.hostname}:${server.port}`); + const shutdownH2C = (signal: string): void => { + console.log(`elysia: received ${signal}, shutting down`); + h2c.stop(); + setTimeout(() => process.exit(0), 50); + }; + process.on("SIGTERM", () => shutdownH2C("SIGTERM")); + process.on("SIGINT", () => shutdownH2C("SIGINT")); +} else { + const server = Bun.serve({ + hostname: host, + port, + reusePort: true, + fetch: app.fetch, + }); -const shutdown = (signal: string): void => { - console.log(`elysia: received ${signal}, shutting down`); - server.stop(true); - setTimeout(() => process.exit(0), 50); -}; -process.on("SIGTERM", () => shutdown("SIGTERM")); -process.on("SIGINT", () => shutdown("SIGINT")); + console.log(`ready addr=${server.hostname}:${server.port}`); + + const shutdown = (signal: string): void => { + console.log(`elysia: received ${signal}, shutting down`); + server.stop(true); + setTimeout(() => process.exit(0), 50); + }; + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); +} function parseBind(argv: readonly string[]): { host: string; port: number } { let raw = process.env["BIND"] ?? "0.0.0.0:8080"; @@ -136,3 +184,34 @@ function parseBind(argv: readonly string[]): { host: string; port: number } { } return { host, port }; } + +// parseEngine walks argv for -engine (accepts `-engine h1` and +// `-engine=h1`). Recognised values: +// "" / absent / "h1" → Bun.serve HTTP/1.1 fast path. +// "h2c" → HTTP/2 cleartext prior-knowledge (node:http2). +// Any other value is a hard error: better to fail loudly than silently +// serve the wrong protocol and skew the bench. The registry currently +// gives elysia no Engine, so this returns "h1" in practice — but an added +// Engine flows through here without further changes. +function parseEngine(argv: readonly string[]): "h1" | "h2c" { + let raw = ""; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === undefined) continue; + if (a === "-engine" || a === "--engine") { + raw = argv[i + 1] ?? ""; + break; + } + if (a.startsWith("-engine=") || a.startsWith("--engine=")) { + raw = a.slice(a.indexOf("=") + 1); + break; + } + } + if (raw === "" || raw === "h1") return "h1"; + if (raw === "h2c") return "h2c"; + console.error( + `elysia: unsupported -engine ${JSON.stringify(raw)} ` + + `(supported: h1, h2c)`, + ); + process.exit(2); +} diff --git a/servers/express/package.json b/servers/express/package.json new file mode 100644 index 0000000..acaba0a --- /dev/null +++ b/servers/express/package.json @@ -0,0 +1,18 @@ +{ + "name": "probatorium-express-server", + "private": true, + "type": "commonjs", + "//purpose": "Express 5 adapter — the Node.js baseline. Express on the stock Node.js http server (HTTP/1.1 only). Node-runtime counterpart to the bun adapters and the python fastapi adapter. Source is plain CommonJS run directly by node — NO bundler, NO transpile step.", + "//build": "node has no build step for this adapter: `npm ci` (or `npm install`) populates node_modules from the resolved express, then the launcher execs `node src/server.js`. The ansible node role writes a POSIX-sh launcher next to src/ that runs the bench-installed node on src/server.js, forwarding argv.", + "//run": "`node src/server.js -bind [-engine h1]` is the canonical entry. The launcher symlinked into competitors/express forwards the runner's -bind flag.", + "//versions": "Always-latest policy: express uses unbounded `latest` so `npm install` resolves the newest stable cut at deploy time. Do NOT pin — the bench tracks upstream as-is.", + "engines": { + "node": ">=18" + }, + "scripts": { + "start": "node src/server.js" + }, + "dependencies": { + "express": "latest" + } +} diff --git a/servers/express/src/payload.js b/servers/express/src/payload.js new file mode 100644 index 0000000..d875708 --- /dev/null +++ b/servers/express/src/payload.js @@ -0,0 +1,91 @@ +// Deterministic 1/8/16/64 KiB JSON payload generator. +// +// Byte-identical port of probatorium/servers/common/payload.go. The Go +// reference runs encoding/json over (paginatedResponse, paginatedItem), +// which emits compact JSON with field order matching struct declaration +// order: page, per_page, total, total_pages, data for the wrapper, and +// id, name, email, status, created_at per item. +// +// As in the bun adapters, the bytes are emitted by hand rather than via +// JSON.stringify on a JS object: byte-for-byte equivalence with the Go +// reference is a hard conformance requirement (cmd/conformance does a +// bytes-equal compare), and the two encoders carry no formal +// cross-language guarantee — so this module owns the bytes. The corpus is +// pure ASCII (no escape hazards) and is generated once at startup, so the +// manual path is both correct and trivially auditable. +// +// Termination rule from the Go reference: append items until the +// marshalled length is at least targetSize. Resulting sizes (the cluster +// conformance probe byte-compares these): 1k=1026, 8k=8286, 16k=16463, +// 64k=65618 bytes. + +"use strict"; + +const HEADER = + '{"page":1,"per_page":50,"total":1000,"total_pages":20,"data":['; +const FOOTER = "]}"; + +function item(n) { + // Order MUST match the Go struct declaration exactly: + // id, name, email, status, created_at. + return ( + '{"id":' + + n + + ',"name":"User ' + + n + + '","email":"user' + + n + + '@example.com","status":"active","created_at":"2024-01-15T09:30:00Z"}' + ); +} + +function generate(targetSize) { + // Build the string then encode once. The wrapper struct serialises to a + // fixed-length footer, so length(buf) + length(FOOTER) is the exact + // marshalled length — same termination predicate as the Go reference's + // per-iteration json.Marshal. + let buf = HEADER; + let i = 1; + while (true) { + if (i > 1) buf += ","; + buf += item(i); + if (buf.length + FOOTER.length >= targetSize) break; + i += 1; + } + buf += FOOTER; + // Pure ASCII, so a latin1/utf-8 Buffer is byte-identical; Buffer.from + // defaults to utf-8 which is correct here. + return Buffer.from(buf, "utf-8"); +} + +let json1k; +let json8k; +let json16k; +let json64k; + +function json1KPayload() { + if (!json1k) json1k = generate(1024); + return json1k; +} + +function json8KPayload() { + if (!json8k) json8k = generate(8192); + return json8k; +} + +function json16KPayload() { + if (!json16k) json16k = generate(16384); + return json16k; +} + +function json64KPayload() { + if (!json64k) json64k = generate(65536); + return json64k; +} + +module.exports = { + json1KPayload, + json8KPayload, + json16KPayload, + json64KPayload, +}; diff --git a/servers/express/src/server.js b/servers/express/src/server.js new file mode 100644 index 0000000..1e4905e --- /dev/null +++ b/servers/express/src/server.js @@ -0,0 +1,223 @@ +// Probatorium express adapter — the Node.js baseline. +// +// Express 5 on the stock Node.js `http` server. This is the Node-runtime +// counterpart to the bun adapters (hono/elysia/bunraw) and the python +// adapter (fastapi): a mainstream, batteries-included web framework on its +// language's default runtime, serving the canonical contract from +// servers/common/contract.go: +// +// GET / -> "Hello, World!" text/plain +// GET /json -> {"message":"Hello, World!"} application/json +// GET /json-1k -> deterministic 1026-byte JSON page +// GET /json-8k -> deterministic 8286-byte JSON page +// GET /json-16k -> deterministic 16463-byte JSON page +// GET /json-64k -> deterministic 65618-byte JSON page +// GET /users/:id -> "User ID: " text/plain +// POST /upload -> read-and-discard body, reply "OK" text/plain +// +// Driver-backed (/db, /cache, /mc, /session) and chain-* endpoints are +// out of scope for this adapter (Capabilities = Static only), so the +// scenario applicability filter never dials them here. +// +// CLI contract (matched by every probatorium adapter): +// -bind default 127.0.0.1:8080. Pass `:0` (or `host:0`) to +// let the kernel allocate a port; the bound address +// is reported on stdout via the `ready addr=` +// line the runner's TCP probe waits for. +// -engine default "h1". Only "h1" (plain HTTP/1.1) is +// supported: Express is built on Node's HTTP/1.x +// `http` server and has no HTTP/2 request/response +// shim, so there is no cheap h2c path. Any other +// value (including "h2c") exits non-zero rather than +// silently serving h1 and skewing the bench. +// +// Lifecycle: SIGTERM / SIGINT trigger a graceful shutdown — stop +// accepting, close idle keep-alive sockets, drain in-flight requests, and +// exit. The runner's 5s SIGKILL backstop is the upper bound, so a short +// hard-close timeout keeps us well inside it. + +"use strict"; + +const express = require("express"); +const { + json1KPayload, + json8KPayload, + json16KPayload, + json64KPayload, +} = require("./payload"); + +// Static byte payloads, hoisted to module scope so every request reuses +// the same immutable Buffer — no per-request allocation, mirroring the Go +// adapters that serve a pre-baked slice from servers/common. +const HELLO = Buffer.from("Hello, World!", "utf-8"); +const JSON_HELLO = Buffer.from('{"message":"Hello, World!"}', "utf-8"); +const OK = Buffer.from("OK", "utf-8"); +const JSON_1K = json1KPayload(); +const JSON_8K = json8KPayload(); +const JSON_16K = json16KPayload(); +const JSON_64K = json64KPayload(); + +const TEXT = "text/plain"; +const JSON_CT = "application/json"; + +// sendBytes writes a pre-encoded Buffer verbatim with status 200 and an +// explicit Content-Type. res.end(buffer) bypasses Express's res.send body +// transforms (no charset suffix, no ETag, no JSON re-encoding), so the +// wire body is byte-identical to common.Endpoints[...].ResponseBody. +// Content-Length is set from the Buffer length so HTTP/1.1 keep-alive +// frames the response exactly. +function sendBytes(res, body, contentType) { + res.status(200); + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Length", body.length); + res.end(body); +} + +const app = express(); + +// Strip framework-added response headers that would otherwise cost work +// per request and are irrelevant to the contract. etag is disabled so +// Express does not hash every body; x-powered-by is removed for parity +// with the lean Go/bun adapters. +app.disable("etag"); +app.disable("x-powered-by"); + +app.get("/", (_req, res) => sendBytes(res, HELLO, TEXT)); +app.get("/json", (_req, res) => sendBytes(res, JSON_HELLO, JSON_CT)); +app.get("/json-1k", (_req, res) => sendBytes(res, JSON_1K, JSON_CT)); +app.get("/json-8k", (_req, res) => sendBytes(res, JSON_8K, JSON_CT)); +app.get("/json-16k", (_req, res) => sendBytes(res, JSON_16K, JSON_CT)); +app.get("/json-64k", (_req, res) => sendBytes(res, JSON_64K, JSON_CT)); + +// /users/:id — Express's :param syntax matches the contract template +// verbatim. Echo the captured segment, matching WritePath in +// servers/common/common.go ("User ID: "). +app.get("/users/:id", (req, res) => { + sendBytes(res, Buffer.from("User ID: " + req.params.id, "utf-8"), TEXT); +}); + +// /upload — drain the request body so the body parser is part of the +// measured cost (every adapter does this deliberately), then reply "OK". +// No body-parser middleware is mounted: we consume the raw stream so the +// cell measures socket-read + discard, not JSON parsing. +app.post("/upload", (req, res) => { + req.on("data", () => {}); + req.on("end", () => sendBytes(res, OK, TEXT)); + req.on("error", () => sendBytes(res, OK, TEXT)); +}); + +const { host, port } = parseBind(process.argv); +const engine = parseEngine(process.argv); + +if (engine !== "h1") { + // Express has no cheap HTTP/2 path (see header comment). Fail loudly so + // a mis-routed -engine value is visible rather than silently h1. + console.error( + "express: unsupported -engine " + + JSON.stringify(engine) + + " (supported: h1)", + ); + process.exit(2); +} + +// app.listen returns the underlying http.Server. Passing port 0 lets the +// kernel assign a port; server.address().port then reports the resolved +// value for the ready line. +const server = app.listen(port, host, () => { + const addr = server.address(); + const boundHost = typeof addr === "object" && addr ? addr.address : host; + const boundPort = typeof addr === "object" && addr ? addr.port : port; + // The runner's TCP probe waits for this exact line on stdout. + console.log(`ready addr=${boundHost}:${boundPort}`); +}); + +server.on("error", (err) => { + console.error(`express: listen ${host}:${port}: ${err.message}`); + process.exit(1); +}); + +let shuttingDown = false; +function shutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + console.log(`express: received ${signal}, shutting down`); + // Stop accepting, then drop idle keep-alives so close() does not hang + // on connections parked between requests. In-flight requests still get + // to finish. closeIdleConnections is available on Node's http.Server + // (>=18.2); closeAllConnections is the hard backstop. + if (typeof server.closeIdleConnections === "function") { + server.closeIdleConnections(); + } + server.close(() => process.exit(0)); + // Hard cap well below the runner's 5s SIGKILL backstop: force-close any + // stragglers and exit so a stuck keep-alive never strands the process. + setTimeout(() => { + if (typeof server.closeAllConnections === "function") { + server.closeAllConnections(); + } + process.exit(0); + }, 1500).unref(); +} + +process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("SIGINT", () => shutdown("SIGINT")); + +// parseBind walks argv for the canonical -bind flag (accepts both +// `-bind 127.0.0.1:0` and `-bind=127.0.0.1:0`), falling back to the BIND +// env var then 127.0.0.1:8080. Returns {host, port} for app.listen. +// IPv6 literals in bracketed form ([::1]:8080) are supported. +function parseBind(argv) { + let raw = process.env.BIND || "127.0.0.1:8080"; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === undefined) continue; + if (a === "-bind" || a === "--bind") { + const v = argv[i + 1]; + if (v !== undefined) raw = v; + break; + } + if (a.startsWith("-bind=") || a.startsWith("--bind=")) { + raw = a.slice(a.indexOf("=") + 1); + break; + } + } + let host; + let portStr; + const m = /^\[(.+)\]:(\d+)$/.exec(raw); + if (m) { + host = m[1]; + portStr = m[2]; + } else { + const idx = raw.lastIndexOf(":"); + if (idx < 0) { + throw new Error("express: invalid -bind value " + JSON.stringify(raw)); + } + host = raw.slice(0, idx); + portStr = raw.slice(idx + 1); + } + const port = Number(portStr); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error("express: invalid port in -bind " + JSON.stringify(raw)); + } + return { host, port }; +} + +// parseEngine walks argv for -engine (accepts `-engine h1` and +// `-engine=h1`), defaulting to "h1". The value is validated by the caller +// above; we only normalise it here. +function parseEngine(argv) { + let raw = ""; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === undefined) continue; + if (a === "-engine" || a === "--engine") { + raw = argv[i + 1] || ""; + break; + } + if (a.startsWith("-engine=") || a.startsWith("--engine=")) { + raw = a.slice(a.indexOf("=") + 1); + break; + } + } + return raw === "" ? "h1" : raw; +} diff --git a/servers/fastapi/app/payload.py b/servers/fastapi/app/payload.py index 1811197..3b6b47b 100644 --- a/servers/fastapi/app/payload.py +++ b/servers/fastapi/app/payload.py @@ -1,4 +1,4 @@ -"""Deterministic JSON payloads for /json-1k and /json-64k. +"""Deterministic JSON payloads for /json-1k, /json-8k, /json-16k and /json-64k. These MUST be byte-identical to the Go reference at ``servers/common/payload.go``. The conformance probe (``cmd/conformance``) @@ -20,6 +20,11 @@ * The growth loop appends items until the marshalled length crosses the target — same termination predicate as ``generateJSONPayload`` in Go, which gives the same item count (8 items for 1 KiB, 583 for 64 KiB). + The mid-size 8 KiB / 16 KiB payloads bridge the 1k→64k gap so the bench + keeps differentiating adapters below the 20G fabric ceiling that makes + the 64k cells NIC-bound (see ``servers/common/payload.go`` JSON8KPayload). + Exact byte lengths (byte-compared by cluster conformance): + 1k = 1026, 8k = 8286, 16k = 16463, 64k = 65618. If a future change introduces a non-ASCII char or a key Go would HTML- escape, the conformance probe will fail loudly — keep this module's @@ -59,4 +64,6 @@ def _generate(target_size: int) -> bytes: JSON_1K_PAYLOAD: bytes = _generate(1024) +JSON_8K_PAYLOAD: bytes = _generate(8192) +JSON_16K_PAYLOAD: bytes = _generate(16384) JSON_64K_PAYLOAD: bytes = _generate(65536) diff --git a/servers/fastapi/app/server.py b/servers/fastapi/app/server.py index e64f85e..839498b 100644 --- a/servers/fastapi/app/server.py +++ b/servers/fastapi/app/server.py @@ -13,12 +13,28 @@ Argv contract (matches the Go adapters): - python -m app.server --bind 127.0.0.1:8080 - -The flag accepts ``host:port`` and is mapped onto ``uvicorn.Server`` in -single-process mode. The cluster launcher script prefers ``uvicorn`` -directly with ``--workers $(nproc)``; this entry-point is for local -development and for the dev-mac smoke import test. + python -m app.server -bind 127.0.0.1:8080 [-engine h1|h2c] + +``-bind`` accepts ``host:port``. ``-engine`` selects the wire protocol: + +* ``h1`` (or absent) — plain HTTP/1.1 on uvicorn + uvloop + httptools, + the tuned fast path, mapped onto ``uvicorn.Server`` in single-process + mode. The cluster launcher script prefers ``uvicorn`` directly with + ``--workers $(nproc)``; this entry-point is for local development and + for the dev-mac smoke import test. +* ``h2c`` — HTTP/2 cleartext, prior-knowledge, no TLS. uvicorn cannot + speak HTTP/2, so this path launches the same ASGI app under + **hypercorn** instead. Hypercorn's h11 reader detects the HTTP/2 + connection preface (``PRI * HTTP/2.0``) on a plaintext bind and swaps + the connection to its H2 protocol with no ALPN / TLS handshake — i.e. + exactly h2c prior-knowledge. See ``hypercorn/protocol/h11.py`` + (``H2ProtocolAssumedError``) and ``config.ssl_enabled`` (False with no + certfile ⇒ insecure/cleartext sockets). Single ``--workers``-equivalent + process; hypercorn's ``asyncio.serve`` runs one worker. + +Both long (``--bind``/``--engine``) and short (``-bind``/``-engine``) +spellings are accepted so the launcher shim and the Go orchestrator can +pass either. Readiness banner: @@ -38,6 +54,7 @@ import argparse import asyncio +import os import socket import sys @@ -45,7 +62,12 @@ from fastapi import FastAPI, Request from fastapi.responses import ORJSONResponse, PlainTextResponse, Response -from .payload import JSON_1K_PAYLOAD, JSON_64K_PAYLOAD +from .payload import ( + JSON_1K_PAYLOAD, + JSON_8K_PAYLOAD, + JSON_16K_PAYLOAD, + JSON_64K_PAYLOAD, +) # Static byte payloads. Hoisted to module scope so each request reuses the # same immutable bytes object — no per-request allocation, mirrors the Go @@ -55,6 +77,20 @@ _OK_PLAIN: bytes = b"OK" +def _announce_ready(bound_host: str, bound_port: int) -> None: + """Print the ``ready addr=...`` banner once, unless suppressed. + + The cluster launcher emits the banner from an external TCP probe so + the count is exactly one regardless of worker count / server. It sets + ``PROBATORIUM_SUPPRESS_READY=1`` to silence this in-process banner and + avoid a duplicate. Local-dev runs (no env var) still get the banner. + """ + if os.environ.get("PROBATORIUM_SUPPRESS_READY") == "1": + return + print(f"ready addr={bound_host}:{bound_port}", flush=True) + sys.stdout.flush() + + app = FastAPI(default_response_class=ORJSONResponse) @@ -76,6 +112,16 @@ async def json_1k() -> Response: return Response(content=JSON_1K_PAYLOAD, media_type="application/json") +@app.get("/json-8k") +async def json_8k() -> Response: + return Response(content=JSON_8K_PAYLOAD, media_type="application/json") + + +@app.get("/json-16k") +async def json_16k() -> Response: + return Response(content=JSON_16K_PAYLOAD, media_type="application/json") + + @app.get("/json-64k") async def json_64k() -> Response: return Response(content=JSON_64K_PAYLOAD, media_type="application/json") @@ -113,23 +159,18 @@ def _parse_bind(bind: str) -> tuple[str, int]: return host, int(port_s) -def main() -> None: - """Local-dev entry point. The cluster launcher invokes uvicorn directly.""" - parser = argparse.ArgumentParser(prog="probatorium-fastapi") - parser.add_argument("--bind", default="127.0.0.1:8080") - args = parser.parse_args() - host, port = _parse_bind(args.bind) - - # Bind the socket up front so we know the final address (port may be - # 0 in dev) before announcing readiness. uvicorn supports this via - # the ``fd`` config knob. +def _bind_socket(host: str, port: int) -> socket.socket: + """Open a listening socket on ``host:port`` (port 0 ⇒ OS-assigned).""" sock = socket.socket(socket.AF_INET if ":" not in host else socket.AF_INET6, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((host, port)) - bound_host, bound_port = sock.getsockname()[:2] sock.listen(2048) + return sock + +def _serve_h1(sock: socket.socket, bound_host: str, bound_port: int) -> None: + """HTTP/1.1 fast path: uvicorn + uvloop + httptools on the pre-bound fd.""" config = uvicorn.Config( app, loop="uvloop", @@ -142,11 +183,81 @@ def main() -> None: ) server = uvicorn.Server(config) - print(f"ready addr={bound_host}:{bound_port}", flush=True) - sys.stdout.flush() + _announce_ready(bound_host, bound_port) asyncio.run(server.serve()) +def _serve_h2c(sock: socket.socket, bound_host: str, bound_port: int) -> None: + """HTTP/2 cleartext (prior-knowledge, no TLS) via hypercorn. + + uvicorn has no HTTP/2 support, so the h2c column runs hypercorn. With + no certfile/keyfile, ``Config.ssl_enabled`` is False and the bind is + served on an insecure (cleartext) socket; hypercorn's h11 reader then + upgrades any connection that opens with the ``PRI * HTTP/2.0`` preface + straight to HTTP/2 — that is h2c prior-knowledge. uvloop is selected + via the loop installed before ``serve``. + """ + try: + import uvloop + from hypercorn.asyncio import serve + from hypercorn.config import Config + except ImportError as exc: # pragma: no cover - dep guard + print( + f"fastapi-h2: hypercorn/uvloop unavailable for -engine h2c: {exc}", + file=sys.stderr, + flush=True, + ) + raise SystemExit(3) from exc + + config = Config() + config.bind = [f"{bound_host}:{bound_port}"] + config.insecure_bind = [] + config.accesslog = None + config.errorlog = None + config.loglevel = "WARNING" + # With no certfile/keyfile, ssl_enabled is False, so `bind` is served on + # an insecure (cleartext) socket. Advertise h2 first anyway; on cleartext + # ALPN is never consulted — prior-knowledge keys solely off the preface. + config.alpn_protocols = ["h2", "http/1.1"] + + # We bound the socket up front to learn the final address (port may be + # 0). hypercorn's serve() re-binds from `config.bind`, so release ours + # first to avoid a double-bind on the same address. SO_REUSEADDR was set + # and this is a single process, so there is no contention. + sock.close() + + uvloop.install() + + _announce_ready(bound_host, bound_port) + + asyncio.run(serve(app, config)) + + +def main() -> None: + """Local-dev entry point. The cluster launcher invokes the server directly.""" + parser = argparse.ArgumentParser(prog="probatorium-fastapi") + parser.add_argument("-bind", "--bind", dest="bind", default="127.0.0.1:8080") + parser.add_argument( + "-engine", + "--engine", + dest="engine", + default="h1", + choices=["h1", "h2c"], + ) + args = parser.parse_args() + host, port = _parse_bind(args.bind) + + # Bind the socket up front so we know the final address (port may be + # 0 in dev) before announcing readiness. + sock = _bind_socket(host, port) + bound_host, bound_port = sock.getsockname()[:2] + + if args.engine == "h2c": + _serve_h2c(sock, bound_host, bound_port) + else: + _serve_h1(sock, bound_host, bound_port) + + if __name__ == "__main__": main() diff --git a/servers/fastapi/pyproject.toml b/servers/fastapi/pyproject.toml index 7aa1eb9..2096b8d 100644 --- a/servers/fastapi/pyproject.toml +++ b/servers/fastapi/pyproject.toml @@ -17,12 +17,16 @@ # {{ bench_root }}/competitors/fastapi/.venv via `uv venv` + `uv pip install`. # # Run command (the launcher script in this directory wraps it): -# uvicorn app.server:app --host 127.0.0.1 --port

\ -# --workers $(nproc) --loop uvloop --http httptools \ -# --no-access-log --log-level warning +# -engine h1 (or absent): HTTP/1.1 fast path +# uvicorn app.server:app --host 127.0.0.1 --port

\ +# --workers $(nproc) --loop uvloop --http httptools \ +# --no-access-log --log-level warning +# -engine h2c: HTTP/2 cleartext, prior-knowledge, no TLS +# python -m app.server -bind 127.0.0.1:

-engine h2c +# (launches the same ASGI app under hypercorn; uvicorn has no HTTP/2.) # # `--workers` only takes effect outside reload mode (which we never enable), -# so the launcher hard-codes a non-reload uvicorn invocation. +# so the launcher hard-codes a non-reload uvicorn invocation for h1. [project] name = "probatorium-fastapi-adapter" @@ -33,6 +37,13 @@ dependencies = [ "fastapi>=0.115", "uvicorn[standard]>=0.32", "orjson>=3.10", + # `-engine h2c` only. uvicorn cannot speak HTTP/2, so the h2c column + # runs the same ASGI app under hypercorn, which serves HTTP/2 cleartext + # prior-knowledge on a plain (no-TLS) bind. `-engine h1` never imports + # it. hypercorn's HTTP/2 stack (h2, hpack, priority) is a core dep, not + # an extra — plain `hypercorn` already pulls it. uvloop comes from + # uvicorn[standard] above, so no `[uvloop]` extra is needed here. + "hypercorn>=0.17", ] [build-system] diff --git a/servers/fastapi_test.go b/servers/fastapi_test.go index 9ca1577..f8c843b 100644 --- a/servers/fastapi_test.go +++ b/servers/fastapi_test.go @@ -75,6 +75,8 @@ func TestFastAPIPayload(t *testing.T) { expected []byte }{ {"json-1k", "import sys; from app.payload import JSON_1K_PAYLOAD as p; sys.stdout.buffer.write(p)", common.JSON1KPayload()}, + {"json-8k", "import sys; from app.payload import JSON_8K_PAYLOAD as p; sys.stdout.buffer.write(p)", common.JSON8KPayload()}, + {"json-16k", "import sys; from app.payload import JSON_16K_PAYLOAD as p; sys.stdout.buffer.write(p)", common.JSON16KPayload()}, {"json-64k", "import sys; from app.payload import JSON_64K_PAYLOAD as p; sys.stdout.buffer.write(p)", common.JSON64KPayload()}, } { t.Run(tc.name, func(t *testing.T) { diff --git a/servers/fasthttp/go.mod b/servers/fasthttp/go.mod index c37643b..8f5af52 100644 --- a/servers/fasthttp/go.mod +++ b/servers/fasthttp/go.mod @@ -3,7 +3,7 @@ module github.com/goceleris/probatorium/servers/fasthttp go 1.26.4 require ( - github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf + github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c github.com/goceleris/probatorium v0.0.0-00010101000000-000000000000 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 diff --git a/servers/fasthttp/go.sum b/servers/fasthttp/go.sum index 8636649..3f316d8 100644 --- a/servers/fasthttp/go.sum +++ b/servers/fasthttp/go.sum @@ -1,7 +1,7 @@ github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I= -github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= +github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c h1:6Gpm9YYUEQx2T9zMsYolQhr6sjwwGtFitSA0pQsa7a8= +github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= diff --git a/servers/fasthttp/server.go b/servers/fasthttp/server.go index d4e568f..5166e94 100644 --- a/servers/fasthttp/server.go +++ b/servers/fasthttp/server.go @@ -135,6 +135,14 @@ func (s *Server) dispatch(ctx *fasthttp.RequestCtx) { ctx.SetContentType("application/json") ctx.SetBody(common.JSON1KPayload()) return + case method == "GET" && path == "/json-8k": + ctx.SetContentType("application/json") + ctx.SetBody(common.JSON8KPayload()) + return + case method == "GET" && path == "/json-16k": + ctx.SetContentType("application/json") + ctx.SetBody(common.JSON16KPayload()) + return case method == "GET" && path == "/json-64k": ctx.SetContentType("application/json") ctx.SetBody(common.JSON64KPayload()) diff --git a/servers/fastify/package.json b/servers/fastify/package.json new file mode 100644 index 0000000..f4b2571 --- /dev/null +++ b/servers/fastify/package.json @@ -0,0 +1,16 @@ +{ + "name": "probatorium-fastify-server", + "version": "0.0.0", + "private": true, + "description": "Fastify (Node.js) adapter for the probatorium contract.", + "type": "commonjs", + "main": "src/server.js", + "//versions": "Always-latest policy: fastify is pinned to `latest` so `npm install` resolves the newest cut at deploy time. Do NOT pin an upper bound — the bench is meant to track upstream as-is. There is NO bundle step: Node runs src/server.js directly against node_modules.", + "//run": "`node src/server.js -bind [-engine h1|h2c]` is the canonical entry. The ansible node role writes a POSIX-sh launcher at competitors/fastify that execs the bench-installed node on this file, forwarding argv.", + "scripts": { + "start": "node src/server.js" + }, + "dependencies": { + "fastify": "latest" + } +} diff --git a/servers/fastify/src/payload.js b/servers/fastify/src/payload.js new file mode 100644 index 0000000..4531834 --- /dev/null +++ b/servers/fastify/src/payload.js @@ -0,0 +1,74 @@ +// Deterministic 1/8/16/64 KiB JSON payload generator. +// +// Byte-identical port of probatorium/servers/common/payload.go. The Go +// reference runs encoding/json over (paginatedResponse, paginatedItem), +// which emits compact JSON with field order matching struct declaration +// order: page, per_page, total, total_pages, data for the wrapper, and +// id, name, email, status, created_at per item. +// +// We emit the bytes by hand rather than via JSON.stringify on a JS object. +// Byte-for-byte equivalence with the Go reference is a hard conformance +// requirement (cmd/conformance does a bytes-equal check); JSON.stringify +// and encoding/json agree on this pure-ASCII corpus, but owning the bytes +// removes any cross-runtime serialiser ambiguity. The corpus is generated +// once at startup and reused for every request, so the build cost never +// shows up in the bench. +// +// Termination rule from the Go reference: append items until the +// marshalled length is at least targetSize. Resulting sizes: +// 1 KiB target -> 1026 bytes ending at item 9 +// 8 KiB target -> 8286 bytes ending at item 75 +// 16 KiB target -> 16463 bytes ending at item 148 +// 64 KiB target -> 65618 bytes ending at item 583 + +'use strict'; + +const HEADER = '{"page":1,"per_page":50,"total":1000,"total_pages":20,"data":['; +const FOOTER = ']}'; + +function item(n) { + // Field order MUST match the Go struct declaration exactly: + // id, name, email, status, created_at. + return ( + '{"id":' + + n + + ',"name":"User ' + + n + + '","email":"user' + + n + + '@example.com","status":"active","created_at":"2024-01-15T09:30:00Z"}' + ); +} + +function generate(targetSize) { + // Build the body as a string, encode once to a Buffer at the end. The + // payload is pure ASCII so byte length equals string length, which makes + // the Go termination predicate (marshalled length >= targetSize) exact + // against buf.length + FOOTER.length here. + let buf = HEADER; + let i = 1; + while (true) { + if (i > 1) buf += ','; + buf += item(i); + if (buf.length + FOOTER.length >= targetSize) break; + i += 1; + } + buf += FOOTER; + return Buffer.from(buf, 'utf8'); +} + +// Generated once at module load and frozen into module-level constants so +// every request reuses the same immutable Buffer — no per-request alloc, +// mirroring the Go adapters that serve a pre-baked slice from +// servers/common. +const JSON_1K_PAYLOAD = generate(1024); +const JSON_8K_PAYLOAD = generate(8192); +const JSON_16K_PAYLOAD = generate(16384); +const JSON_64K_PAYLOAD = generate(65536); + +module.exports = { + JSON_1K_PAYLOAD, + JSON_8K_PAYLOAD, + JSON_16K_PAYLOAD, + JSON_64K_PAYLOAD, +}; diff --git a/servers/fastify/src/server.js b/servers/fastify/src/server.js new file mode 100644 index 0000000..f9f44b3 --- /dev/null +++ b/servers/fastify/src/server.js @@ -0,0 +1,227 @@ +// Probatorium fastify adapter (Node.js runtime). +// +// Serves the canonical contract endpoints declared in +// servers/common/contract.go: +// +// GET / -> "Hello, World!" text/plain +// GET /json -> {"message":"Hello, World!"} application/json +// GET /json-1k -> deterministic 1026-byte JSON page +// GET /json-8k -> deterministic 8286-byte JSON page +// GET /json-16k -> deterministic 16463-byte JSON page +// GET /json-64k -> deterministic 65618-byte JSON page +// GET /users/:id -> "User ID: " text/plain +// POST /upload -> read-and-discard body, reply "OK" text/plain +// +// Driver-backed (/db, /cache, /mc, /session) and chain-* endpoints are out +// of scope here — the scenario applicability filter (servers/servers.go) +// never schedules those cells against a Static-only adapter, so they are +// simply not mounted. +// +// CLI contract (matched by every probatorium adapter): +// -bind default 127.0.0.1:8080. Pass `:0` (or `host:0`) to +// let the kernel assign a port; the resolved address +// is echoed on stdout via the `ready addr=` line +// the runner waits for before opening loadgen. Both +// `-bind X` and `-bind=X` (and `--bind` spellings) are +// accepted, as is an env BIND fallback. +// -engine default h1. +// h1 — plain HTTP/1.1 (Fastify on node:http). +// h2c — HTTP/2 cleartext, PRIOR-KNOWLEDGE only: +// Fastify({ http2: true }) with no `https` +// stands up node:http2.createServer(), a +// cleartext h2 listener. No TLS, no h1->h2 +// upgrade — a client must open with the h2 +// preface (curl --http2-prior-knowledge), +// matching stdhttp-h2 / axum-h2's h2c-noupg +// semantics. Any other value exits non-zero. +// +// Lifecycle: SIGTERM (or SIGINT) triggers fastify.close(), which stops +// accepting and drains in-flight requests well within the runner's +// 5-second grace window (servers/start.go) before the SIGKILL fallback. + +'use strict'; + +const Fastify = require('fastify'); +const { + JSON_1K_PAYLOAD, + JSON_8K_PAYLOAD, + JSON_16K_PAYLOAD, + JSON_64K_PAYLOAD, +} = require('./payload'); + +// Static byte payloads. Pre-encoded Buffers reused across every request — +// no per-request allocation, and Fastify treats a Buffer as pre-serialized +// (sent verbatim, no response validation / re-encode), so the wire bytes +// are byte-identical to common.Endpoints[...].ResponseBody regardless of +// the Content-Type we set. +const HELLO_PLAIN = Buffer.from('Hello, World!'); +const HELLO_JSON = Buffer.from('{"message":"Hello, World!"}'); +const OK_PLAIN = Buffer.from('OK'); + +const TEXT = 'text/plain'; +const JSON_CT = 'application/json'; + +function buildApp(engine) { + // http2:true with no `https` ⇒ Fastify uses node:http2.createServer(), + // i.e. cleartext HTTP/2 (h2c prior-knowledge). The h1 path stays on + // node:http. disableRequestLogging + no logger keep the per-request cost + // to the framework, not to logging. + const app = Fastify({ + http2: engine === 'h2c', + logger: false, + disableRequestLogging: true, + }); + + // Catch-all body parser: read-and-discard the request body for ANY + // content type. Without this, a POST /upload whose body is not valid + // JSON (or carries an unmapped Content-Type) would fail in Fastify's + // default parser. Draining the stream keeps the body parser in the + // measured path (matching every other adapter) without buffering the + // payload. done() with no value yields an undefined body the handler + // ignores. + app.addContentTypeParser('*', (_req, payload, done) => { + payload.resume(); + payload.on('end', () => done(null, undefined)); + payload.on('error', done); + }); + + app.get('/', (_req, reply) => { + reply.header('content-type', TEXT).send(HELLO_PLAIN); + }); + + app.get('/json', (_req, reply) => { + reply.header('content-type', JSON_CT).send(HELLO_JSON); + }); + + app.get('/json-1k', (_req, reply) => { + reply.header('content-type', JSON_CT).send(JSON_1K_PAYLOAD); + }); + + app.get('/json-8k', (_req, reply) => { + reply.header('content-type', JSON_CT).send(JSON_8K_PAYLOAD); + }); + + app.get('/json-16k', (_req, reply) => { + reply.header('content-type', JSON_CT).send(JSON_16K_PAYLOAD); + }); + + app.get('/json-64k', (_req, reply) => { + reply.header('content-type', JSON_CT).send(JSON_64K_PAYLOAD); + }); + + app.get('/users/:id', (req, reply) => { + // Echo the path param verbatim — matches WritePath in + // servers/common/common.go. A string sent with text/plain set goes out + // unmodified (no custom serializer registered for text/plain). + reply.header('content-type', TEXT).send('User ID: ' + req.params.id); + }); + + app.post('/upload', (_req, reply) => { + // Body was already drained-and-discarded by the catch-all parser above. + reply.header('content-type', TEXT).send(OK_PLAIN); + }); + + return app; +} + +// parseFlag walks argv for `-name ` / `-name=value` (and the `--` +// spellings), Go-flag style — the convention every probatorium adapter +// follows so the runner can invoke this binary identically. Returns +// undefined when the flag is absent. +function parseFlag(argv, name) { + const short = '-' + name; + const long = '--' + name; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === short || a === long) return argv[i + 1]; + if (a.startsWith(short + '=')) return a.slice(short.length + 1); + if (a.startsWith(long + '=')) return a.slice(long.length + 1); + } + return undefined; +} + +// parseBind splits `host:port` into { host, port }. IPv6 literals are +// accepted in bracketed form ([::1]:8080). port 0 ⇒ kernel-assigned. +function parseBind(raw) { + if (raw.startsWith('[')) { + const rb = raw.indexOf(']'); + if (rb < 0 || raw[rb + 1] !== ':') { + throw new Error('fastify: invalid -bind ' + JSON.stringify(raw)); + } + return { host: raw.slice(1, rb), port: Number(raw.slice(rb + 2)) }; + } + const idx = raw.lastIndexOf(':'); + if (idx < 0) { + throw new Error('fastify: invalid -bind ' + JSON.stringify(raw)); + } + const host = raw.slice(0, idx); + const port = Number(raw.slice(idx + 1)); + if (!host || !Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error('fastify: invalid -bind ' + JSON.stringify(raw)); + } + return { host, port }; +} + +async function main() { + const argv = process.argv.slice(2); + + const engineRaw = parseFlag(argv, 'engine') ?? 'h1'; + if (engineRaw !== 'h1' && engineRaw !== 'h2c') { + process.stderr.write( + 'fastify: unknown -engine ' + + JSON.stringify(engineRaw) + + ' (want h1|h2c)\n', + ); + process.exit(2); + } + + const bindRaw = parseFlag(argv, 'bind') ?? process.env.BIND ?? '127.0.0.1:8080'; + let host; + let port; + try { + ({ host, port } = parseBind(bindRaw)); + } catch (err) { + process.stderr.write(String(err.message ?? err) + '\n'); + process.exit(1); + return; + } + + const app = buildApp(engineRaw); + + try { + await app.listen({ host, port }); + } catch (err) { + process.stderr.write('fastify: listen ' + bindRaw + ': ' + err + '\n'); + process.exit(1); + return; + } + + // Report the resolved address. We echo the requested host with the + // resolved port (app.server.address().port) rather than the address + // Fastify hands back from listen(): binding 0.0.0.0 makes Fastify report + // the first concrete IPv4, which would surprise a runner that dialed the + // host it asked for. The port is the only field that can change under + // `:0`, so requested-host + resolved-port is the correct identity. + const resolvedPort = app.server.address().port; + process.stdout.write('ready addr=' + host + ':' + resolvedPort + '\n'); + + let closing = false; + const shutdown = () => { + if (closing) return; + closing = true; + // fastify.close() stops accepting and drains in-flight requests, then + // resolves. The runner's 5s SIGKILL backstop is the upper bound; we + // exit 0 once close resolves. + app.close().then( + () => process.exit(0), + () => process.exit(0), + ); + }; + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); +} + +main().catch((err) => { + process.stderr.write('fastify: fatal: ' + err + '\n'); + process.exit(1); +}); diff --git a/servers/fiber/server.go b/servers/fiber/server.go index 146e8f9..8c71ba5 100644 --- a/servers/fiber/server.go +++ b/servers/fiber/server.go @@ -75,6 +75,14 @@ func registerRoutes(app *fiber.App) { c.Set("Content-Type", "application/json") return c.Send(common.JSON1KPayload()) }) + app.Get("/json-8k", func(c *fiber.Ctx) error { + c.Set("Content-Type", "application/json") + return c.Send(common.JSON8KPayload()) + }) + app.Get("/json-16k", func(c *fiber.Ctx) error { + c.Set("Content-Type", "application/json") + return c.Send(common.JSON16KPayload()) + }) app.Get("/json-64k", func(c *fiber.Ctx) error { c.Set("Content-Type", "application/json") return c.Send(common.JSON64KPayload()) diff --git a/servers/gin/go.mod b/servers/gin/go.mod index 3e6c21d..25f54c2 100644 --- a/servers/gin/go.mod +++ b/servers/gin/go.mod @@ -3,7 +3,7 @@ module github.com/goceleris/probatorium/servers/gin go 1.26.4 require ( - github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf + github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c github.com/gin-gonic/gin v1.12.0 github.com/goceleris/probatorium v0.0.0-00010101000000-000000000000 github.com/google/uuid v1.6.0 @@ -43,7 +43,7 @@ require ( go.uber.org/atomic v1.11.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.53.0 // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect diff --git a/servers/gin/go.sum b/servers/gin/go.sum index e3834bd..c7aa03b 100644 --- a/servers/gin/go.sum +++ b/servers/gin/go.sum @@ -1,5 +1,5 @@ -github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I= -github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= +github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c h1:6Gpm9YYUEQx2T9zMsYolQhr6sjwwGtFitSA0pQsa7a8= +github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -99,8 +99,8 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/servers/gin/server.go b/servers/gin/server.go index 9af49fb..8e08c45 100644 --- a/servers/gin/server.go +++ b/servers/gin/server.go @@ -76,6 +76,12 @@ func registerRoutes(r *gin.Engine) { r.GET("/json-1k", func(c *gin.Context) { c.Data(http.StatusOK, "application/json", common.JSON1KPayload()) }) + r.GET("/json-8k", func(c *gin.Context) { + c.Data(http.StatusOK, "application/json", common.JSON8KPayload()) + }) + r.GET("/json-16k", func(c *gin.Context) { + c.Data(http.StatusOK, "application/json", common.JSON16KPayload()) + }) r.GET("/json-64k", func(c *gin.Context) { c.Data(http.StatusOK, "application/json", common.JSON64KPayload()) }) diff --git a/servers/gnet/main.go b/servers/gnet/main.go index e4191a8..38f446a 100644 --- a/servers/gnet/main.go +++ b/servers/gnet/main.go @@ -42,6 +42,8 @@ var ( respRoot = buildResponse("text/plain", common.Endpoints[0].ResponseBody) respJSON = buildResponse("application/json", common.Endpoints[1].ResponseBody) respJSON1K = buildResponse("application/json", common.JSON1KPayload()) + respJSON8K = buildResponse("application/json", common.JSON8KPayload()) + respJSON16K = buildResponse("application/json", common.JSON16KPayload()) respJSON64K = buildResponse("application/json", common.JSON64KPayload()) respUpload = buildResponse("text/plain", []byte("OK")) respNotFnd = buildStatusResponse(404, "Not Found", "text/plain", []byte("Not Found")) @@ -148,6 +150,10 @@ func route(method, target []byte) []byte { return respJSON case equal(path, "/json-1k"): return respJSON1K + case equal(path, "/json-8k"): + return respJSON8K + case equal(path, "/json-16k"): + return respJSON16K case equal(path, "/json-64k"): return respJSON64K case hasPrefix(path, "/users/"): diff --git a/servers/gorilla_ws/server.go b/servers/gorilla_ws/server.go index 242e915..166bb6a 100644 --- a/servers/gorilla_ws/server.go +++ b/servers/gorilla_ws/server.go @@ -119,6 +119,12 @@ func registerStatic(mux *http.ServeMux) { mux.HandleFunc("GET /json-1k", func(w http.ResponseWriter, r *http.Request) { writeBlob(w, "application/json", common.JSON1KPayload()) }) + mux.HandleFunc("GET /json-8k", func(w http.ResponseWriter, r *http.Request) { + writeBlob(w, "application/json", common.JSON8KPayload()) + }) + mux.HandleFunc("GET /json-16k", func(w http.ResponseWriter, r *http.Request) { + writeBlob(w, "application/json", common.JSON16KPayload()) + }) mux.HandleFunc("GET /json-64k", func(w http.ResponseWriter, r *http.Request) { writeBlob(w, "application/json", common.JSON64KPayload()) }) diff --git a/servers/h2o/.gitignore b/servers/h2o/.gitignore new file mode 100644 index 0000000..8dbb8b1 --- /dev/null +++ b/servers/h2o/.gitignore @@ -0,0 +1,2 @@ +h2o-adapter +*.o diff --git a/servers/h2o/Makefile b/servers/h2o/Makefile new file mode 100644 index 0000000..ce91370 --- /dev/null +++ b/servers/h2o/Makefile @@ -0,0 +1,83 @@ +# probatorium h2o adapter (C, libh2o) build. +# +# Produces ./h2o-adapter, a single-file C server linked against libh2o's +# event-loop variant (libh2o-evloop) plus its transitive deps (OpenSSL, +# zlib, wslay). Mirrors the drogon adapter's "build natively on the bench +# host" model: the ansible "c" role compiles + installs libh2o into a +# bench-root prefix and exports H2O_PREFIX / PKG_CONFIG_PATH; this Makefile +# resolves the include + link flags from there. +# +# Resolution order for the h2o flags: +# 1. pkg-config libh2o-evloop (the .pc the cmake install drops in +# $PREFIX/lib/pkgconfig — preferred; carries -I/-L automatically). +# 2. H2O_PREFIX fallback (-I$PREFIX/include -L$PREFIX/lib -lh2o-evloop) +# for a prefix that has no .pc on PKG_CONFIG_PATH. +# +# libh2o-evloop is a STATIC archive whose .pc Libs: line does NOT carry its +# transitive link deps (a static .a records no dependency metadata), so the +# final adapter link must name them explicitly. The evloop archive bundles +# picotls/quicly/picohttpparser and references symbols from: +# -lssl -lcrypto OpenSSL (h2o's FIND_PACKAGE(OpenSSL REQUIRED)) +# -lz zlib (FIND_PACKAGE(ZLIB REQUIRED)) +# -lbrotlienc -lbrotlidec Brotli — REQUIRED: h2o only INSTALLS +# libh2o-evloop.a when system brotli is found +# (BROTLI_FOUND gates the INSTALL(TARGETS ...)), so a +# prefix that contains the archive was built WITH brotli +# and the archive references brotli symbols. +# -lwslay websocket framing (libh2o links it when libwslay is +# present; the archive references it). Harmless to keep on +# the line — our embedding never mounts the ws handler, but +# the static archive still pulls the symbols. +# plus -lm -lpthread -ldl. If a given prefix was built without one of these +# (e.g. no system wslay), drop it via H2O_EXTRA_LIBS on the make line. +# +# Override knobs (make VAR=...): +# CC compiler (default cc) +# H2O_PREFIX libh2o install dir (used only if pkg-config misses) +# H2O_EXTRA_LIBS transitive deps +# (default -lssl -lcrypto -lz -lbrotlienc -lbrotlidec -lwslay) +# CFLAGS_EXTRA appended to CFLAGS (e.g. -march=native from the role) + +CC ?= cc +BIN := h2o-adapter +SRC := main.c + +PKG_CONFIG ?= pkg-config +H2O_PREFIX ?= /usr/local +H2O_EXTRA_LIBS ?= -lssl -lcrypto -lz -lbrotlienc -lbrotlidec -lwslay + +# h2o include/link flags from pkg-config when the .pc is visible, else the +# H2O_PREFIX fallback. `pkg-config --exists` is the probe. +H2O_PC := $(shell $(PKG_CONFIG) --exists libh2o-evloop 2>/dev/null && echo yes) +ifeq ($(H2O_PC),yes) + H2O_CFLAGS := $(shell $(PKG_CONFIG) --cflags libh2o-evloop) + H2O_LIBS := $(shell $(PKG_CONFIG) --libs libh2o-evloop) +else + H2O_CFLAGS := -I$(H2O_PREFIX)/include + H2O_LIBS := -L$(H2O_PREFIX)/lib -lh2o-evloop +endif + +# H2O_USE_LIBUV=0 selects the evloop backend in the installed headers +# (libh2o-evloop). Without it h2o/socket.h falls back to the libuv binding +# and pulls in , which the evloop variant does not ship. +# +# -O3 matches the optimization level libreactor bakes into its own Makefile. +# The bench role passes CFLAGS_EXTRA=-march=native (build_native_competitor's +# c family has no implicit CFLAGS env), so the adapter is benched native. +# +# -std=gnu11 + -D_GNU_SOURCE: h2o's public headers use POSIX/GNU symbols +# (posix_memalign in h2o/memory.h, struct addrinfo in h2o/hostinfo.h) that +# strict ISO C (-std=c11 → __STRICT_ANSI__) hides — h2o's own build defines +# _GNU_SOURCE, so the embedding must too or those headers fail to compile. +CFLAGS ?= -O3 -std=gnu11 +CFLAGS += -D_GNU_SOURCE -DH2O_USE_LIBUV=0 $(H2O_CFLAGS) $(CFLAGS_EXTRA) +LDLIBS := $(H2O_LIBS) $(H2O_EXTRA_LIBS) -lpthread -lm -ldl + +.PHONY: all clean +all: $(BIN) + +$(BIN): $(SRC) + $(CC) $(CFLAGS) -o $@ $(SRC) $(LDLIBS) + +clean: + rm -f $(BIN) diff --git a/servers/h2o/main.c b/servers/h2o/main.c new file mode 100644 index 0000000..f46851b --- /dev/null +++ b/servers/h2o/main.c @@ -0,0 +1,554 @@ +/* + * probatorium h2o adapter — C, libh2o (the H2O project's embeddable HTTP + * server library). Same contract as servers/axum, servers/drogon: the + * eight canonical static endpoints, served byte-identically. + * + * Endpoint set (see servers/common/contract.go for the canonical bytes): + * GET / -> "Hello, World!" text/plain + * GET /json -> {"message":"Hello, World!"} application/json + * GET /json-1k -> deterministic 1026-byte JSON page + * GET /json-8k -> deterministic 8286-byte JSON page + * GET /json-16k -> deterministic 16463-byte JSON page + * GET /json-64k -> deterministic 65618-byte JSON page + * GET /users/:id -> "User ID: " text/plain + * POST /upload -> read-and-discard body, reply "OK" text/plain + * + * Driver-backed (/db, /cache, /mc, /session) and chain-* endpoints are out + * of scope (Capabilities all-false: static + concurrency scenarios only), + * so anything unmatched returns 404 — the scenario applicability filter in + * servers/servers.go skips h2o for those classes. + * + * CLI: `{bin} -bind [-engine h1|h2c]`. Mirrors the Rust/C++ + * adapters so servers.StartAdapter launches every native adapter with one + * pattern (`{bin} -bind {addr}`, wait for the `ready addr=` stdout + * line, SIGTERM for graceful drain). + * + * -bind default 127.0.0.1:8080. `host:0` lets the kernel + * pick the port; the actually-bound address is read + * back with getsockname and reported on stdout. + * -engine default "h1". + * h1 -> plain HTTP/1.1 on a single cleartext h2o_evloop listener. + * h2c -> HTTP/2 cleartext prior-knowledge. NOT served as a distinct + * column: h2o's cleartext accept path speaks h1 AND h2c + * prior-knowledge on the SAME socket (it sniffs the + * "PRI * HTTP/2.0" preface and dispatches to the HTTP/2 + * handler, otherwise stays h1) — it cannot REFUSE h1 the way + * the strict h2c-noupg contract demands. Serving h1 under an + * h2c label would corrupt the column, so -engine h2c fails + * fast (exit 2), exactly like the drogon adapter. h2o stays + * H1-only; no h2o-h2 column is registered. + * + * Threading: one h2o_evloop PER CORE via fork + SO_REUSEPORT. libh2o has no + * in-process multi-loop accept sharing, so a single evloop pins the adapter + * to one core (~190k RPS on a 16-core host — a thread-pool artefact, not + * h2o's real ceiling). Instead we resolve the port once, print ready once, + * then fork one worker per online CPU; each worker runs its own evloop and + * binds the same host:port with its own SO_REUSEPORT listen fd, letting the + * kernel spread connections across all cores. SIGTERM reaches the whole + * process group (the runner signals the group), so every worker drains. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "h2o.h" +#include "h2o/http1.h" +#include "h2o/http2.h" + +static h2o_globalconf_t config; +static h2o_context_t ctx; +static h2o_accept_ctx_t accept_ctx; + +static void on_accept(h2o_socket_t *listener, const char *err); + +/* Pre-baked response bodies. Built once at startup so each request writes a + * shared, immutable buffer — no per-request allocation, mirroring the Go + * adapters that serve a pre-computed slice from servers/common. */ +static h2o_iovec_t json1k, json8k, json16k, json64k; + +/* ---- payload generator ---- + * Byte-identical port of servers/common/payload.go generateJSONPayload. The + * Go reference marshals a (paginatedResponse, paginatedItem) struct pair with + * encoding/json, which emits compact JSON in struct-declaration field order. + * We emit those bytes by hand. Termination rule mirrors Go: append items + * until the full marshalled length (header + items + footer) crosses + * targetSize, so the resulting sizes match exactly: + * 1 KiB target -> 1026 bytes + * 8 KiB target -> 8286 bytes + * 16 KiB target -> 16463 bytes + * 64 KiB target -> 65618 bytes + * The returned buffer is heap-allocated for process lifetime (never freed — + * it lives until exit). */ +static h2o_iovec_t generate_json_payload(size_t target_size) +{ + static const char header[] = + "{\"page\":1,\"per_page\":50,\"total\":1000,\"total_pages\":20,\"data\":["; + static const char footer[] = "]}"; + const size_t header_len = sizeof(header) - 1; + const size_t footer_len = sizeof(footer) - 1; + + size_t cap = target_size + 256; + char *buf = h2o_mem_alloc(cap); + size_t len = 0; + + memcpy(buf, header, header_len); + len += header_len; + + for (uint64_t i = 1;; ++i) { + char item[160]; + int n; + if (i > 1) { + n = snprintf(item, sizeof(item), + ",{\"id\":%llu,\"name\":\"User %llu\",\"email\":\"user%llu@example.com\"," + "\"status\":\"active\",\"created_at\":\"2024-01-15T09:30:00Z\"}", + (unsigned long long)i, (unsigned long long)i, (unsigned long long)i); + } else { + n = snprintf(item, sizeof(item), + "{\"id\":%llu,\"name\":\"User %llu\",\"email\":\"user%llu@example.com\"," + "\"status\":\"active\",\"created_at\":\"2024-01-15T09:30:00Z\"}", + (unsigned long long)i, (unsigned long long)i, (unsigned long long)i); + } + if (len + (size_t)n + footer_len + 1 > cap) { + cap = (len + (size_t)n + footer_len + 1) * 2; + buf = h2o_mem_realloc(buf, cap); + } + memcpy(buf + len, item, (size_t)n); + len += (size_t)n; + + if (len + footer_len >= target_size) + break; + } + memcpy(buf + len, footer, footer_len); + len += footer_len; + + return h2o_iovec_init(buf, len); +} + +/* ---- response helpers ---- */ + +/* Send a fixed, process-lifetime body. The buffer is NOT duplicated into the + * request pool (it outlives every request), so h2o_send borrows it directly. */ +static int send_static(h2o_req_t *req, h2o_iovec_t body, const char *content_type, size_t ct_len) +{ + static h2o_generator_t generator = {NULL, NULL}; + req->res.status = 200; + req->res.reason = "OK"; + h2o_add_header(&req->pool, &req->res.headers, H2O_TOKEN_CONTENT_TYPE, NULL, content_type, ct_len); + h2o_start_response(req, &generator); + h2o_send(req, &body, 1, H2O_SEND_STATE_FINAL); + return 0; +} + +#define CT_TEXT "text/plain" +#define CT_JSON "application/json" + +/* ---- handlers ---- */ + +static int on_root(h2o_handler_t *self, h2o_req_t *req) +{ + if (!h2o_memis(req->method.base, req->method.len, H2O_STRLIT("GET"))) + return -1; + /* "/" is a prefix-match handler: it must only answer the bare root, not + * every unmatched path. Anything longer falls through (return -1) so the + * core emits 404 for unknown routes. */ + if (!h2o_memis(req->path_normalized.base, req->path_normalized.len, H2O_STRLIT("/"))) + return -1; + return send_static(req, h2o_iovec_init(H2O_STRLIT("Hello, World!")), H2O_STRLIT(CT_TEXT)); +} + +static int on_json(h2o_handler_t *self, h2o_req_t *req) +{ + if (!h2o_memis(req->method.base, req->method.len, H2O_STRLIT("GET"))) + return -1; + return send_static(req, h2o_iovec_init(H2O_STRLIT("{\"message\":\"Hello, World!\"}")), + H2O_STRLIT(CT_JSON)); +} + +static int on_json_1k(h2o_handler_t *self, h2o_req_t *req) +{ + if (!h2o_memis(req->method.base, req->method.len, H2O_STRLIT("GET"))) + return -1; + return send_static(req, json1k, H2O_STRLIT(CT_JSON)); +} + +static int on_json_8k(h2o_handler_t *self, h2o_req_t *req) +{ + if (!h2o_memis(req->method.base, req->method.len, H2O_STRLIT("GET"))) + return -1; + return send_static(req, json8k, H2O_STRLIT(CT_JSON)); +} + +static int on_json_16k(h2o_handler_t *self, h2o_req_t *req) +{ + if (!h2o_memis(req->method.base, req->method.len, H2O_STRLIT("GET"))) + return -1; + return send_static(req, json16k, H2O_STRLIT(CT_JSON)); +} + +static int on_json_64k(h2o_handler_t *self, h2o_req_t *req) +{ + if (!h2o_memis(req->method.base, req->method.len, H2O_STRLIT("GET"))) + return -1; + return send_static(req, json64k, H2O_STRLIT(CT_JSON)); +} + +/* /users/:id — h2o has no built-in path-param router; a path handler matches + * by prefix. We register this handler on "/users/" and slice the id out of + * the normalized path (everything after the prefix). The body is built in the + * request pool (request-lifetime) and duplicated by h2o_send. */ +static int on_users(h2o_handler_t *self, h2o_req_t *req) +{ + static const char prefix[] = "/users/"; + const size_t prefix_len = sizeof(prefix) - 1; + + if (!h2o_memis(req->method.base, req->method.len, H2O_STRLIT("GET"))) + return -1; + if (req->path_normalized.len < prefix_len) + return -1; + + const char *id = req->path_normalized.base + prefix_len; + size_t id_len = req->path_normalized.len - prefix_len; + + static const char body_prefix[] = "User ID: "; + const size_t body_prefix_len = sizeof(body_prefix) - 1; + + h2o_iovec_t body; + body.len = body_prefix_len + id_len; + body.base = h2o_mem_alloc_pool(&req->pool, char, body.len); + memcpy(body.base, body_prefix, body_prefix_len); + memcpy(body.base + body_prefix_len, id, id_len); + + static h2o_generator_t generator = {NULL, NULL}; + req->res.status = 200; + req->res.reason = "OK"; + h2o_add_header(&req->pool, &req->res.headers, H2O_TOKEN_CONTENT_TYPE, NULL, H2O_STRLIT(CT_TEXT)); + h2o_start_response(req, &generator); + h2o_send(req, &body, 1, H2O_SEND_STATE_FINAL); + return 0; +} + +/* /upload — read-and-discard the body, reply "OK". h2o buffers the full entity + * before invoking the handler (req->entity is the whole body; base == NULL if + * none), so the parse cost is already on the measured path — we just touch it + * and reply. */ +static int on_upload(h2o_handler_t *self, h2o_req_t *req) +{ + if (!h2o_memis(req->method.base, req->method.len, H2O_STRLIT("POST"))) + return -1; + /* Discard: a volatile read keeps the buffered body on the measured path + * without the compiler eliding it. */ + if (req->entity.base != NULL) { + volatile char sink = 0; + for (size_t i = 0; i < req->entity.len; ++i) + sink ^= req->entity.base[i]; + (void)sink; + } + return send_static(req, h2o_iovec_init(H2O_STRLIT("OK")), H2O_STRLIT(CT_TEXT)); +} + +static void register_handler(h2o_hostconf_t *hostconf, const char *path, + int (*on_req)(h2o_handler_t *, h2o_req_t *)) +{ + h2o_pathconf_t *pathconf = h2o_config_register_path(hostconf, path, 0); + h2o_handler_t *handler = h2o_create_handler(pathconf, sizeof(*handler)); + handler->on_req = on_req; +} + +/* ---- listener ---- */ + +/* create_listener binds host:port, reads the actually-bound address back + * (handles port 0), prints the ready line, and wires the fd into the evloop. + * Returns 0 on success, -1 on failure. */ +static int create_listener(const char *host, uint16_t port, char *bound_out, size_t bound_cap) +{ + struct sockaddr_in addr; + int fd, reuseaddr_flag = 1; + h2o_socket_t *sock; + + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + if (host == NULL || host[0] == '\0') { + addr.sin_addr.s_addr = htonl(INADDR_ANY); + } else if (inet_pton(AF_INET, host, &addr.sin_addr) != 1) { + fprintf(stderr, "h2o: bad -bind host %s (IPv4 literal expected)\n", host); + return -1; + } + + if ((fd = socket(AF_INET, SOCK_STREAM, 0)) == -1) { + perror("h2o: socket"); + return -1; + } + if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuseaddr_flag, sizeof(reuseaddr_flag)) != 0) { + perror("h2o: setsockopt(SO_REUSEADDR)"); + close(fd); + return -1; + } + /* SO_REUSEPORT lets every forked worker bind the SAME host:port with its + * own listen fd; the kernel load-balances incoming connections across + * them. This is what scales the single-evloop-per-process model to all + * cores (libh2o has no in-process multi-loop accept sharing). */ + if (setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &reuseaddr_flag, sizeof(reuseaddr_flag)) != 0) { + perror("h2o: setsockopt(SO_REUSEPORT)"); + close(fd); + return -1; + } + if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + perror("h2o: bind"); + close(fd); + return -1; + } + if (listen(fd, SOMAXCONN) != 0) { + perror("h2o: listen"); + close(fd); + return -1; + } + + /* Read back the bound address so a `host:0` bind reports the + * kernel-assigned port on the ready line. */ + struct sockaddr_in bound; + socklen_t bound_len = sizeof(bound); + if (getsockname(fd, (struct sockaddr *)&bound, &bound_len) != 0) { + perror("h2o: getsockname"); + close(fd); + return -1; + } + char ip[INET_ADDRSTRLEN]; + inet_ntop(AF_INET, &bound.sin_addr, ip, sizeof(ip)); + snprintf(bound_out, bound_cap, "%s:%u", ip, (unsigned)ntohs(bound.sin_port)); + + sock = h2o_evloop_socket_create(ctx.loop, fd, H2O_SOCKET_FLAG_DONT_READ); + h2o_socket_read_start(sock, on_accept); + return 0; +} + +static void on_accept(h2o_socket_t *listener, const char *err) +{ + h2o_socket_t *sock; + if (err != NULL) + return; + if ((sock = h2o_evloop_socket_accept(listener)) == NULL) + return; + h2o_accept(&accept_ctx, sock); +} + +/* ---- lifecycle ---- */ + +static volatile sig_atomic_t shutdown_requested = 0; + +static void on_signal(int signo) +{ + (void)signo; + shutdown_requested = 1; +} + +/* parse host:port. host may be empty ("" -> INADDR_ANY). Returns 0 on success. + * IPv6-bracketed forms are rejected (the bench dials IPv4 literals). */ +static int parse_bind(const char *bind, char *host_out, size_t host_cap, uint16_t *port_out) +{ + const char *colon = strrchr(bind, ':'); + if (colon == NULL) { + fprintf(stderr, "h2o: bad -bind %s (want host:port)\n", bind); + return -1; + } + size_t host_len = (size_t)(colon - bind); + if (host_len >= host_cap) { + fprintf(stderr, "h2o: -bind host too long\n"); + return -1; + } + memcpy(host_out, bind, host_len); + host_out[host_len] = '\0'; + + char *end = NULL; + long port = strtol(colon + 1, &end, 10); + if (end == colon + 1 || *end != '\0' || port < 0 || port > 65535) { + fprintf(stderr, "h2o: bad -bind port %s\n", colon + 1); + return -1; + } + *port_out = (uint16_t)port; + return 0; +} + +/* run_worker sets up an independent h2o context + evloop + SO_REUSEPORT + * listener and serves until SIGTERM flips shutdown_requested. Called once per + * forked worker (and by the parent). config/handlers are shared read-only; + * the loop + listener fd are private to this process. */ +static int run_worker(const char *host, uint16_t port) +{ + char bound[INET_ADDRSTRLEN + 8]; + + h2o_context_init(&ctx, h2o_evloop_create(), &config); + accept_ctx.ctx = &ctx; + accept_ctx.hosts = config.hosts; + + if (create_listener(host, port, bound, sizeof(bound)) != 0) + return -1; + + while (!shutdown_requested) { + if (h2o_evloop_run(ctx.loop, 100) != 0) + break; + } + return 0; +} + +/* resolve_listen_port turns a (possibly :0) request into a concrete port + a + * "host:port" string for the ready line WITHOUT holding a socket open. For an + * explicit port we format directly (no bind → no race). For port 0 we temp-bind + * with SO_REUSEPORT to learn the kernel-assigned port, then close; the workers + * re-bind it with SO_REUSEPORT. */ +static int resolve_listen_port(const char *host, uint16_t want_port, + uint16_t *out_port, char *bound_out, size_t bound_cap) +{ + if (want_port != 0) { + *out_port = want_port; + snprintf(bound_out, bound_cap, "%s:%u", + (host && host[0]) ? host : "0.0.0.0", (unsigned)want_port); + return 0; + } + + struct sockaddr_in addr; + int fd, one = 1; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = 0; + if (host == NULL || host[0] == '\0') { + addr.sin_addr.s_addr = htonl(INADDR_ANY); + } else if (inet_pton(AF_INET, host, &addr.sin_addr) != 1) { + fprintf(stderr, "h2o: bad -bind host %s (IPv4 literal expected)\n", host); + return -1; + } + if ((fd = socket(AF_INET, SOCK_STREAM, 0)) == -1) { + perror("h2o: socket"); + return -1; + } + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)); + if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + perror("h2o: bind"); + close(fd); + return -1; + } + struct sockaddr_in b; + socklen_t bl = sizeof(b); + if (getsockname(fd, (struct sockaddr *)&b, &bl) != 0) { + perror("h2o: getsockname"); + close(fd); + return -1; + } + char ip[INET_ADDRSTRLEN]; + inet_ntop(AF_INET, &b.sin_addr, ip, sizeof(ip)); + *out_port = ntohs(b.sin_port); + snprintf(bound_out, bound_cap, "%s:%u", ip, (unsigned)*out_port); + close(fd); + return 0; +} + +int main(int argc, char **argv) +{ + const char *bind = "127.0.0.1:8080"; + const char *engine = "h1"; + + for (int i = 1; i < argc; ++i) { + if ((strcmp(argv[i], "-bind") == 0 || strcmp(argv[i], "--bind") == 0) && i + 1 < argc) { + bind = argv[++i]; + } else if (strncmp(argv[i], "-bind=", 6) == 0) { + bind = argv[i] + 6; + } else if (strncmp(argv[i], "--bind=", 7) == 0) { + bind = argv[i] + 7; + } else if ((strcmp(argv[i], "-engine") == 0 || strcmp(argv[i], "--engine") == 0) && i + 1 < argc) { + engine = argv[++i]; + } else if (strncmp(argv[i], "-engine=", 8) == 0) { + engine = argv[i] + 8; + } else if (strncmp(argv[i], "--engine=", 9) == 0) { + engine = argv[i] + 9; + } + } + + /* Wire-protocol selection mirrors the drogon adapter. h2o's cleartext + * accept path serves h1 AND h2c prior-knowledge on one socket and cannot + * refuse h1, so a strict h2c-noupg column is impossible here: fail fast on + * -engine h2c rather than report h1 numbers under an h2c label. */ + if (strcmp(engine, "h2c") == 0) { + fprintf(stderr, + "h2o: h2c not served as a distinct column — h2o's cleartext " + "listener speaks both HTTP/1.1 and h2c prior-knowledge on the " + "same socket and cannot refuse h1, so it cannot satisfy the " + "strict h2c-noupg contract; refusing to serve h1 under an h2c " + "label\n"); + return 2; + } + if (strcmp(engine, "h1") != 0) { + fprintf(stderr, "h2o: unknown -engine %s (want h1 or h2c)\n", engine); + return 2; + } + + char host[256]; + uint16_t port; + if (parse_bind(bind, host, sizeof(host), &port) != 0) + return 1; + + signal(SIGPIPE, SIG_IGN); + signal(SIGTERM, on_signal); + signal(SIGINT, on_signal); + + json1k = generate_json_payload(1024); + json8k = generate_json_payload(8192); + json16k = generate_json_payload(16384); + json64k = generate_json_payload(65536); + + h2o_config_init(&config); + h2o_hostconf_t *hostconf = + h2o_config_register_host(&config, h2o_iovec_init(H2O_STRLIT("default")), 65535); + + /* Exact-path handlers first. h2o matches the LONGEST registered prefix, so + * "/users/" and "/json-1k" win over "/" for their paths; "/" is guarded to + * answer only the bare root (see on_root). */ + register_handler(hostconf, "/json-1k", on_json_1k); + register_handler(hostconf, "/json-8k", on_json_8k); + register_handler(hostconf, "/json-16k", on_json_16k); + register_handler(hostconf, "/json-64k", on_json_64k); + register_handler(hostconf, "/json", on_json); + register_handler(hostconf, "/users/", on_users); + register_handler(hostconf, "/upload", on_upload); + register_handler(hostconf, "/", on_root); + + /* Resolve the concrete port ONCE (handles :0) so every worker binds the + * same host:port via SO_REUSEPORT, then announce readiness once. The + * runner's TCP probe waits for this exact line. */ + uint16_t bound_port; + char bound[INET_ADDRSTRLEN + 8]; + if (resolve_listen_port(host, port, &bound_port, bound, sizeof(bound)) != 0) { + fprintf(stderr, "h2o: failed to resolve listen port for %s\n", bind); + return 1; + } + printf("ready addr=%s\n", bound); + fflush(stdout); + + /* One worker per online CPU (capped). The parent forks nworkers-1 children + * and then serves as a worker itself; each process owns an independent + * evloop + SO_REUSEPORT listener, so the kernel spreads connections across + * all cores. SIGTERM reaches the whole process group, draining every + * worker. A worker that cannot bind exits non-zero; the rest keep serving. */ + long nworkers = sysconf(_SC_NPROCESSORS_ONLN); + if (nworkers < 1) + nworkers = 1; + if (nworkers > 256) + nworkers = 256; + for (long i = 1; i < nworkers; ++i) { + pid_t pid = fork(); + if (pid == 0) + return run_worker(host, bound_port) == 0 ? 0 : 1; /* child */ + if (pid < 0) + perror("h2o: fork"); /* fewer workers, continue */ + } + return run_worker(host, bound_port) == 0 ? 0 : 1; /* parent serves too */ +} diff --git a/servers/hertz/go.mod b/servers/hertz/go.mod index bd98329..a6ff2c0 100644 --- a/servers/hertz/go.mod +++ b/servers/hertz/go.mod @@ -32,7 +32,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect diff --git a/servers/hertz/go.sum b/servers/hertz/go.sum index 9f96c23..cdcd7b8 100644 --- a/servers/hertz/go.sum +++ b/servers/hertz/go.sum @@ -73,8 +73,8 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/servers/hertz/server.go b/servers/hertz/server.go index 9a33e8e..b23757b 100644 --- a/servers/hertz/server.go +++ b/servers/hertz/server.go @@ -78,6 +78,14 @@ func registerRoutes(h *server.Hertz) { ctx.SetContentType("application/json") ctx.Response.SetBody(common.JSON1KPayload()) }) + h.GET("/json-8k", func(_ context.Context, ctx *app.RequestContext) { + ctx.SetContentType("application/json") + ctx.Response.SetBody(common.JSON8KPayload()) + }) + h.GET("/json-16k", func(_ context.Context, ctx *app.RequestContext) { + ctx.SetContentType("application/json") + ctx.Response.SetBody(common.JSON16KPayload()) + }) h.GET("/json-64k", func(_ context.Context, ctx *app.RequestContext) { ctx.SetContentType("application/json") ctx.Response.SetBody(common.JSON64KPayload()) diff --git a/servers/hono/src/h2c.ts b/servers/hono/src/h2c.ts new file mode 100644 index 0000000..9d0a50b --- /dev/null +++ b/servers/hono/src/h2c.ts @@ -0,0 +1,157 @@ +// HTTP/2 cleartext (h2c) prior-knowledge server for the Bun runtime. +// +// Why this exists +// --------------- +// Bun.serve speaks HTTP/1.1 natively and offers HTTP/2 ONLY behind TLS +// (the `tls:` option). There is no cleartext-h2 switch on Bun.serve as of +// Bun 1.3.14 — verified via `bun --help` (the only http2 flag is +// `--experimental-http2-fetch`, which is the *client* h2-over-TLS-ALPN +// path, not a server option). So to serve h2c prior-knowledge we drop to +// Bun's node:http2 compatibility layer, whose `http2.createServer()` +// stands up a cleartext h2 listener. We then bridge each h2 stream to the +// framework's WHATWG `fetch` handler so the route table and payloads stay +// byte-identical to the Bun.serve (h1) fast path. +// +// Bun-specific gotcha +// ------------------- +// Converting the inbound h2 request stream to a web ReadableStream via +// `Readable.toWeb(stream)` and handing it to `new Request(..., { body })` +// HANGS under Bun's node:http2 (the body never drains, /upload stalls). +// We therefore collect the request body manually off the node stream's +// "data"/"end" events into a Buffer before constructing the Request. GET +// requests carry no body and skip this entirely. +// +// This path is only taken for `-engine h2c`. `-engine h1` (or no flag) +// stays on Bun.serve and never imports this module's runtime cost. + +import http2 from "node:http2"; +import type { + Http2Server, + ServerHttp2Stream, + IncomingHttpHeaders, +} from "node:http2"; + +// FetchHandler is the WHATWG handler shape both Hono (app.fetch) and the +// other Bun adapters expose: a Request in, a Response (or Promise) out. +export type FetchHandler = (req: Request) => Response | Promise; + +export interface H2CListenResult { + hostname: string; + port: number; + stop: () => void; +} + +// serveH2C stands up a cleartext HTTP/2 (prior-knowledge) listener on +// host:port that dispatches every stream through `handler`. The returned +// promise resolves once the socket is listening, mirroring the synchronous +// readiness Bun.serve gives the h1 path. `port: 0` is honoured (the kernel +// assigns one) and the resolved port is reported back. +export function serveH2C( + host: string, + port: number, + handler: FetchHandler, +): Promise { + const server: Http2Server = http2.createServer(); + + server.on("stream", (stream, headers) => { + // node's "stream" event types the first arg as the base Http2Stream, + // but a server stream is always a ServerHttp2Stream (it carries + // respond()/headersSent — the half we use). Narrow it here. + void dispatch(stream as ServerHttp2Stream, headers, handler); + }); + + // A stream-level error (client RST, malformed frame) must not crash the + // process — the bench probes edge cases. Swallow it; the stream is gone. + server.on("error", () => {}); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => { + server.removeListener("error", reject); + const addr = server.address(); + const resolvedPort = + addr && typeof addr === "object" ? addr.port : port; + resolve({ + hostname: host, + port: resolvedPort, + stop: () => server.close(), + }); + }); + }); +} + +async function dispatch( + stream: ServerHttp2Stream, + headers: IncomingHttpHeaders, + handler: FetchHandler, +): Promise { + try { + const method = (headers[":method"] as string | undefined) ?? "GET"; + const path = (headers[":path"] as string | undefined) ?? "/"; + const authority = + (headers[":authority"] as string | undefined) ?? "127.0.0.1"; + const scheme = (headers[":scheme"] as string | undefined) ?? "http"; + const url = scheme + "://" + authority + path; + + // Copy non-pseudo headers across so the framework sees the real + // request headers (Content-Type on /upload, etc.). + const reqHeaders = new Headers(); + for (const key of Object.keys(headers)) { + if (key.startsWith(":")) continue; + const value = headers[key]; + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const v of value) reqHeaders.append(key, v); + } else { + reqHeaders.set(key, String(value)); + } + } + + const init: RequestInit = { method, headers: reqHeaders }; + const hasBody = method !== "GET" && method !== "HEAD"; + if (hasBody) { + // Manual drain — Readable.toWeb(stream) hangs under Bun's + // node:http2 (see module header). + init.body = await collectBody(stream); + } + + const response = await handler(new Request(url, init)); + + const outHeaders: Record = { + ":status": response.status, + }; + response.headers.forEach((value, key) => { + // node:http2 forbids connection-specific headers on h2 frames. + if (key === "connection" || key === "keep-alive") return; + outHeaders[key] = value; + }); + + const body = Buffer.from(await response.arrayBuffer()); + stream.respond(outHeaders); + stream.end(body); + } catch { + if (!stream.headersSent) { + try { + stream.respond({ ":status": 500 }); + } catch { + // stream already torn down — nothing to do. + } + } + try { + stream.end(); + } catch { + // ignore + } + } +} + +// collectBody pulls the inbound h2 request body off the node stream into a +// single Buffer. Used only for body-bearing methods (POST /upload). +function collectBody(stream: ServerHttp2Stream): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.on("end", () => resolve(Buffer.concat(chunks))); + stream.on("error", reject); + }); +} diff --git a/servers/hono/src/payload.ts b/servers/hono/src/payload.ts index c36fa83..46b3455 100644 --- a/servers/hono/src/payload.ts +++ b/servers/hono/src/payload.ts @@ -1,4 +1,4 @@ -// Deterministic 1 KiB / 64 KiB JSON payload generator. +// Deterministic 1/8/16/64 KiB JSON payload generator. // // Byte-identical port of probatorium/servers/common/payload.go. The Go // reference uses encoding/json on (paginatedResponse, paginatedItem), @@ -21,12 +21,16 @@ // Termination rule from the Go reference: append items until the // marshalled length is at least targetSize. Resulting sizes: // 1 KiB target -> 1026 bytes ending at item 9 +// 8 KiB target -> 8286 bytes ending at item 75 +// 16 KiB target -> 16463 bytes ending at item 147 // 64 KiB target -> 65618 bytes ending at item 583 const HEADER = '{"page":1,"per_page":50,"total":1000,"total_pages":20,"data":['; const FOOTER = "]}"; let json1k: Uint8Array | undefined; +let json8k: Uint8Array | undefined; +let json16k: Uint8Array | undefined; let json64k: Uint8Array | undefined; export function json1KPayload(): Uint8Array { @@ -34,6 +38,16 @@ export function json1KPayload(): Uint8Array { return json1k; } +export function json8KPayload(): Uint8Array { + if (!json8k) json8k = generate(8192); + return json8k; +} + +export function json16KPayload(): Uint8Array { + if (!json16k) json16k = generate(16384); + return json16k; +} + export function json64KPayload(): Uint8Array { if (!json64k) json64k = generate(65536); return json64k; diff --git a/servers/hono/src/server.ts b/servers/hono/src/server.ts index 8fd35f3..50fc394 100644 --- a/servers/hono/src/server.ts +++ b/servers/hono/src/server.ts @@ -17,14 +17,29 @@ // the canonical CLI form `bun run dist/server -- -bind 127.0.0.1:0` // puts our flags after `--`; we walk the array looking for `-bind` to // stay robust to either invocation shape. +// +// -engine flag: the bench passes `-engine ` only when the registry +// gives the adapter an Engine. Today hono has none, so no -engine arrives +// — but we parse it defensively so an added Engine works without a code +// change. "h1" (or absent) stays on the Bun.serve fast path. "h2c" serves +// HTTP/2 cleartext prior-knowledge via the node:http2 bridge in h2c.ts. +// Any other value exits non-zero with a clear message. import { Hono } from "hono"; -import { json1KPayload, json64KPayload } from "./payload"; +import { + json1KPayload, + json8KPayload, + json16KPayload, + json64KPayload, +} from "./payload"; +import { serveH2C } from "./h2c"; const HELLO = new TextEncoder().encode("Hello, World!"); const JSON_HELLO = new TextEncoder().encode('{"message":"Hello, World!"}'); const OK = new TextEncoder().encode("OK"); const JSON_1K = json1KPayload(); +const JSON_8K = json8KPayload(); +const JSON_16K = json16KPayload(); const JSON_64K = json64KPayload(); const TEXT = "text/plain"; @@ -57,6 +72,20 @@ app.get("/json-1k", (c) => }), ); +app.get("/json-8k", (c) => + c.body(JSON_8K, 200, { + "Content-Type": JSON_CT, + "Content-Length": String(JSON_8K.length), + }), +); + +app.get("/json-16k", (c) => + c.body(JSON_16K, 200, { + "Content-Type": JSON_CT, + "Content-Length": String(JSON_16K.length), + }), +); + app.get("/json-64k", (c) => c.body(JSON_64K, 200, { "Content-Type": JSON_CT, @@ -84,34 +113,51 @@ app.post("/upload", async (c) => { }); const { host, port } = parseBind(process.argv); +const engine = parseEngine(process.argv); -const server = Bun.serve({ - hostname: host, - port, - // reusePort lets the kernel SO_REUSEPORT load-balance across - // multiple Bun.serve workers if a future operator launches more - // than one process — harmless on a single-process bench. - reusePort: true, - fetch: app.fetch, -}); +if (engine === "h2c") { + // HTTP/2 cleartext prior-knowledge via node:http2 (see h2c.ts). Bun.serve + // has no cleartext-h2 server option as of Bun 1.3.14, so we bridge the h2 + // streams to the same app.fetch handler the h1 path uses. + const h2c = await serveH2C(host, port, app.fetch); + console.log(`ready addr=${h2c.hostname}:${h2c.port}`); -// Bun.serve.port is the resolved port (kernel-assigned when the -// caller passed 0). Print the ready line in the exact shape every -// other adapter uses so the runner's TCP-probe loop can attach. -console.log(`ready addr=${server.hostname}:${server.port}`); - -const shutdown = (signal: string): void => { - console.log(`hono: received ${signal}, shutting down`); - // stop(true) closes idle keep-alives immediately; in-flight - // requests still get to drain. Bun resolves the returned promise - // when the listener is fully torn down, but we don't await it — - // the runner's 5s SIGKILL backstop is the upper bound. - server.stop(true); - // Give Bun's loop a tick to finish flushing logs, then exit. - setTimeout(() => process.exit(0), 50); -}; -process.on("SIGTERM", () => shutdown("SIGTERM")); -process.on("SIGINT", () => shutdown("SIGINT")); + const shutdownH2C = (signal: string): void => { + console.log(`hono: received ${signal}, shutting down`); + h2c.stop(); + setTimeout(() => process.exit(0), 50); + }; + process.on("SIGTERM", () => shutdownH2C("SIGTERM")); + process.on("SIGINT", () => shutdownH2C("SIGINT")); +} else { + const server = Bun.serve({ + hostname: host, + port, + // reusePort lets the kernel SO_REUSEPORT load-balance across + // multiple Bun.serve workers if a future operator launches more + // than one process — harmless on a single-process bench. + reusePort: true, + fetch: app.fetch, + }); + + // Bun.serve.port is the resolved port (kernel-assigned when the + // caller passed 0). Print the ready line in the exact shape every + // other adapter uses so the runner's TCP-probe loop can attach. + console.log(`ready addr=${server.hostname}:${server.port}`); + + const shutdown = (signal: string): void => { + console.log(`hono: received ${signal}, shutting down`); + // stop(true) closes idle keep-alives immediately; in-flight + // requests still get to drain. Bun resolves the returned promise + // when the listener is fully torn down, but we don't await it — + // the runner's 5s SIGKILL backstop is the upper bound. + server.stop(true); + // Give Bun's loop a tick to finish flushing logs, then exit. + setTimeout(() => process.exit(0), 50); + }; + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); +} // parseBind walks argv looking for -bind (the canonical probatorium // flag). Falls back to BIND env var, then 0.0.0.0:8080. Accepts both @@ -144,3 +190,34 @@ function parseBind(argv: readonly string[]): { host: string; port: number } { } return { host, port }; } + +// parseEngine walks argv for -engine (accepts `-engine h1` and +// `-engine=h1`). Recognised values: +// "" / absent / "h1" → Bun.serve HTTP/1.1 fast path. +// "h2c" → HTTP/2 cleartext prior-knowledge (node:http2). +// Any other value is a hard error: better to fail loudly than silently +// serve the wrong protocol and skew the bench. The registry currently +// gives hono no Engine, so this returns "h1" in practice — but an added +// Engine flows through here without further changes. +function parseEngine(argv: readonly string[]): "h1" | "h2c" { + let raw = ""; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === undefined) continue; + if (a === "-engine" || a === "--engine") { + raw = argv[i + 1] ?? ""; + break; + } + if (a.startsWith("-engine=") || a.startsWith("--engine=")) { + raw = a.slice(a.indexOf("=") + 1); + break; + } + } + if (raw === "" || raw === "h1") return "h1"; + if (raw === "h2c") return "h2c"; + console.error( + `hono: unsupported -engine ${JSON.stringify(raw)} ` + + `(supported: h1, h2c)`, + ); + process.exit(2); +} diff --git a/servers/httpzig/.gitignore b/servers/httpzig/.gitignore new file mode 100644 index 0000000..4f9a228 --- /dev/null +++ b/servers/httpzig/.gitignore @@ -0,0 +1,4 @@ +zig-out/ +.zig-cache/ +zig-pkg/ +*.bak diff --git a/servers/httpzig/build.zig b/servers/httpzig/build.zig new file mode 100644 index 0000000..a061d89 --- /dev/null +++ b/servers/httpzig/build.zig @@ -0,0 +1,39 @@ +// Build script for the httpzig probatorium adapter. +// +// Produces a single ReleaseFast executable named `httpzig` under +// zig-out/bin/. The conformance test and the cluster build invoke +// `zig build -Doptimize=ReleaseFast`; the runner then execs the binary +// with `-bind ` and waits for the `ready addr=` line on stdout. +// +// Depends on karlseguin/http.zig (the `httpz` module), pinned in +// build.zig.zon. `zig build` resolves + fetches it into the build cache +// (ZIG_GLOBAL_CACHE_DIR on the bench host) at build time, so no vendoring. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + // Leave preferred_optimize_mode unset so the standard `-Doptimize` flag + // is registered. The cluster build and the conformance test both invoke + // `zig build -Doptimize=ReleaseFast`. + const optimize = b.standardOptimizeOption(.{}); + + const httpz = b.dependency("httpz", .{ + .target = target, + .optimize = optimize, + }); + + const exe = b.addExecutable(.{ + .name = "httpzig", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "httpz", .module = httpz.module("httpz") }, + }, + }), + }); + + b.installArtifact(exe); +} diff --git a/servers/httpzig/build.zig.zon b/servers/httpzig/build.zig.zon new file mode 100644 index 0000000..39ab1f9 --- /dev/null +++ b/servers/httpzig/build.zig.zon @@ -0,0 +1,17 @@ +.{ + .name = .httpzig, + .version = "0.1.0", + .fingerprint = 0xec64bd2b05b1f3e2, + .minimum_zig_version = "0.16.0", + .dependencies = .{ + .httpz = .{ + .url = "https://github.com/karlseguin/http.zig/archive/80a76ddee50e348f0c6ce5ccb6ac860dbb510f20.tar.gz", + .hash = "httpz-0.0.0-PNVzrIDeCAAPK5gkCAuTcCmqRfn0u65AYxxojzjsy-Pn", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/servers/httpzig/src/main.zig b/servers/httpzig/src/main.zig new file mode 100644 index 0000000..cc2c756 --- /dev/null +++ b/servers/httpzig/src/main.zig @@ -0,0 +1,276 @@ +// probatorium httpzig adapter — the Zig event-loop competitor column, +// rebuilt on karlseguin/http.zig after the std.http.Server entrant +// (servers/zig_zap) was retired for choking at 64+ concurrent dials. +// +// Why http.zig and not std.http.Server: Zig 0.16's IpAddress.listen has +// no usable SO_REUSEPORT fan-out for a worker pool, so the old adapter ran +// one shared listener accepted on under a mutex — fine at 8/16/32 conns, +// deadlocked at the bench's default 64. http.zig instead spins one worker +// thread per CPU, each binding the *same* address with SO_REUSEPORT_LB / +// SO_REUSEPORT (see its listen()), so the kernel load-balances accepts +// across cores with no userspace queue — the same architecture as +// celeris's epoll / iouring engines and the Rust/Go/.NET multi-listener +// competitors. +// +// Serves the canonical contract endpoints declared in +// servers/common/contract.go: +// +// GET / -> "Hello, World!" text/plain +// GET /json -> {"message":"Hello, World!"} application/json +// GET /json-1k -> deterministic JSON page (>= 1 KiB) +// GET /json-8k -> deterministic JSON page (>= 8 KiB) +// GET /json-16k -> deterministic JSON page (>= 16 KiB) +// GET /json-64k -> deterministic JSON page (>= 64 KiB) +// GET /users/:id -> "User ID: " text/plain +// POST /upload -> read-and-discard body, reply "OK" text/plain +// +// Driver-backed (/db, /cache, /mc, /session) and chain-* endpoints are not +// served — this column declares Capabilities{Static: true} only, so the +// scenario applicability filter (servers/servers.go) never schedules them +// against this adapter. +// +// CLI: +// -bind default 127.0.0.1:8080. Pass `:0` (or any `host:0`) +// to let the kernel allocate a port; the concrete +// bound address is reported on stdout via the +// `ready addr=` line the runner waits for before +// opening loadgen. +// -engine accepted for CLI parity with the registry. http.zig +// is HTTP/1.1-only, so anything other than h1 is +// rejected (the registry registers no httpzig-h2 +// column, so this is never exercised in practice). +// +// Zig 0.16 entry point: main receives a std.process.Init — `init.gpa` is +// the allocator, `init.io` the blocking Io, `init.minimal.args` the argv +// iterator. http.zig's Server.init takes (io, allocator, config, handler). + +const std = @import("std"); +const httpz = @import("httpz"); +const net = std.Io.net; +const Io = std.Io; +const payload = @import("payload.zig"); + +// Static contract bodies. The JSON payloads are built once at startup into +// process-global slices shared read-only across every worker thread. +const hello_body = "Hello, World!"; +const json_hello = "{\"message\":\"Hello, World!\"}"; +const upload_body = "OK"; + +const ct_text = "text/plain"; +const ct_json = "application/json"; + +var json_1k: []const u8 = undefined; +var json_8k: []const u8 = undefined; +var json_16k: []const u8 = undefined; +var json_64k: []const u8 = undefined; + +// Server pointer parked for the signal handler so SIGINT/SIGTERM can call +// stop(), unblocking listen() for a graceful drain. Default SIGTERM would +// terminate the process anyway (the runner SIGTERMs the process group), +// but an explicit stop() lets in-flight responses finish first. +const Server = httpz.Server(void); +var gserver: ?*Server = null; + +fn onSignal(_: std.posix.SIG) callconv(.c) void { + if (gserver) |s| s.stop(); +} + +// ---- handlers ------------------------------------------------------------- + +// Each static handler writes a fixed body + bare Content-Type. We set the +// header explicitly (not res.content_type) so the wire value is exactly +// "text/plain" / "application/json" — byte-identical to the Go adapters — +// rather than http.zig's enum form ("text/plain; charset=UTF-8"). + +fn root(_: *httpz.Request, res: *httpz.Response) !void { + res.header("Content-Type", ct_text); + res.body = hello_body; +} + +fn jsonHello(_: *httpz.Request, res: *httpz.Response) !void { + res.header("Content-Type", ct_json); + res.body = json_hello; +} + +fn json1k(_: *httpz.Request, res: *httpz.Response) !void { + res.header("Content-Type", ct_json); + res.body = json_1k; +} + +fn json8k(_: *httpz.Request, res: *httpz.Response) !void { + res.header("Content-Type", ct_json); + res.body = json_8k; +} + +fn json16k(_: *httpz.Request, res: *httpz.Response) !void { + res.header("Content-Type", ct_json); + res.body = json_16k; +} + +fn json64k(_: *httpz.Request, res: *httpz.Response) !void { + res.header("Content-Type", ct_json); + res.body = json_64k; +} + +// users echoes the :id path param. The formatted body is allocated on +// res.arena (reset per request) so it outlives the handler return — a +// stack buffer would dangle when http.zig serializes the response after +// the handler completes. +fn users(req: *httpz.Request, res: *httpz.Response) !void { + const id = req.param("id") orelse ""; + res.header("Content-Type", ct_text); + res.body = try std.fmt.allocPrint(res.arena, "User ID: {s}", .{id}); +} + +// upload reads-and-discards the request body (req.body() consumes it) and +// replies with the literal "OK" the contract demands. +fn upload(req: *httpz.Request, res: *httpz.Response) !void { + _ = req.body(); + res.header("Content-Type", ct_text); + res.body = upload_body; +} + +// ---- bind helpers --------------------------------------------------------- + +// parseBind walks argv (Go-flag style `-bind `, matching every other +// adapter) and returns the bind string. The iterator's byte slices point +// into argv memory that lives for the whole process, so no copy is needed. +fn parseBind(args: std.process.Args) []const u8 { + var it = std.process.Args.Iterator.init(args); + _ = it.next(); // argv[0] + while (it.next()) |arg| { + if (eql(arg, "-bind") or eql(arg, "--bind")) { + if (it.next()) |v| return v; + } + if (std.mem.startsWith(u8, arg, "-bind=")) return arg["-bind=".len..]; + if (std.mem.startsWith(u8, arg, "--bind=")) return arg["--bind=".len..]; + } + return "127.0.0.1:8080"; +} + +// parseEngine returns the -engine value (default "h1"). http.zig is +// HTTP/1.1-only; main rejects anything else. +fn parseEngine(args: std.process.Args) []const u8 { + var it = std.process.Args.Iterator.init(args); + _ = it.next(); + while (it.next()) |arg| { + if (eql(arg, "-engine") or eql(arg, "--engine")) { + if (it.next()) |v| return v; + } + if (std.mem.startsWith(u8, arg, "-engine=")) return arg["-engine=".len..]; + if (std.mem.startsWith(u8, arg, "--engine=")) return arg["--engine=".len..]; + } + return "h1"; +} + +fn eql(a: []const u8, b: []const u8) bool { + return std.mem.eql(u8, a, b); +} + +// resolvePort binds a throwaway listener on host:port and reads back the +// concrete local port via getsockname, resolving the `:0` (kernel-assigned) +// case. The probe socket is closed immediately; http.zig then re-binds the +// same concrete port (both sockets set SO_REUSEADDR, so the rebind window +// is harmless). This mirrors servers/zig_zap's boundPort approach because +// http.zig keeps its listener fd private and only binds inside the blocking +// listen() call — so we cannot read the port off http.zig itself before +// the ready line must be printed. +fn resolvePort(io: Io, host: []const u8, port: u16) !u16 { + if (port != 0) return port; + const addr = try net.IpAddress.parse(host, 0); + var probe = try net.IpAddress.listen(&addr, io, .{ .reuse_address = true }); + defer probe.deinit(io); + return boundPort(probe.socket.handle); +} + +// boundPort reads the concrete local port off a bound socket via +// getsockname. posix.system.getsockname is the raw syscall wrapper — +// std.posix.getsockname does not exist in this toolchain. Returns 0 on +// failure. +fn boundPort(handle: net.Socket.Handle) u16 { + var storage: std.posix.sockaddr.storage = undefined; + var len: std.posix.socklen_t = @sizeOf(std.posix.sockaddr.storage); + if (std.posix.system.getsockname(handle, @ptrCast(&storage), &len) != 0) return 0; + const port_be: u16 = switch (storage.family) { + std.posix.AF.INET => @as(*const std.posix.sockaddr.in, @ptrCast(@alignCast(&storage))).port, + std.posix.AF.INET6 => @as(*const std.posix.sockaddr.in6, @ptrCast(@alignCast(&storage))).port, + else => return 0, + }; + return std.mem.bigToNative(u16, port_be); +} + +pub fn main(init: std.process.Init) !void { + const alloc = init.gpa; + const io = init.io; + + const bind = parseBind(init.minimal.args); + const engine = parseEngine(init.minimal.args); + if (!eql(engine, "h1")) { + std.debug.print("httpzig: unsupported -engine {s} (HTTP/1.1 only)\n", .{engine}); + return error.UnsupportedEngine; + } + + json_1k = try payload.generate(alloc, 1024); + json_8k = try payload.generate(alloc, 8192); + json_16k = try payload.generate(alloc, 16384); + json_64k = try payload.generate(alloc, 65536); + + // IpAddress.parse takes host and port separately, so split the bind + // string on its last colon. A `:0` port is resolved to a concrete + // kernel-assigned port before http.zig binds. + const colon = std.mem.lastIndexOfScalar(u8, bind, ':') orelse return error.InvalidBind; + const host = bind[0..colon]; + const req_port = try std.fmt.parseInt(u16, bind[colon + 1 ..], 10); + const port = try resolvePort(io, host, req_port); + const ip = try net.IpAddress.parse(host, port); + + // One worker per CPU so http.zig fans accepts out across cores via + // SO_REUSEPORT_LB / SO_REUSEPORT — the whole reason this adapter + // replaces the single-listener std.http entrant. + const cpus: u16 = @intCast(std.Thread.getCpuCount() catch 1); + + var server = try Server.init(io, alloc, .{ + .address = .{ .ip = ip }, + .workers = .{ .count = cpus }, + }, {}); + defer { + server.stop(); + server.deinit(); + } + gserver = &server; + + var router = try server.router(.{}); + router.get("/", root, .{}); + router.get("/json", jsonHello, .{}); + router.get("/json-1k", json1k, .{}); + router.get("/json-8k", json8k, .{}); + router.get("/json-16k", json16k, .{}); + router.get("/json-64k", json64k, .{}); + router.get("/users/:id", users, .{}); + router.post("/upload", upload, .{}); + + // Graceful shutdown: SIGINT/SIGTERM -> server.stop() -> listen() returns. + installSignals(); + + // Report the bound port (resolving the :0 case) on the ready line + // before listen() blocks. The runner's TCP probe waits for the addr to + // answer; the conformance harness waits for this exact line. + var out_buf: [128]u8 = undefined; + const msg = try std.fmt.bufPrint(&out_buf, "ready addr={s}:{d}\n", .{ host, port }); + var stdout_buf: [128]u8 = undefined; + var stdout = Io.File.stdout().writer(io, &stdout_buf); + try stdout.interface.writeAll(msg); + try stdout.interface.flush(); + + try server.listen(); // blocks until stop() +} + +fn installSignals() void { + var act = std.posix.Sigaction{ + .handler = .{ .handler = onSignal }, + .mask = std.posix.sigemptyset(), + .flags = 0, + }; + std.posix.sigaction(std.posix.SIG.INT, &act, null); + std.posix.sigaction(std.posix.SIG.TERM, &act, null); +} diff --git a/servers/httpzig/src/payload.zig b/servers/httpzig/src/payload.zig new file mode 100644 index 0000000..2fdfc9b --- /dev/null +++ b/servers/httpzig/src/payload.zig @@ -0,0 +1,56 @@ +// Deterministic JSON payload generator (1 KiB / 8 KiB / 16 KiB / 64 KiB). +// +// Byte-identical port of probatorium/servers/common/payload.go. The Go +// reference marshals the (paginatedResponse, paginatedItem) struct pair +// with encoding/json, which emits compact JSON in struct-declaration field +// order: page, per_page, total, total_pages, data for the wrapper; id, +// name, email, status, created_at for each item. +// +// We emit the bytes by hand rather than via a JSON encoder: byte-for-byte +// equivalence with the Go reference is a hard conformance requirement +// (the runner's assertContract does an exact bytes compare), and the +// corpus is tiny pure-ASCII, so manual formatting is both correct and +// trivially auditable. The termination rule mirrors the Go loop — append +// items until the marshalled length crosses targetSize — fixing the sizes +// for every target the contract benches (1k / 8k / 16k / 64k). + +const std = @import("std"); + +const header = "{\"page\":1,\"per_page\":50,\"total\":1000,\"total_pages\":20,\"data\":["; +const footer = "]}"; + +// generate builds a paginated-response payload of at least target_size +// bytes into a freshly allocated buffer owned by the caller. +pub fn generate(alloc: std.mem.Allocator, target_size: usize) ![]u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(alloc); + + try buf.appendSlice(alloc, header); + var i: u64 = 1; + while (true) : (i += 1) { + if (i > 1) try buf.append(alloc, ','); + try appendItem(alloc, &buf, i); + // Tentative size = current buf + footer length; the footer is + // fixed-length so we never need to re-marshal the whole thing. + if (buf.items.len + footer.len >= target_size) break; + } + try buf.appendSlice(alloc, footer); + return buf.toOwnedSlice(alloc); +} + +// appendItem writes one paginatedItem in the exact byte form +// encoding/json produces for the Go struct: +// {"id":,"name":"User ","email":"user@example.com", +// "status":"active","created_at":"2024-01-15T09:30:00Z"} +fn appendItem(alloc: std.mem.Allocator, buf: *std.ArrayList(u8), n: u64) !void { + var num: [20]u8 = undefined; + const ns = std.fmt.bufPrint(&num, "{d}", .{n}) catch unreachable; + + try buf.appendSlice(alloc, "{\"id\":"); + try buf.appendSlice(alloc, ns); + try buf.appendSlice(alloc, ",\"name\":\"User "); + try buf.appendSlice(alloc, ns); + try buf.appendSlice(alloc, "\",\"email\":\"user"); + try buf.appendSlice(alloc, ns); + try buf.appendSlice(alloc, "@example.com\",\"status\":\"active\",\"created_at\":\"2024-01-15T09:30:00Z\"}"); +} diff --git a/servers/hyper/Cargo.lock b/servers/hyper/Cargo.lock index 647838a..fca06f3 100644 --- a/servers/hyper/Cargo.lock +++ b/servers/hyper/Cargo.lock @@ -14,6 +14,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -24,6 +30,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "futures-channel" version = "0.3.32" @@ -39,6 +51,37 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "http" version = "1.4.1" @@ -94,6 +137,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -118,6 +162,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itoa" version = "1.0.18" @@ -147,6 +201,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -235,6 +295,12 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -289,6 +355,38 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/servers/hyper/Cargo.toml b/servers/hyper/Cargo.toml index f1f53d0..5bec7eb 100644 --- a/servers/hyper/Cargo.toml +++ b/servers/hyper/Cargo.toml @@ -39,16 +39,19 @@ name = "probatorium-hyper-server" path = "src/main.rs" [dependencies] -# hyper — the raw HTTP/1 server. We drive http1::Builder::serve_connection -# directly on accepted sockets, so the only hyper features we need are the -# H1 server stack. No router, no tower, no axum layer — this is the -# baseline competitors above it are measured against. -hyper = { version = ">=1", features = ["http1", "server"] } +# hyper — the raw HTTP server. We drive http1::Builder::serve_connection +# (engine h1) or http2::Builder::serve_connection (engine h2c) directly on +# accepted sockets. The "http2" feature pulls hyper's H2 server stack used +# for prior-knowledge h2c cleartext. No router, no tower, no axum layer — +# this is the baseline competitors above it are measured against. +hyper = { version = ">=1", features = ["http1", "http2", "server"] } # hyper-util — the small glue layer hyper 1.x split out: TokioIo (adapts a -# tokio TcpStream to hyper's I/O traits) and the graceful-shutdown watcher. -# The `http1` feature is what makes hyper's H1 Connection implement -# GracefulConnection, which GracefulShutdown::watch requires. -hyper-util = { version = ">=0.1", features = ["tokio", "server", "server-graceful", "http1"] } +# tokio TcpStream to hyper's I/O traits), TokioExecutor (the +# Http2ServerConnExec the http2 builder requires), and the +# graceful-shutdown watcher. The `http1`/`http2` features make hyper's +# H1/H2 Connection implement GracefulConnection, which +# GracefulShutdown::watch requires. +hyper-util = { version = ">=0.1", features = ["tokio", "server", "server-graceful", "http1", "http2"] } # http-body-util — Full body type for fixed-size response bodies and # BodyExt::collect for draining the /upload request body. http-body-util = ">=0.1" diff --git a/servers/hyper/src/main.rs b/servers/hyper/src/main.rs index 1b154f8..62c4170 100644 --- a/servers/hyper/src/main.rs +++ b/servers/hyper/src/main.rs @@ -11,6 +11,8 @@ // GET / -> "Hello, World!" text/plain // GET /json -> {"message":"Hello, World!"} application/json // GET /json-1k -> deterministic 1026-byte JSON page +// GET /json-8k -> deterministic 8286-byte JSON page +// GET /json-16k -> deterministic 16463-byte JSON page // GET /json-64k -> deterministic 65618-byte JSON page // GET /users/{id} -> "User ID: " text/plain // POST /upload -> read-and-discard body, reply "OK" text/plain @@ -26,6 +28,15 @@ // bound address is reported on stdout via the // `ready addr=` line that the runner waits for // before opening loadgen. +// -engine default "h1". One of: +// h1 — plain HTTP/1.1 (http1::Builder, as before). +// h2c — HTTP/2 cleartext, PRIOR-KNOWLEDGE only: +// each accepted TCP conn is served through +// http2::Builder, so the client must open +// with the h2 preface (curl +// --http2-prior-knowledge). No TLS, no h1->h2 +// upgrade. Mirrors stdhttp-h2's h2c-noupg +// mode. Unknown values exit non-zero. // // Lifecycle: SIGTERM (or SIGINT) stops accepting new connections and the // hyper-util GracefulShutdown watcher drains in-flight connections, well @@ -36,6 +47,7 @@ mod payload; use std::convert::Infallible; use std::io::Write as _; use std::net::SocketAddr; +use std::process::ExitCode; use bytes::Bytes; use http_body_util::{BodyExt, Full}; @@ -43,20 +55,52 @@ use hyper::body::Incoming; use hyper::header::{HeaderValue, CONTENT_TYPE}; use hyper::service::service_fn; use hyper::{Method, Request, Response, StatusCode}; -use hyper_util::rt::TokioIo; +use hyper_util::rt::{TokioExecutor, TokioIo}; use hyper_util::server::graceful::GracefulShutdown; use tokio::net::TcpListener; use tokio::signal::unix::{signal, SignalKind}; +// Engine names the wire protocol the listener speaks. h2c is +// prior-knowledge-only (no h1 fallback on that listener), mirroring the +// stdhttp adapter's h2c-noupg mode. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Engine { + H1, + H2c, +} + #[tokio::main(flavor = "multi_thread")] -async fn main() -> std::io::Result<()> { +async fn main() -> ExitCode { + let engine = match parse_engine_arg() { + Ok(e) => e, + Err(msg) => { + eprintln!("{msg}"); + return ExitCode::FAILURE; + } + }; let bind = parse_bind_arg().unwrap_or_else(|| "127.0.0.1:8080".to_string()); - let addr: SocketAddr = bind - .parse() - .unwrap_or_else(|e| panic!("hyper: bad -bind {bind:?}: {e}")); + let addr: SocketAddr = match bind.parse() { + Ok(a) => a, + Err(e) => { + eprintln!("hyper: bad -bind {bind:?}: {e}"); + return ExitCode::FAILURE; + } + }; - let listener = TcpListener::bind(addr).await?; - let local = listener.local_addr()?; + let listener = match TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + eprintln!("hyper: bind {addr}: {e}"); + return ExitCode::FAILURE; + } + }; + let local = match listener.local_addr() { + Ok(a) => a, + Err(e) => { + eprintln!("hyper: local_addr: {e}"); + return ExitCode::FAILURE; + } + }; // The runner's TCP probe waits for this exact line on stdout. Print // and flush before the accept loop so the probe never races the @@ -65,10 +109,12 @@ async fn main() -> std::io::Result<()> { let _ = std::io::stdout().flush(); // hyper 1.x has no top-level serve() like axum: we own the accept - // loop. http1::Builder serves one connection per accepted socket; + // loop. Each accepted socket is served as one connection — http1 for + // engine h1, http2 (prior-knowledge cleartext) for engine h2c. // GracefulShutdown tracks them so SIGTERM drains in-flight requests // instead of cutting them mid-response. - let http = hyper::server::conn::http1::Builder::new(); + let http1 = hyper::server::conn::http1::Builder::new(); + let http2 = hyper::server::conn::http2::Builder::new(TokioExecutor::new()); let graceful = GracefulShutdown::new(); // Pin the shutdown future so it can be polled by reference across loop // iterations inside select! without being moved. @@ -86,13 +132,27 @@ async fn main() -> std::io::Result<()> { Err(_) => continue, }; let io = TokioIo::new(stream); - let conn = http.serve_connection(io, service_fn(handle)); - let fut = graceful.watch(conn); - tokio::spawn(async move { - // Per-connection errors (client resets, partial sends) - // are expected churn under load; drop them. - let _ = fut.await; - }); + // serve_connection returns different Connection types for + // h1 vs h2; watch() each in its own arm so the graceful + // watcher tracks both without a boxed trait object. + match engine { + Engine::H1 => { + let conn = http1.serve_connection(io, service_fn(handle)); + let fut = graceful.watch(conn); + tokio::spawn(async move { + // Per-connection errors (client resets, partial + // sends) are expected churn under load; drop them. + let _ = fut.await; + }); + } + Engine::H2c => { + let conn = http2.serve_connection(io, service_fn(handle)); + let fut = graceful.watch(conn); + tokio::spawn(async move { + let _ = fut.await; + }); + } + } } _ = &mut shutdown => { // Stop accepting; fall through to draining below. @@ -109,7 +169,7 @@ async fn main() -> std::io::Result<()> { _ = tokio::time::sleep(std::time::Duration::from_secs(3)) => {} } - Ok(()) + ExitCode::SUCCESS } // parse_bind_arg walks argv looking for `-bind ` (Go-flag style, @@ -132,6 +192,28 @@ fn parse_bind_arg() -> Option { None } +// parse_engine_arg reads `-engine ` (default "h1"). Accepts "h1" +// and "h2c"; any other value is a hard error so a typo in the runner's +// invocation fails fast and visibly rather than silently serving h1. +fn parse_engine_arg() -> Result { + let mut value: Option = None; + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + if a == "-engine" || a == "--engine" { + value = args.next(); + } else if let Some(rest) = a.strip_prefix("-engine=") { + value = Some(rest.to_string()); + } else if let Some(rest) = a.strip_prefix("--engine=") { + value = Some(rest.to_string()); + } + } + match value.as_deref() { + None | Some("h1") => Ok(Engine::H1), + Some("h2c") => Ok(Engine::H2c), + Some(other) => Err(format!("hyper: unknown -engine {other:?} (want h1|h2c)")), + } +} + // shutdown_signal resolves on the first SIGTERM or SIGINT. Returning ends // the accept loop, which then drains via GracefulShutdown. async fn shutdown_signal() { @@ -157,6 +239,8 @@ async fn handle(req: Request) -> Result>, Infalli json_response(Bytes::from_static(br#"{"message":"Hello, World!"}"#)) } (&Method::GET, "/json-1k") => json_response(Bytes::from_static(payload::json_1k())), + (&Method::GET, "/json-8k") => json_response(Bytes::from_static(payload::json_8k())), + (&Method::GET, "/json-16k") => json_response(Bytes::from_static(payload::json_16k())), (&Method::GET, "/json-64k") => json_response(Bytes::from_static(payload::json_64k())), (&Method::POST, "/upload") => { // Read-and-discard the request body so /upload exercises the diff --git a/servers/hyper/src/payload.rs b/servers/hyper/src/payload.rs index fe4aba0..903ff16 100644 --- a/servers/hyper/src/payload.rs +++ b/servers/hyper/src/payload.rs @@ -13,12 +13,22 @@ use std::sync::OnceLock; static JSON_1K: OnceLock> = OnceLock::new(); +static JSON_8K: OnceLock> = OnceLock::new(); +static JSON_16K: OnceLock> = OnceLock::new(); static JSON_64K: OnceLock> = OnceLock::new(); pub fn json_1k() -> &'static [u8] { JSON_1K.get_or_init(|| generate(1024)).as_slice() } +pub fn json_8k() -> &'static [u8] { + JSON_8K.get_or_init(|| generate(8192)).as_slice() +} + +pub fn json_16k() -> &'static [u8] { + JSON_16K.get_or_init(|| generate(16384)).as_slice() +} + pub fn json_64k() -> &'static [u8] { JSON_64K.get_or_init(|| generate(65536)).as_slice() } @@ -79,6 +89,16 @@ mod tests { assert_eq!(json_1k().len(), 1026); } + #[test] + fn json_8k_matches_go_size() { + assert_eq!(json_8k().len(), 8286); + } + + #[test] + fn json_16k_matches_go_size() { + assert_eq!(json_16k().len(), 16463); + } + #[test] fn json_64k_matches_go_size() { assert_eq!(json_64k().len(), 65618); diff --git a/servers/iris/go.mod b/servers/iris/go.mod index c56e8c6..83712a8 100644 --- a/servers/iris/go.mod +++ b/servers/iris/go.mod @@ -52,7 +52,7 @@ require ( go.uber.org/atomic v1.11.0 // indirect golang.org/x/crypto v0.53.0 // indirect golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect diff --git a/servers/iris/go.sum b/servers/iris/go.sum index 6fa9f50..26d3fbf 100644 --- a/servers/iris/go.sum +++ b/servers/iris/go.sum @@ -164,8 +164,8 @@ golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= diff --git a/servers/iris/server.go b/servers/iris/server.go index 4fe26df..bb71772 100644 --- a/servers/iris/server.go +++ b/servers/iris/server.go @@ -81,6 +81,14 @@ func registerRoutes(app *iris.Application) { ctx.ContentType("application/json") _, _ = ctx.Write(common.JSON1KPayload()) }) + app.Get("/json-8k", func(ctx iris.Context) { + ctx.ContentType("application/json") + _, _ = ctx.Write(common.JSON8KPayload()) + }) + app.Get("/json-16k", func(ctx iris.Context) { + ctx.ContentType("application/json") + _, _ = ctx.Write(common.JSON16KPayload()) + }) app.Get("/json-64k", func(ctx iris.Context) { ctx.ContentType("application/json") _, _ = ctx.Write(common.JSON64KPayload()) diff --git a/servers/libreactor/.gitignore b/servers/libreactor/.gitignore new file mode 100644 index 0000000..fed3635 --- /dev/null +++ b/servers/libreactor/.gitignore @@ -0,0 +1,2 @@ +libreactor-adapter +*.o diff --git a/servers/libreactor/Makefile b/servers/libreactor/Makefile new file mode 100644 index 0000000..b23ef84 --- /dev/null +++ b/servers/libreactor/Makefile @@ -0,0 +1,41 @@ +# probatorium libreactor adapter — build manifest. +# +# Produces ./libreactor-adapter, a minimal HTTP/1.1 epoll server built on +# libreactor 3.x (fredrikwidlund/libreactor) — the historic TechEmpower #1 +# C framework. The bench cluster builds libreactor from source into a +# pristine prefix under {bench_root}/libreactor/prefix (see +# ansible/roles/c), then invokes this Makefile with PREFIX pointed at it: +# +# make PREFIX={bench_root}/libreactor/prefix +# +# locally you can install libreactor to /usr/local (autogen + configure + +# make install) and just run `make`. +# +# Link line: libreactor 3.x ships a SINGLE static archive `libreactor` +# (there is NO separate libdynamic / libclo — those were pre-2.x packages). +# The server module embeds an SSL_CTX and pulls , so the +# program links OpenSSL too: -lreactor -lssl -lcrypto. -pthread covers the +# library's async/resolver threads. + +PREFIX ?= /usr/local +CC ?= cc + +# -I/-L resolve against the libreactor install prefix. -O3 -flto +# -march=native mirror libreactor's own example/Makefile.am AM_CFLAGS so +# the adapter is benched at the framework's intended optimization level. +CFLAGS ?= -O3 -flto -march=native -Wall -Wextra +CFLAGS += -I$(PREFIX)/include +LDFLAGS ?= -flto +LDFLAGS += -L$(PREFIX)/lib +LDLIBS := -lreactor -lssl -lcrypto -pthread + +BIN := libreactor-adapter +SRC := main.c + +$(BIN): $(SRC) + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(SRC) $(LDLIBS) + +clean: + rm -f $(BIN) + +.PHONY: clean diff --git a/servers/libreactor/main.c b/servers/libreactor/main.c new file mode 100644 index 0000000..a6ffcca --- /dev/null +++ b/servers/libreactor/main.c @@ -0,0 +1,387 @@ +/* probatorium libreactor adapter — wave 5 (C). + * + * Same canonical contract as servers/axum, servers/drogon, served on a + * hand-rolled HTTP/1.1 epoll loop via libreactor's high-level `server` + * abstraction (src/reactor/server.h). Lifecycle and CLI match the other + * native competitors so servers.StartAdapter launches every adapter with + * one invocation pattern (`{bin} -bind `, wait for the + * `ready addr=` stdout line, SIGTERM for graceful shutdown). + * + * Endpoint set (canonical bytes in servers/common/contract.go): + * GET / -> "Hello, World!" text/plain + * GET /json -> {"message":"Hello, World!"} application/json + * GET /json-1k -> deterministic 1026-byte JSON page + * GET /json-8k -> deterministic 8286-byte JSON page + * GET /json-16k -> deterministic 16463-byte JSON page + * GET /json-64k -> deterministic 65618-byte JSON page + * GET /users/:id -> "User ID: " text/plain + * POST /upload -> read-and-discard body, reply "OK" text/plain + * + * Driver-backed (/db, /cache, /mc, /session) and chain-* endpoints are out + * of scope (Capabilities{Static:true} only), so unknown targets 404 — the + * scenario applicability filter in servers/servers.go never schedules those + * classes against this column. + * + * Engine: libreactor's server speaks HTTP/1.1 only (it parses requests with + * picohttpparser and serializes HTTP/1.1 responses; there is no HTTP/2 + * framing layer anywhere in the library). h2c cleartext prior-knowledge is + * therefore impossible, so `-engine h2c` fails fast with a non-zero exit — + * exactly like drogon — and no libreactor-h2 column is registered. + * + * Why bytes-by-hand for JSON: the conformance probe byte-compares response + * bodies against the Go-generated payload in common.Endpoints. We emit the + * paginated JSON by hand (no clo encoder) so every byte matches Go's + * encoding/json compact output. libclo is still linked (the canonical + * libreactor link line is -lreactor -ldynamic -lclo) but unused here. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +/* The umbrella pulls in reactor/{data,net,http,server,...}.h, so + * the server API, the `data` view type, and net_resolve/net_socket are all + * available from this one include (libreactor 3.x). */ +#include + +/* The single live server, parked at file scope so the SIGTERM/SIGINT + * handler can ask the reactor to stop. server_shutdown() is the library's + * graceful path: it stops accepting, drains in-flight requests, and lets + * reactor_loop() return. */ +static server g_server; +static volatile sig_atomic_t g_stopping = 0; + +/* Pre-baked response bodies. Built once in main() and pointed at by `data` + * views so every request reuses the same immutable bytes — no per-request + * allocation, mirroring the Go adapters that serve a pre-computed slice. */ +static data g_hello_plain; /* "Hello, World!" */ +static data g_hello_json; /* {"message":"Hello, World!"} */ +static data g_ok_plain; /* "OK" */ +static data g_json_1k; +static data g_json_8k; +static data g_json_16k; +static data g_json_64k; + +static const data CT_TEXT = {.base = "text/plain", .size = 10}; +static const data CT_JSON = {.base = "application/json", .size = 16}; + +/* Byte-identical port of servers/common/payload.go generateJSONPayload. + * The Go reference marshals (paginatedResponse, paginatedItem) with + * encoding/json, which emits compact JSON in struct-declaration field order. + * We emit those bytes by hand so the body is byte-for-byte equal to the Go + * generator. Termination rule mirrors Go: append items until the full + * marshalled length (header + items + footer) crosses target_size. Sizes: + * 1 KiB target -> 1026 bytes + * 8 KiB target -> 8286 bytes + * 16 KiB target -> 16463 bytes + * 64 KiB target -> 65618 bytes + * Returns a heap buffer (never freed — lives for the process lifetime) and + * writes its length through *out_len. */ +static char *generate_json_payload(size_t target_size, size_t *out_len) +{ + static const char header[] = + "{\"page\":1,\"per_page\":50,\"total\":1000,\"total_pages\":20,\"data\":["; + static const char footer[] = "]}"; + const size_t header_len = sizeof header - 1; + const size_t footer_len = sizeof footer - 1; + + size_t cap = target_size + 256; + char *buf = malloc(cap); + if (!buf) + { + perror("libreactor: malloc payload"); + exit(1); + } + size_t len = 0; + memcpy(buf, header, header_len); + len += header_len; + + for (unsigned long i = 1;; i++) + { + /* Grow generously: one item is well under 128 bytes, but keep a wide + * margin against the decimal width of i and the footer. */ + if (len + 256 > cap) + { + cap = cap * 2 + 256; + char *grown = realloc(buf, cap); + if (!grown) + { + perror("libreactor: realloc payload"); + exit(1); + } + buf = grown; + } + if (i > 1) + buf[len++] = ','; + len += (size_t)snprintf( + buf + len, cap - len, + "{\"id\":%lu,\"name\":\"User %lu\",\"email\":\"user%lu@example.com\"," + "\"status\":\"active\",\"created_at\":\"2024-01-15T09:30:00Z\"}", + i, i, i); + if (len + footer_len >= target_size) + break; + } + memcpy(buf + len, footer, footer_len); + len += footer_len; + + *out_len = len; + return buf; +} + +/* The request callback. libreactor delivers one SERVER_REQUEST event per + * parsed HTTP request; event->data is the server_request, event->state is + * the &g_server we registered. request->method / request->target / + * request->data are `data` views into the parsed request (method, request + * target, body). server_ok / server_respond serialize an HTTP/1.1 response + * (status line + Server/Date/Content-Type/Content-Length headers computed by + * the library) and release the request. */ +static void on_request(reactor_event *event) +{ + server_request *request = (server_request *)event->data; + data target = request->target; + data method = request->method; + + /* Fast exact-match routes first (the hot static paths). data_equal does a + * length + memcmp comparison. */ + if (data_equal(target, data_string("/"))) + { + server_ok(request, CT_TEXT, g_hello_plain); + return; + } + if (data_equal(target, data_string("/json"))) + { + server_ok(request, CT_JSON, g_hello_json); + return; + } + if (data_equal(target, data_string("/json-1k"))) + { + server_ok(request, CT_JSON, g_json_1k); + return; + } + if (data_equal(target, data_string("/json-8k"))) + { + server_ok(request, CT_JSON, g_json_8k); + return; + } + if (data_equal(target, data_string("/json-16k"))) + { + server_ok(request, CT_JSON, g_json_16k); + return; + } + if (data_equal(target, data_string("/json-64k"))) + { + server_ok(request, CT_JSON, g_json_64k); + return; + } + + /* POST /upload — the library has already buffered the request body by the + * time the handler fires (request->data), so the parse cost is part of the + * measured path. We read-and-discard and reply with the literal "OK". */ + if (data_equal(target, data_string("/upload")) && + data_equal(method, data_string("POST"))) + { + server_ok(request, CT_TEXT, g_ok_plain); + return; + } + + /* GET /users/ — prefix match, then echo the path segment after + * "/users/". We test the prefix with an explicit length + memcmp rather + * than data_prefix so the match does not depend on that helper's argument + * order (target-starts-with-prefix vs prefix-starts-with-target). The body + * is built on the stack per request (the id segment is small and bounded); + * server_ok copies it into the response stream synchronously before + * returning, so the stack buffer is safe to let go. */ + { + static const char pfx[] = "/users/"; + const size_t pfx_len = sizeof pfx - 1; + if (target.size >= pfx_len && + memcmp(target.base, pfx, pfx_len) == 0) + { + const char *id = (const char *)target.base + pfx_len; + size_t id_len = target.size - pfx_len; + /* "User ID: " + id. Bound the id so a hostile long target cannot + * overflow the stack buffer; the bench only ever sends short ids. */ + char body[64]; + static const char lead[] = "User ID: "; + const size_t lead_len = sizeof lead - 1; + if (id_len > sizeof body - lead_len) + id_len = sizeof body - lead_len; + memcpy(body, lead, lead_len); + memcpy(body + lead_len, id, id_len); + data b = {.base = body, .size = lead_len + id_len}; + server_ok(request, CT_TEXT, b); + return; + } + } + + server_not_found(request); +} + +static void on_signal(int signo) +{ + (void)signo; + g_stopping = 1; + /* server_shutdown is safe to call from here: it flips the server's + * accept state and arms the drain. reactor_loop() then returns once + * in-flight work completes. */ + server_shutdown(&g_server); +} + +/* Split "host:port" on the LAST colon (drogon parity). An empty host means + * the wildcard bind 0.0.0.0; an empty/":0" port means kernel-assigned. */ +static void parse_bind(const char *bind, char *host, size_t host_cap, char *port, + size_t port_cap) +{ + const char *colon = strrchr(bind, ':'); + if (!colon) + { + /* No port — treat the whole thing as host, default port 8080. */ + snprintf(host, host_cap, "%s", bind); + snprintf(port, port_cap, "%s", "8080"); + } + else + { + size_t hlen = (size_t)(colon - bind); + if (hlen >= host_cap) + hlen = host_cap - 1; + memcpy(host, bind, hlen); + host[hlen] = '\0'; + snprintf(port, port_cap, "%s", colon + 1); + } + if (host[0] == '\0') + snprintf(host, host_cap, "%s", "0.0.0.0"); +} + +/* Print "ready addr=" exactly once. The runner's TCP probe + * waits for this line, so we resolve the REAL bound address with getsockname + * (port may be kernel-assigned when the caller passed :0) and flush before + * the accept loop starts. */ +static void announce_ready(int fd) +{ + struct sockaddr_storage ss; + socklen_t sl = sizeof ss; + char ip[INET6_ADDRSTRLEN] = "0.0.0.0"; + unsigned port = 0; + + if (getsockname(fd, (struct sockaddr *)&ss, &sl) == 0) + { + if (ss.ss_family == AF_INET) + { + struct sockaddr_in *a = (struct sockaddr_in *)&ss; + inet_ntop(AF_INET, &a->sin_addr, ip, sizeof ip); + port = ntohs(a->sin_port); + } + else if (ss.ss_family == AF_INET6) + { + struct sockaddr_in6 *a = (struct sockaddr_in6 *)&ss; + inet_ntop(AF_INET6, &a->sin6_addr, ip, sizeof ip); + port = ntohs(a->sin6_port); + } + } + printf("ready addr=%s:%u\n", ip, port); + fflush(stdout); +} + +int main(int argc, char *argv[]) +{ + const char *bind = "127.0.0.1:8080"; + const char *engine = "h1"; + + for (int i = 1; i < argc; i++) + { + const char *arg = argv[i]; + if ((strcmp(arg, "-bind") == 0 || strcmp(arg, "--bind") == 0) && i + 1 < argc) + bind = argv[++i]; + else if (strncmp(arg, "-bind=", 6) == 0) + bind = arg + 6; + else if (strncmp(arg, "--bind=", 7) == 0) + bind = arg + 7; + else if ((strcmp(arg, "-engine") == 0 || strcmp(arg, "--engine") == 0) && + i + 1 < argc) + engine = argv[++i]; + else if (strncmp(arg, "-engine=", 8) == 0) + engine = arg + 8; + else if (strncmp(arg, "--engine=", 9) == 0) + engine = arg + 9; + } + + /* Wire-protocol gate. h1 (or absent) -> HTTP/1.1. h2c -> unsupported: + * libreactor's server has no HTTP/2 framing, so refuse rather than serve + * h1 under an h2c label (which would corrupt a libreactor-h2 column). The + * bench's bind-gate then records that column as not-applicable/DNF. */ + if (strcmp(engine, "h2c") == 0) + { + fprintf(stderr, + "libreactor: h2c not supported (libreactor's server speaks " + "HTTP/1.1 only — no HTTP/2 framing layer) — refusing to serve " + "h1 under an h2c label\n"); + return 2; + } + if (strcmp(engine, "h1") != 0) + { + fprintf(stderr, "libreactor: unknown -engine value %s (want h1 or h2c)\n", + engine); + return 2; + } + + /* Build the static bodies once. */ + g_hello_plain = data_string("Hello, World!"); + g_hello_json = data_string("{\"message\":\"Hello, World!\"}"); + g_ok_plain = data_string("OK"); + { + size_t n; + char *p; + p = generate_json_payload(1024, &n); + g_json_1k = data_construct(p, n); + p = generate_json_payload(8192, &n); + g_json_8k = data_construct(p, n); + p = generate_json_payload(16384, &n); + g_json_16k = data_construct(p, n); + p = generate_json_payload(65536, &n); + g_json_64k = data_construct(p, n); + } + + char host[256]; + char port[32]; + parse_bind(bind, host, sizeof host, port, sizeof port); + + /* Resolve + create the listening socket. net_socket sets + * SO_REUSEADDR/SO_REUSEPORT, binds, and listens (backlog INT_MAX) for an + * AI_PASSIVE addrinfo, returning the listening fd. net_resolve takes + * mutable char*, so pass our local buffers. */ + struct addrinfo *ai = net_resolve(host, port, AF_INET, SOCK_STREAM, AI_PASSIVE); + if (!ai) + { + fprintf(stderr, "libreactor: net_resolve %s:%s failed\n", host, port); + return 1; + } + int fd = net_socket(ai); + if (fd < 0) + { + fprintf(stderr, "libreactor: bind/listen %s:%s failed\n", host, port); + return 1; + } + + reactor_construct(); + server_construct(&g_server, on_request, &g_server); + server_open(&g_server, fd, NULL); + + /* Graceful shutdown on SIGTERM/SIGINT (well inside the runner's 5s grace + * window). SIGPIPE off so a client reset mid-write never kills us. */ + signal(SIGPIPE, SIG_IGN); + signal(SIGTERM, on_signal); + signal(SIGINT, on_signal); + + announce_ready(fd); + + reactor_loop(); + + server_destruct(&g_server); + reactor_destruct(); + return 0; +} diff --git a/servers/lithium/.gitignore b/servers/lithium/.gitignore new file mode 100644 index 0000000..6709bcd --- /dev/null +++ b/servers/lithium/.gitignore @@ -0,0 +1,2 @@ +build/ +third_party/ diff --git a/servers/lithium/CMakeLists.txt b/servers/lithium/CMakeLists.txt new file mode 100644 index 0000000..e244e68 --- /dev/null +++ b/servers/lithium/CMakeLists.txt @@ -0,0 +1,118 @@ +cmake_minimum_required(VERSION 3.16) +project(lithium_adapter CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() + +# --- lithium single-header dependency ------------------------------------- +# +# matt-42/lithium ships a header-only amalgamation: the whole HTTP server is +# the single file single_headers/lithium_http_server.hh. There is NO +# system package / find_package for it (unlike drogon), so we vendor the +# header at configure time. Resolution order: +# +# 1. LITHIUM_HEADER_DIR (cache/env) — a directory already containing +# lithium_http_server.hh. The cpp ansible role points this at the +# bench-staged copy it fetched once (pristine: no network at adapter +# build time, header lives under bench_root). +# 2. ${CMAKE_CURRENT_SOURCE_DIR}/third_party — an in-tree vendored copy. +# 3. file(DOWNLOAD ...) of a PINNED upstream commit into the build tree +# (local-dev / CI fallback; requires network at configure time). +# +# LITHIUM_COMMIT pins the upstream revision so the build is reproducible — +# bump it deliberately, never float on master. The pin below is the +# revision this adapter's API was written against (li::http_api, +# response.set_header/write, request.url_parameters(s::id=...), +# request.http_ctx.read_whole_body(), li::http_serve(api, port, +# s::nthreads=...)). +# Pinned (not "master") so the input-buffer patch below targets an exact, +# reproducible source literal. This tip still has `buffer_(50 * 1024)` verbatim +# and an unchanged adapter API (li::http_api, read_whole_body, s::nthreads). +set(LITHIUM_COMMIT "879620e6df4540eefa4c6ae408934307d305e9d9" CACHE STRING "Upstream matt-42/lithium revision to vendor") + +set(_lithium_header "") +if(DEFINED LITHIUM_HEADER_DIR AND EXISTS "${LITHIUM_HEADER_DIR}/lithium_http_server.hh") + set(_lithium_header "${LITHIUM_HEADER_DIR}") +elseif(DEFINED ENV{LITHIUM_HEADER_DIR} AND EXISTS "$ENV{LITHIUM_HEADER_DIR}/lithium_http_server.hh") + set(_lithium_header "$ENV{LITHIUM_HEADER_DIR}") +elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/lithium_http_server.hh") + set(_lithium_header "${CMAKE_CURRENT_SOURCE_DIR}/third_party") +else() + set(_lithium_header "${CMAKE_CURRENT_BINARY_DIR}/lithium") + if(NOT EXISTS "${_lithium_header}/lithium_http_server.hh") + message(STATUS "lithium: downloading single header (commit ${LITHIUM_COMMIT})") + file(DOWNLOAD + "https://raw.githubusercontent.com/matt-42/lithium/${LITHIUM_COMMIT}/single_headers/lithium_http_server.hh" + "${_lithium_header}/lithium_http_server.hh" + TLS_VERIFY ON + STATUS _dl_status + SHOW_PROGRESS) + list(GET _dl_status 0 _dl_code) + if(NOT _dl_code EQUAL 0) + list(GET _dl_status 1 _dl_msg) + message(FATAL_ERROR "lithium: failed to download single header: ${_dl_msg}. " + "Set -DLITHIUM_HEADER_DIR=

" + "or vendor it at third_party/lithium_http_server.hh.") + endif() + endif() +endif() +message(STATUS "lithium: using header from ${_lithium_header}") + +# --- patch the input buffer cap (post-64k / post-1m DNF fix) --------------- +# +# lithium's single header hard-codes the per-connection input buffer at +# `buffer_(50 * 1024)` (50 KiB) and NEVER grows it, so a request body larger +# than ~50 KiB (post-64k = 64 KiB, post-1m = 1 MiB) yields zero successful +# requests (the server reads-and-discards via read_whole_body, but the body +# never fully buffers). Copy the resolved header into a build-owned dir and +# bump the cap to 2 MiB, then HARD-FAIL if the literal wasn't found (so a +# silent upstream drift can't ship the 50 KiB cap with a green build). +set(_lithium_patched "${CMAKE_CURRENT_BINARY_DIR}/lithium_patched") +configure_file("${_lithium_header}/lithium_http_server.hh" + "${_lithium_patched}/lithium_http_server.hh" COPYONLY) +execute_process( + COMMAND sed -i.bak "s/buffer_(50 \\* 1024)/buffer_(2 * 1024 * 1024)/" + "${_lithium_patched}/lithium_http_server.hh" + RESULT_VARIABLE _sed_rc) +file(READ "${_lithium_patched}/lithium_http_server.hh" _patched_src) +string(FIND "${_patched_src}" "buffer_(2 * 1024 * 1024)" _has_patch) +if(_has_patch EQUAL -1) + message(FATAL_ERROR "lithium: input-buffer patch did not apply — upstream literal " + "'buffer_(50 * 1024)' not found at commit ${LITHIUM_COMMIT}; refusing to " + "build the 50KB-capped header (would dnf post-64k/post-1m). Re-pin " + "LITHIUM_COMMIT and update the sed pattern.") +endif() +message(STATUS "lithium: input buffer patched to 2 MiB") + +# --- link dependencies ---------------------------------------------------- +# +# lithium links against pthread (Threads), OpenSSL (ssl + crypto, used by +# the HTTP server even without TLS configured), and Boost.Context (its +# fiber-per-connection reactor). These mirror the TechEmpower lithium +# compile.sh: -lpthread -lboost_context -lssl -lcrypto. +find_package(Threads REQUIRED) +find_package(OpenSSL REQUIRED) +find_package(Boost REQUIRED COMPONENTS context) + +add_executable(lithium-adapter src/main.cc) +target_include_directories(lithium-adapter PRIVATE "${_lithium_patched}") +target_link_libraries(lithium-adapter PRIVATE + Threads::Threads + OpenSSL::SSL + OpenSSL::Crypto + Boost::context) + +# Bench-grade optimization, matching the TechEmpower lithium build: +# -O3 -march=native -flto, NDEBUG, and LITHIUM_SERVER_NAME shortens the +# emitted Server: header. The cpp ansible role also exports CXXFLAGS= +# -march=native; setting it here too keeps a standalone `cmake --build` +# identically optimized. +target_compile_definitions(lithium-adapter PRIVATE NDEBUG LITHIUM_SERVER_NAME=l) +target_compile_options(lithium-adapter PRIVATE -O3 -march=native -flto) +set_target_properties(lithium-adapter PROPERTIES + INTERPROCEDURAL_OPTIMIZATION TRUE + LINK_FLAGS "-flto") diff --git a/servers/lithium/src/main.cc b/servers/lithium/src/main.cc new file mode 100644 index 0000000..66bb38f --- /dev/null +++ b/servers/lithium/src/main.cc @@ -0,0 +1,295 @@ +// probatorium lithium adapter — C++ (matt-42/lithium, the TechEmpower C++ +// speed leader). +// +// Same contract as servers/drogon, servers/axum, servers/ntex, served by +// lithium's HTTP stack. Lifecycle and CLI match the other native +// competitors so servers.StartAdapter launches every native adapter with +// one invocation pattern (`{bin} -bind {addr}`, wait for `ready +// addr=` on stdout, SIGTERM → graceful drain). +// +// Endpoint set (see servers/common/contract.go for canonical bytes): +// GET / -> "Hello, World!" text/plain +// GET /json -> {"message":"Hello, World!"} application/json +// GET /json-1k -> deterministic 1026-byte JSON page +// GET /json-8k -> deterministic 8286-byte JSON page +// GET /json-16k -> deterministic 16463-byte JSON page +// GET /json-64k -> deterministic 65618-byte JSON page +// GET /users/{{id}}-> "User ID: " text/plain +// POST /upload -> read-and-discard body, reply "OK" text/plain +// +// Driver-backed (/db, /cache, /mc, /session) and chain-* endpoints are out +// of scope (Capabilities all-false: static + concurrency scenarios only), +// so they intentionally 404 — the scenario applicability filter in +// servers/servers.go skips lithium for those classes. +// +// CLI: `{bin} -bind [-engine h1|h2c]`. -engine selects the wire +// protocol: +// h1 (or absent) -> plain HTTP/1.1, exactly as benched. +// h2c -> HTTP/2 cleartext prior-knowledge — NOT supported. +// +// h2c investigation (lithium master): lithium's http_server speaks HTTP/1.x +// only. Its request parser / response serializer (libraries/http_server/ +// http_server/{http_ctx,http_top_header_builder}.hh) emit an HTTP/1.1 +// status line + headers and parse an HTTP/1 request line; there is no +// HTTP/2 framing layer, no h2c upgrade path, and http_serve()'s only +// protocol-adjacent options are TLS (s::ssl_key / s::ssl_certificate / +// s::ssl_ciphers) — TLS, not h2c prior-knowledge. So we fail fast on -engine +// h2c instead of silently downgrading to h1 (which would corrupt a +// lithium-h2 column by reporting h1 numbers under an h2 label). +// +// Bind handling (important — two lithium quirks worked around here): +// +// 1. Port 0 (kernel-assigned). lithium's http_serve() binds the listen +// socket inside a detached worker thread and never reports the actual +// port, so it cannot serve the `ready addr=:` contract +// for `-bind host:0` on its own. We therefore reserve the port +// ourselves: bind a throwaway socket to host:0, read the kernel- +// assigned port back with getsockname(), close it, and hand the now- +// concrete port to http_serve(). SO_REUSEADDR/SO_REUSEPORT (lithium +// sets both on its listen socket) makes the immediate rebind safe. +// This mirrors the reserve-then-pass pattern the Go conformance +// harness uses for native adapters. +// +// 2. The s::ip explicit-bind byte-swap. lithium's create_and_bind() sets +// `addr.sin_port = port` WITHOUT htons() on the explicit-IP path, so +// passing s::ip = "127.0.0.1" mis-binds to the byte-swapped port. Its +// default (no s::ip) path uses getaddrinfo(AI_PASSIVE) which sets the +// port correctly and binds an IPv6 dual-stack socket (IPV6_V6ONLY=0) +// that accepts IPv4 too. The probatorium runner always dials +// 127.0.0.1:, which a dual-stack all-interfaces listener serves, +// so we bind all interfaces (omit s::ip) to get a correct port. The +// requested host is still echoed verbatim in the ready line so the +// runner's dial target matches what it asked for. + +#include "lithium_http_server.hh" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +// Byte-identical port of servers/common/payload.go generateJSONPayload (the +// same hand emitter the drogon adapter uses, proven byte-for-byte equal to +// Go's encoding/json output for the paginatedResponse/paginatedItem structs). +// The Go reference marshals compact JSON in struct-declaration field order; +// we emit those bytes directly so the conformance probe's exact-byte compare +// passes. +// +// Termination rule mirrors Go: append items until the full marshalled length +// (header + items + footer) crosses targetSize. Resulting sizes: +// 1 KiB target -> 1026 bytes +// 8 KiB target -> 8286 bytes +// 16 KiB target -> 16463 bytes +// 64 KiB target -> 65618 bytes +std::string generateJSONPayload(std::size_t targetSize) { + static const std::string header = + R"({"page":1,"per_page":50,"total":1000,"total_pages":20,"data":[)"; + static const std::string footer = "]}"; + + std::string buf; + buf.reserve(targetSize + 256); + buf += header; + + for (std::uint64_t i = 1;; ++i) { + if (i > 1) { + buf += ','; + } + const std::string n = std::to_string(i); + buf += R"({"id":)"; + buf += n; + buf += R"(,"name":"User )"; + buf += n; + buf += R"(","email":"user)"; + buf += n; + buf += R"(@example.com","status":"active","created_at":"2024-01-15T09:30:00Z"})"; + if (buf.size() + footer.size() >= targetSize) { + break; + } + } + buf += footer; + return buf; +} + +// reservePort binds a throwaway socket to host:wantPort and returns the +// actual port the kernel assigned (== wantPort when wantPort != 0). The +// socket is closed before returning; SO_REUSEADDR + SO_REUSEPORT let +// lithium rebind the same port immediately. Returns 0 on failure. +std::uint16_t reservePort(const std::string &host, std::uint16_t wantPort) { + const int fd = ::socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + return 0; + } + int on = 1; + ::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); +#ifdef SO_REUSEPORT + ::setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &on, sizeof(on)); +#endif + + struct sockaddr_in addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(wantPort); + const std::string bindHost = + (host.empty() || host == "*") ? std::string("0.0.0.0") : host; + if (::inet_pton(AF_INET, bindHost.c_str(), &addr.sin_addr) != 1) { + // Non-numeric host (e.g. "localhost") — fall back to all interfaces; + // the kernel-assigned port is what we need, the address is moot. + addr.sin_addr.s_addr = htonl(INADDR_ANY); + } + + if (::bind(fd, reinterpret_cast(&addr), sizeof(addr)) != 0) { + ::close(fd); + return 0; + } + + struct sockaddr_in bound; + socklen_t blen = sizeof(bound); + std::uint16_t got = wantPort; + if (::getsockname(fd, reinterpret_cast(&bound), &blen) == 0) { + got = ntohs(bound.sin_port); + } + ::close(fd); + return got; +} + +} // namespace + +int main(int argc, char *argv[]) { + std::string bind = "127.0.0.1:8080"; + std::string engine = "h1"; + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + if ((arg == "-bind" || arg == "--bind") && i + 1 < argc) { + bind = argv[++i]; + } else if (arg.rfind("-bind=", 0) == 0) { + bind = arg.substr(6); + } else if (arg.rfind("--bind=", 0) == 0) { + bind = arg.substr(7); + } else if ((arg == "-engine" || arg == "--engine") && i + 1 < argc) { + engine = argv[++i]; + } else if (arg.rfind("-engine=", 0) == 0) { + engine = arg.substr(8); + } else if (arg.rfind("--engine=", 0) == 0) { + engine = arg.substr(9); + } + } + + if (engine == "h2c") { + std::fprintf(stderr, + "lithium: h2c not supported (lithium's http_server speaks " + "HTTP/1.x only; no HTTP/2 framing or h2c upgrade path) — " + "refusing to serve h1 under an h2c label\n"); + return 2; + } + if (engine != "h1") { + std::fprintf(stderr, + "lithium: unknown -engine value %s (want h1 or h2c)\n", + engine.c_str()); + return 2; + } + + // Split host:port. host kept for the ready line; port resolved (incl. :0) + // via reservePort below. + std::string host = bind; + std::uint16_t wantPort = 8080; + const auto colon = bind.find_last_of(':'); + if (colon != std::string::npos) { + host = bind.substr(0, colon); + wantPort = static_cast(std::stoul(bind.substr(colon + 1))); + } + if (host.empty()) { + host = "0.0.0.0"; + } + + const std::uint16_t port = reservePort(host, wantPort); + if (port == 0) { + std::fprintf(stderr, "lithium: could not reserve %s:%u\n", host.c_str(), + wantPort); + return 1; + } + + const std::string json1k = generateJSONPayload(1024); + const std::string json8k = generateJSONPayload(8192); + const std::string json16k = generateJSONPayload(16384); + const std::string json64k = generateJSONPayload(65536); + + li::http_api api; + + api.get("/") = [](li::http_request &, li::http_response &response) { + response.set_header("Content-Type", "text/plain"); + response.write("Hello, World!"); + }; + + api.get("/json") = [](li::http_request &, li::http_response &response) { + response.set_header("Content-Type", "application/json"); + response.write(R"({"message":"Hello, World!"})"); + }; + + api.get("/json-1k") = [&json1k](li::http_request &, + li::http_response &response) { + response.set_header("Content-Type", "application/json"); + response.write(std::string_view(json1k)); + }; + + api.get("/json-8k") = [&json8k](li::http_request &, + li::http_response &response) { + response.set_header("Content-Type", "application/json"); + response.write(std::string_view(json8k)); + }; + + api.get("/json-16k") = [&json16k](li::http_request &, + li::http_response &response) { + response.set_header("Content-Type", "application/json"); + response.write(std::string_view(json16k)); + }; + + api.get("/json-64k") = [&json64k](li::http_request &, + li::http_response &response) { + response.set_header("Content-Type", "application/json"); + response.write(std::string_view(json64k)); + }; + + api.get("/users/{{id}}") = [](li::http_request &request, + li::http_response &response) { + auto params = request.url_parameters(s::id = std::string()); + response.set_header("Content-Type", "text/plain"); + response.write("User ID: ", params.id); + }; + + api.post("/upload") = [](li::http_request &request, + li::http_response &response) { + // Read-and-discard the body so the parse cost is on the measured + // path, exactly as the contract specifies, then reply the literal + // "OK". read_whole_body() returns a string_view into the already- + // buffered request body. + (void)request.http_ctx.read_whole_body(); + response.set_header("Content-Type", "text/plain"); + response.write("OK"); + }; + + // Emit the ready line BEFORE http_serve() blocks. http_serve installs + // lithium's own SIGINT/SIGTERM/SIGQUIT handlers (start_tcp_server → + // shutdown_handler sets quit_signal_catched), so SIGTERM from + // servers.spawn() drains gracefully — we deliberately do NOT install our + // own handlers. The host echoed here is the requested host so the + // runner's dial target matches; the port is the resolved (kernel- + // assigned when :0) port. We bind all interfaces (no s::ip) to dodge + // lithium's explicit-IP port byte-swap; an all-interfaces dual-stack + // listener still serves the runner's 127.0.0.1 dial. + std::printf("ready addr=%s:%u\n", host.c_str(), port); + std::fflush(stdout); + + // One IO worker per hardware core (lithium's default), HTTP/1.1, all + // interfaces. Blocks until a quit signal is caught. + li::http_serve(api, port, s::nthreads = int(std::thread::hardware_concurrency())); + return 0; +} diff --git a/servers/nbio/.gitignore b/servers/nbio/.gitignore new file mode 100644 index 0000000..8cd2bd9 --- /dev/null +++ b/servers/nbio/.gitignore @@ -0,0 +1 @@ +/nbio diff --git a/servers/nbio/go.mod b/servers/nbio/go.mod new file mode 100644 index 0000000..61b3a30 --- /dev/null +++ b/servers/nbio/go.mod @@ -0,0 +1,16 @@ +module github.com/goceleris/probatorium/servers/nbio + +go 1.26.4 + +require ( + github.com/goceleris/probatorium v0.0.0-00010101000000-000000000000 + github.com/lesismal/nbio v1.6.9 +) + +require ( + github.com/lesismal/llib v1.2.2 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/sys v0.46.0 // indirect +) + +replace github.com/goceleris/probatorium => ../.. diff --git a/servers/nbio/go.sum b/servers/nbio/go.sum new file mode 100644 index 0000000..12eca45 --- /dev/null +++ b/servers/nbio/go.sum @@ -0,0 +1,17 @@ +github.com/lesismal/llib v1.2.2 h1:ZoVgP9J58Ju3Yue5jtj8ybWl+BKqoVmdRaN1mNwG5Gc= +github.com/lesismal/llib v1.2.2/go.mod h1:70tFXXe7P1FZ02AU9l8LgSOK7d7sRrpnkUr3rd3gKSg= +github.com/lesismal/nbio v1.6.9 h1:wkeD5RAshrNJG5e/Ci0xezGlsvFccIZKLP+1htJEreY= +github.com/lesismal/nbio v1.6.9/go.mod h1:mBn1rSIZ+cmOILhvP+/1Mb/JimgA+1LQudlHJUb/aNA= +golang.org/x/crypto v0.0.0-20210513122933-cd7d49e622d5/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/servers/nbio/main.go b/servers/nbio/main.go new file mode 100644 index 0000000..9322aa5 --- /dev/null +++ b/servers/nbio/main.go @@ -0,0 +1,150 @@ +// Command nbio serves the probatorium static contract on top of +// lesismal/nbio's nbhttp engine — a pure-Go, non-blocking, event-driven HTTP +// server (epoll/kqueue poller pool, not goroutine-per-connection on net/http). +// It is the second Go event-loop peer alongside gnet: where gnet hands the +// adapter a raw inbound ring buffer and the adapter framures HTTP itself, +// nbhttp ships its own non-blocking HTTP/1.x parser and drives a standard +// http.Handler, so this adapter reuses the same mux + servers/common payload +// path as the net/http adapters while the I/O underneath is fully event-loop. +// +// nbhttp speaks HTTP/1.x only on the server side (its HTTP/2 surface is +// client-oriented and not a cheap cleartext-h2c prior-knowledge server), so +// this adapter is HTTP/1.1-only and serves the static contract exclusively — +// no driver, middleware, WS, or SSE routes. The capability manifest +// (Static only) lives in the shared registry at servers/servers.go (the +// nbio-h1 Adapter entry), the single source of truth the runner gates +// scenario waves from. +// +// The -engine flag is accepted for symmetry with the other Go adapters; only +// "h1" is meaningful. +package main + +import ( + "context" + "flag" + "io" + "log" + "net" + "net/http" + "os" + "os/signal" + "strconv" + "sync" + "syscall" + "time" + + "github.com/lesismal/nbio/nbhttp" + + "github.com/goceleris/probatorium/servers/common" +) + +func main() { + bind := flag.String("bind", "127.0.0.1:8080", "address:port to listen on") + // -engine accepted for symmetry with the other adapters; only "h1" is + // supported (nbhttp serves HTTP/1.x only on the server side). + _ = flag.String("engine", "h1", "runtime engine: h1 (only mode supported)") + flag.Parse() + + mux := http.NewServeMux() + registerStatic(mux) + + // nbhttp calls Config.Listen once per address (defaulting to net.Listen + // when nil). We wrap it so the actual bound address — crucially for the + // ":0" kernel-assigned case — is captured from the returned listener and + // reported on the ready line the runner scans for. Only the first + // listener's address is recorded (there is a single Addrs entry here). + var ( + boundOnce sync.Once + boundAddr string + ) + listen := func(network, addr string) (net.Listener, error) { + ln, err := net.Listen(network, addr) + if err != nil { + return nil, err + } + boundOnce.Do(func() { boundAddr = ln.Addr().String() }) + return ln, nil + } + + engine := nbhttp.NewEngine(nbhttp.Config{ + Network: "tcp", + Addrs: []string{*bind}, + Handler: mux, + Listen: listen, + }) + + if err := engine.Start(); err != nil { + log.Fatalf("nbio: start %s: %v", *bind, err) + } + + go func() { + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT) + <-sig + log.Printf("nbio: signal received, shutting down") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = engine.Shutdown(ctx) + }() + + // The runner scans child stdout for this exact prefix before dialing. + // Printed via the logger like the other Go adapters; the runner's scanner + // matches the "ready addr=" token anywhere on the line. + log.Printf("ready addr=%s", boundAddr) + + // Engine.Start returns immediately (the poller pool runs in the + // background), so block until shutdown closes the process. Shutdown -> + // os.Exit happens via the signal goroutine path; we park here. + select {} +} + +// registerStatic mounts the eight canonical contract endpoints from +// servers/common so this adapter satisfies the static contract byte-for-byte +// with every other adapter. +func registerStatic(mux *http.ServeMux) { + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { + // "GET /" matches every otherwise-unmatched path; guard so only the + // root serves the hello body and everything else 404s. + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + writeBlob(w, "text/plain", []byte("Hello, World!")) + }) + mux.HandleFunc("GET /json", func(w http.ResponseWriter, r *http.Request) { + writeBlob(w, "application/json", []byte(`{"message":"Hello, World!"}`)) + }) + mux.HandleFunc("GET /json-1k", func(w http.ResponseWriter, r *http.Request) { + writeBlob(w, "application/json", common.JSON1KPayload()) + }) + mux.HandleFunc("GET /json-8k", func(w http.ResponseWriter, r *http.Request) { + writeBlob(w, "application/json", common.JSON8KPayload()) + }) + mux.HandleFunc("GET /json-16k", func(w http.ResponseWriter, r *http.Request) { + writeBlob(w, "application/json", common.JSON16KPayload()) + }) + mux.HandleFunc("GET /json-64k", func(w http.ResponseWriter, r *http.Request) { + writeBlob(w, "application/json", common.JSON64KPayload()) + }) + mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) { + writeBlob(w, "text/plain", []byte("User ID: "+r.PathValue("id"))) + }) + mux.HandleFunc("POST /upload", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + writeBlob(w, "text/plain", []byte("OK")) + }) +} + +func writeBlob(w http.ResponseWriter, contentType string, body []byte) { + h := w.Header() + h.Set("Content-Type", contentType) + // Set Content-Length explicitly so nbhttp emits a fixed-length body + // instead of falling back to Transfer-Encoding: chunked. The net/http + // adapters get Content-Length for free (their ResponseWriter buffers the + // single Write before flushing); matching it here keeps the wire output + // byte-for-byte identical across adapters, so a throughput delta reflects + // engine cost, not a framing artefact. + h.Set("Content-Length", strconv.Itoa(len(body))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) +} diff --git a/servers/netty/.gitignore b/servers/netty/.gitignore new file mode 100644 index 0000000..ca4dc2d --- /dev/null +++ b/servers/netty/.gitignore @@ -0,0 +1,3 @@ +target/ +server +dependency-reduced-pom.xml diff --git a/servers/netty/pom.xml b/servers/netty/pom.xml new file mode 100644 index 0000000..99e4f17 --- /dev/null +++ b/servers/netty/pom.xml @@ -0,0 +1,153 @@ + + + 4.0.0 + + + + io.goceleris.probatorium + netty-adapter + 1.0.0 + jar + + + UTF-8 + 17 + + 4.2.9.Final + + + + + + io.netty + netty-bom + ${netty.version} + pom + import + + + + + + + + io.netty + netty-codec-http + + + + + io.netty + netty-transport-classes-epoll + + + + + io.netty + netty-transport-native-epoll + + ${netty.version} + ${os.detected.classifier} + + + + + netty-adapter + + + + + kr.motd.maven + os-maven-plugin + 1.7.1 + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + shade + + + + + io.goceleris.probatorium.netty.NettyServer + + + + META-INF/io.netty.versions.properties + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + diff --git a/servers/netty/src/main/java/io/goceleris/probatorium/netty/NettyHttpHandler.java b/servers/netty/src/main/java/io/goceleris/probatorium/netty/NettyHttpHandler.java new file mode 100644 index 0000000..25672ba --- /dev/null +++ b/servers/netty/src/main/java/io/goceleris/probatorium/netty/NettyHttpHandler.java @@ -0,0 +1,129 @@ +package io.goceleris.probatorium.netty; + +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpUtil; + +import java.nio.charset.StandardCharsets; + +import static io.netty.handler.codec.http.HttpHeaderNames.CONNECTION; +import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_LENGTH; +import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE; +import static io.netty.handler.codec.http.HttpHeaderValues.CLOSE; +import static io.netty.handler.codec.http.HttpHeaderValues.KEEP_ALIVE; +import static io.netty.handler.codec.http.HttpResponseStatus.NOT_FOUND; +import static io.netty.handler.codec.http.HttpResponseStatus.OK; + +/** + * The adapter's sole inbound handler: a hand-written (method, path) match + * over the canonical probatorium contract endpoints. There is no router + * abstraction — this is the raw-Netty baseline, so routing is a switch. + * + *

The pipeline runs an {@code HttpObjectAggregator} ahead of this handler, + * so every request arrives as a {@link FullHttpRequest} with its body already + * aggregated. That makes /upload's read-and-discard implicit (we simply never + * read the content) and keeps the handler branch-free per request. + * + *

Responses reuse the pre-built static byte arrays; each response wraps a + * fresh {@code Unpooled.wrappedBuffer} view (zero-copy, no per-request body + * allocation). Keep-alive is honoured per RFC 9112 via {@link HttpUtil}. + */ +final class NettyHttpHandler extends SimpleChannelInboundHandler { + + private static final String TEXT_PLAIN = "text/plain"; + private static final String APPLICATION_JSON = "application/json"; + + private static final byte[] HELLO = "Hello, World!".getBytes(StandardCharsets.UTF_8); + private static final byte[] JSON_HELLO = + "{\"message\":\"Hello, World!\"}".getBytes(StandardCharsets.UTF_8); + private static final byte[] OK_BODY = "OK".getBytes(StandardCharsets.UTF_8); + private static final byte[] NOT_FOUND_BODY = "Not Found".getBytes(StandardCharsets.UTF_8); + + @Override + public void channelReadComplete(ChannelHandlerContext ctx) { + ctx.flush(); + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) { + // Strip the query string; the contract paths are literal. + String uri = req.uri(); + int q = uri.indexOf('?'); + String path = q >= 0 ? uri.substring(0, q) : uri; + HttpMethod method = req.method(); + + if (HttpMethod.GET.equals(method)) { + switch (path) { + case "/": + respond(ctx, req, OK, TEXT_PLAIN, HELLO); + return; + case "/json": + respond(ctx, req, OK, APPLICATION_JSON, JSON_HELLO); + return; + case "/json-1k": + respond(ctx, req, OK, APPLICATION_JSON, Payload.JSON_1K); + return; + case "/json-8k": + respond(ctx, req, OK, APPLICATION_JSON, Payload.JSON_8K); + return; + case "/json-16k": + respond(ctx, req, OK, APPLICATION_JSON, Payload.JSON_16K); + return; + case "/json-64k": + respond(ctx, req, OK, APPLICATION_JSON, Payload.JSON_64K); + return; + default: + if (path.startsWith("/users/")) { + String id = path.substring("/users/".length()); + byte[] body = ("User ID: " + id).getBytes(StandardCharsets.UTF_8); + respond(ctx, req, OK, TEXT_PLAIN, body); + return; + } + } + } else if (HttpMethod.POST.equals(method) && "/upload".equals(path)) { + // Read-and-discard: the aggregated body is simply never touched. + respond(ctx, req, OK, TEXT_PLAIN, OK_BODY); + return; + } + + respond(ctx, req, NOT_FOUND, TEXT_PLAIN, NOT_FOUND_BODY); + } + + private static void respond(ChannelHandlerContext ctx, FullHttpRequest req, + io.netty.handler.codec.http.HttpResponseStatus status, + String contentType, byte[] body) { + boolean keepAlive = HttpUtil.isKeepAlive(req); + FullHttpResponse response = new DefaultFullHttpResponse( + req.protocolVersion(), status, Unpooled.wrappedBuffer(body)); + response.headers() + .set(CONTENT_TYPE, contentType) + .setInt(CONTENT_LENGTH, body.length); + + if (keepAlive) { + if (!req.protocolVersion().isKeepAliveDefault()) { + response.headers().set(CONNECTION, KEEP_ALIVE); + } + } else { + response.headers().set(CONNECTION, CLOSE); + } + + ChannelFuture f = ctx.write(response); + if (!keepAlive) { + f.addListener(ChannelFutureListener.CLOSE); + } + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + // Per-connection errors (client resets, partial sends) are expected + // churn under load; close the offending channel and move on. + ctx.close(); + } +} diff --git a/servers/netty/src/main/java/io/goceleris/probatorium/netty/NettyServer.java b/servers/netty/src/main/java/io/goceleris/probatorium/netty/NettyServer.java new file mode 100644 index 0000000..32c034a --- /dev/null +++ b/servers/netty/src/main/java/io/goceleris/probatorium/netty/NettyServer.java @@ -0,0 +1,175 @@ +package io.goceleris.probatorium.netty; + +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelOption; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.MultiThreadIoEventLoopGroup; +import io.netty.channel.epoll.Epoll; +import io.netty.channel.epoll.EpollChannelOption; +import io.netty.channel.epoll.EpollIoHandler; +import io.netty.channel.epoll.EpollServerSocketChannel; +import io.netty.channel.nio.NioIoHandler; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpServerCodec; +import io.netty.handler.codec.http.HttpServerExpectContinueHandler; + +import java.net.InetSocketAddress; +import java.util.concurrent.CountDownLatch; + +/** + * probatorium netty adapter — the raw JVM low-level baseline. + * + *

Serves the canonical contract endpoints declared in + * servers/common/contract.go: + * + *

+ *   GET  /            -> "Hello, World!"             text/plain
+ *   GET  /json        -> {"message":"Hello, World!"} application/json
+ *   GET  /json-1k     -> deterministic 1026-byte JSON page
+ *   GET  /json-8k     -> deterministic 8286-byte JSON page
+ *   GET  /json-16k    -> deterministic 16463-byte JSON page
+ *   GET  /json-64k    -> deterministic 65618-byte JSON page
+ *   GET  /users/:id   -> "User ID: <id>"             text/plain
+ *   POST /upload      -> read-and-discard body, "OK"  text/plain
+ * 
+ * + *

Transport: epoll when the native is loadable (Linux — the bench host), + * NIO otherwise (dev fallback). SO_REUSEPORT spreads accepts across the + * event loops (one IO thread per CPU), the JVM analogue of the SO_REUSEPORT + * worker pools every other native adapter uses. + * + *

CLI: + *

    + *
  • {@code -bind } — default 0.0.0.0:8080. Pass {@code :0} + * (or {@code host:0}) to let the kernel allocate a port; the actually + * bound address is reported on stdout via {@code ready addr=}, + * the line the runner waits for before opening loadgen.
  • + *
  • {@code -engine } — default "h1". Only "h1" (plain HTTP/1.1) + * is accepted: raw Netty's HttpServerCodec is an HTTP/1.x codec, and + * prior-knowledge h2c would need a separate Http2 pipeline. Any other + * value is a hard error (exit 2), so a typo fails fast and visibly + * rather than silently serving h1 — mirroring drogon's h1-only stance, + * which registers NO h2 column.
  • + *
+ * + *

Lifecycle: SIGTERM or SIGINT triggers a graceful shutdown that finishes + * in-flight requests well inside the runner's 5-second grace window. + */ +public final class NettyServer { + + public static void main(String[] args) throws Exception { + String bind = "0.0.0.0:8080"; + String engine = "h1"; + for (int i = 0; i < args.length; i++) { + if ("-bind".equals(args[i]) && i + 1 < args.length) { + bind = args[++i]; + } else if ("-engine".equals(args[i]) && i + 1 < args.length) { + engine = args[++i]; + } + } + + if (!"h1".equals(engine)) { + System.err.println("netty: unknown -engine \"" + engine + "\" (want \"h1\")"); + System.exit(2); + return; + } + + InetSocketAddress addr = parseBind(bind); + + boolean useEpoll = Epoll.isAvailable(); + int threads = Runtime.getRuntime().availableProcessors(); + + // 4.2 API: one MultiThreadIoEventLoopGroup carrying a transport-specific + // IoHandlerFactory replaces the deprecated per-transport EventLoopGroup + // classes. A single group with N IO threads + SO_REUSEPORT is the + // standard high-throughput shape (no separate boss group needed once + // the kernel load-balances accepts across the reuseport sockets). + final EventLoopGroup group = useEpoll + ? new MultiThreadIoEventLoopGroup(threads, EpollIoHandler.newFactory()) + : new MultiThreadIoEventLoopGroup(threads, NioIoHandler.newFactory()); + + try { + ServerBootstrap b = new ServerBootstrap(); + b.group(group) + .channel(useEpoll ? EpollServerSocketChannel.class : NioServerSocketChannel.class) + .option(ChannelOption.SO_BACKLOG, 1024) + .option(ChannelOption.SO_REUSEADDR, true) + .childOption(ChannelOption.TCP_NODELAY, true) + .childHandler(new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel ch) { + ChannelPipeline p = ch.pipeline(); + p.addLast(new HttpServerCodec()); + // Aggregate the body so /upload's read-and-discard + // is implicit and every request reaches the handler + // as a FullHttpRequest. 1 MiB cap is ample for the + // bench's small uploads. + p.addLast(new HttpObjectAggregator(1 << 20)); + p.addLast(new HttpServerExpectContinueHandler()); + p.addLast(new NettyHttpHandler()); + } + }); + + // SO_REUSEPORT: let every IO thread own an independent accept queue + // on the same port so accepts scale linearly with cores. Epoll-only + // (NIO has no portable SO_REUSEPORT option); NIO falls back to the + // single-acceptor default, which is the dev path, not the bench. + if (useEpoll) { + b.option(EpollChannelOption.SO_REUSEPORT, true); + } + + Channel ch = b.bind(addr).sync().channel(); + + // Report the ACTUAL bound address — critical for -bind host:0, where + // the kernel assigns the port and the runner needs the real value. + InetSocketAddress local = (InetSocketAddress) ch.localAddress(); + String host = addr.getHostString(); + System.out.println("ready addr=" + host + ":" + local.getPort()); + System.out.flush(); + + // Graceful shutdown on SIGTERM / SIGINT: close the listen channel, + // release the loops, and let main() return. + CountDownLatch done = new CountDownLatch(1); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { + ch.close().sync(); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }, "netty-shutdown")); + + ch.closeFuture().sync(); + done.await(); + } finally { + group.shutdownGracefully(); + } + } + + /** + * Splits {@code host:port} into an InetSocketAddress, preserving the + * caller-supplied host so the ready-line echoes the requested bind host + * (the kernel-assigned port replaces the :0). An empty / wildcard host + * binds all interfaces. + */ + private static InetSocketAddress parseBind(String bind) { + int idx = bind.lastIndexOf(':'); + if (idx < 0) { + return new InetSocketAddress(Integer.parseInt(bind)); + } + String host = bind.substring(0, idx); + int port = Integer.parseInt(bind.substring(idx + 1)); + if (host.isEmpty() || "*".equals(host)) { + return new InetSocketAddress(port); + } + return new InetSocketAddress(host, port); + } + + private NettyServer() {} +} diff --git a/servers/netty/src/main/java/io/goceleris/probatorium/netty/Payload.java b/servers/netty/src/main/java/io/goceleris/probatorium/netty/Payload.java new file mode 100644 index 0000000..53da377 --- /dev/null +++ b/servers/netty/src/main/java/io/goceleris/probatorium/netty/Payload.java @@ -0,0 +1,67 @@ +package io.goceleris.probatorium.netty; + +import java.nio.charset.StandardCharsets; + +/** + * Deterministic JSON payload generator — must match + * probatorium/servers/common/payload.go byte for byte so the loadgen + * fixtures compare equal across every adapter. Verified sizes against the + * live common.Endpoints bytes: /json-1k = 1026, /json-8k = 8286, + * /json-16k = 16463, /json-64k = 65618. + * + *

The Go reference (encoding/json over a struct) emits the envelope + * + *

{"page":1,"per_page":50,"total":1000,"total_pages":20,"data":[ ...items... ]}
+ * + * appending one item per iteration (1-based id) and re-marshalling the full + * response after each append; it returns the buffer the first time the + * marshalled length (footer included) reaches the target. We hand-emit the + * same bytes here (no serializer) so key order and separators are fixed and + * identical to encoding/json's output for the reference struct. + */ +final class Payload { + static final byte[] JSON_1K = generate(1024); + + // Mid-size payloads bridge the 1k->64k gap: on the 20G fabric the 64k + // cells are NIC-bound (fast adapters converge at line rate), so the + // 8k/16k cells stay under the ceiling and keep differentiating adapters + // by CPU throughput. Same parametric generator as the Go reference. + static final byte[] JSON_8K = generate(8192); + static final byte[] JSON_16K = generate(16384); + static final byte[] JSON_64K = generate(65536); + + private Payload() {} + + private static byte[] generate(int target) { + final String header = + "{\"page\":1,\"per_page\":50,\"total\":1000,\"total_pages\":20,\"data\":["; + final String footer = "]}"; + + StringBuilder b = new StringBuilder(target + 256); + b.append(header); + + for (int i = 1; ; i++) { + if (i > 1) { + b.append(','); + } + appendItem(b, i); + + // Mirror encoding/json: the closing "]}" is part of the final + // length check. The Go reference returns as soon as the full + // marshalled response (with footer) reaches target. + if (b.length() + footer.length() >= target) { + break; + } + } + + b.append(footer); + return b.toString().getBytes(StandardCharsets.UTF_8); + } + + private static void appendItem(StringBuilder b, int n) { + b.append("{\"id\":").append(n) + .append(",\"name\":\"User ").append(n) + .append("\",\"email\":\"user").append(n) + .append("@example.com\",\"status\":\"active\",\"created_at\":\"2024-01-15T09:30:00Z\"}"); + } +} diff --git a/servers/ntex/src/main.rs b/servers/ntex/src/main.rs index 9376417..9930972 100644 --- a/servers/ntex/src/main.rs +++ b/servers/ntex/src/main.rs @@ -9,6 +9,8 @@ // GET / -> "Hello, World!" text/plain // GET /json -> {"message":"Hello, World!"} application/json // GET /json-1k -> deterministic 1026-byte JSON page +// GET /json-8k -> deterministic 8286-byte JSON page +// GET /json-16k -> deterministic 16463-byte JSON page // GET /json-64k -> deterministic 65618-byte JSON page // GET /users/{id} -> "User ID: " text/plain // POST /upload -> read-and-discard body, reply "OK" text/plain @@ -16,39 +18,57 @@ // Driver-backed (/db, /cache, /mc, /session) and chain-* endpoints // land in wave 6. // +// CLI: +// -bind default 127.0.0.1:8080. The bound addr is reported +// on stdout via `ready addr=`. +// -engine default "h1". One of: +// h1 — plain HTTP/1.1 (web::HttpServer, as before). +// h2c — HTTP/2 cleartext, PRIOR-KNOWLEDGE only. +// ntex DOES support this without TLS: we drop +// to the low-level ntex::server builder and +// serve each accepted Io with +// ntex::http::HttpService::h2(app), which runs +// the H2 dispatcher directly on the plaintext +// socket (no TLS filter, no h1->h2 upgrade). +// Mirrors stdhttp-h2's h2c-noupg mode. Unknown +// values exit non-zero. +// // Lifecycle: ntex's #[ntex::main] entrypoint installs a default // SIGTERM/SIGINT handler that triggers graceful workers shutdown. We // override `shutdown_timeout` to 3 seconds so the runner's 5-second // SIGKILL window in servers/start.go always wins; without that override -// the default 30-second grace would race the SIGKILL fallback. +// the default 30-second grace would race the SIGKILL fallback. Both the +// h1 and h2c paths share this lifecycle — they differ only in the +// per-connection HTTP service factory. mod payload; use std::io::Write as _; use std::net::TcpListener; +use std::process::ExitCode; +use ntex::http::HttpService; use ntex::time::Seconds; use ntex::util::Bytes; use ntex::web::{self, App, HttpResponse, HttpServer}; -#[ntex::main] -async fn main() -> std::io::Result<()> { - let bind = parse_bind_arg().unwrap_or_else(|| "127.0.0.1:8080".to_string()); - - // Bind a std listener ourselves so we can resolve the actual addr - // (handles port=0 cleanly) before handing it to ntex via .listen(). - // ntex's HttpServer doesn't expose an addrs() accessor, so this - // is the canonical pattern. - let listener = TcpListener::bind(&bind) - .unwrap_or_else(|e| panic!("ntex: bind {bind:?}: {e}")); - let bound = listener - .local_addr() - .unwrap_or_else(|e| panic!("ntex: local_addr: {e}")); - - println!("ready addr={bound}"); - let _ = std::io::stdout().flush(); +// Engine names the wire protocol the listener speaks. h2c is +// prior-knowledge-only (no h1 fallback on that listener), mirroring the +// stdhttp adapter's h2c-noupg mode. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Engine { + H1, + H2c, +} - HttpServer::new(async || { +// make_app expands to the `App::new()...` builder expression. Both the h1 +// and h2c serve paths need an identically-configured App, but the App +// builder's concrete type is unnameable (a deep chain of generic route +// wrappers), so a macro shares the expression without naming the type. It +// returns a value implementing IntoServiceFactory<_, Request, SharedCfg>, +// which is what both HttpServer::new and HttpService::h2 accept. +macro_rules! make_app { + () => { App::new() // ntex's web::Bytes extractor caps the buffered body at // 256 KiB by default (ntex::web::Bytes::LIMIT = 262144). @@ -65,15 +85,85 @@ async fn main() -> std::io::Result<()> { .route("/", web::get().to(root)) .route("/json", web::get().to(json_static)) .route("/json-1k", web::get().to(json_1k)) + .route("/json-8k", web::get().to(json_8k)) + .route("/json-16k", web::get().to(json_16k)) .route("/json-64k", web::get().to(json_64k)) .route("/users/{id}", web::get().to(users_id)) .route("/upload", web::post().to(upload)) - }) - .listen(listener)? - // Shutdown grace tighter than the runner's 5s SIGKILL fallback. - .shutdown_timeout(Seconds(3)) - .run() - .await + }; +} + +#[ntex::main] +async fn main() -> ExitCode { + let engine = match parse_engine_arg() { + Ok(e) => e, + Err(msg) => { + eprintln!("{msg}"); + return ExitCode::FAILURE; + } + }; + let bind = parse_bind_arg().unwrap_or_else(|| "127.0.0.1:8080".to_string()); + + // Bind a std listener ourselves so we can resolve the actual addr + // (handles port=0 cleanly) before handing it to ntex. ntex's + // HttpServer doesn't expose an addrs() accessor, so this is the + // canonical pattern for both serve paths. + let listener = match TcpListener::bind(&bind) { + Ok(l) => l, + Err(e) => { + eprintln!("ntex: bind {bind:?}: {e}"); + return ExitCode::FAILURE; + } + }; + let bound = match listener.local_addr() { + Ok(a) => a, + Err(e) => { + eprintln!("ntex: local_addr: {e}"); + return ExitCode::FAILURE; + } + }; + + println!("ready addr={bound}"); + let _ = std::io::stdout().flush(); + + let result = match engine { + Engine::H1 => serve_h1(listener).await, + Engine::H2c => serve_h2c(listener).await, + }; + if let Err(e) = result { + eprintln!("ntex: serve: {e}"); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS +} + +// serve_h1 keeps the original web::HttpServer path: HTTP/1.1 (ntex's +// HttpService::new default serves h1 on a cleartext socket). +async fn serve_h1(listener: TcpListener) -> std::io::Result<()> { + HttpServer::new(async || make_app!()) + .listen(listener)? + // Shutdown grace tighter than the runner's 5s SIGKILL fallback. + .shutdown_timeout(Seconds(3)) + .run() + .await +} + +// serve_h2c serves HTTP/2 prior-knowledge cleartext. web::HttpServer has +// no protocol selector, so we drop to the low-level ntex::server builder +// and supply our own per-connection service factory: +// HttpService::h2(app) runs ntex's H2 dispatcher directly on the +// plaintext Io (the base filter is the raw socket — no TLS, no h1->h2 +// upgrade). The server builder's lifecycle is identical to +// web::HttpServer's (web::HttpServer::run just delegates to it), so the +// default SIGTERM graceful shutdown and the 3s drain cap carry over. +async fn serve_h2c(listener: TcpListener) -> std::io::Result<()> { + ntex::server::build() + .listen("ntex-h2c", listener, async move |_cfg| { + HttpService::h2(make_app!()) + })? + .shutdown_timeout(Seconds(3)) + .run() + .await } fn parse_bind_arg() -> Option { @@ -92,6 +182,28 @@ fn parse_bind_arg() -> Option { None } +// parse_engine_arg reads `-engine ` (default "h1"). Accepts "h1" +// and "h2c"; any other value is a hard error so a typo in the runner's +// invocation fails fast and visibly rather than silently serving h1. +fn parse_engine_arg() -> Result { + let mut value: Option = None; + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + if a == "-engine" || a == "--engine" { + value = args.next(); + } else if let Some(rest) = a.strip_prefix("-engine=") { + value = Some(rest.to_string()); + } else if let Some(rest) = a.strip_prefix("--engine=") { + value = Some(rest.to_string()); + } + } + match value.as_deref() { + None | Some("h1") => Ok(Engine::H1), + Some("h2c") => Ok(Engine::H2c), + Some(other) => Err(format!("ntex: unknown -engine {other:?} (want h1|h2c)")), + } +} + // ---- handlers ---- async fn root() -> HttpResponse { @@ -112,6 +224,18 @@ async fn json_1k() -> HttpResponse { .body(payload::json_1k()) } +async fn json_8k() -> HttpResponse { + HttpResponse::Ok() + .content_type("application/json") + .body(payload::json_8k()) +} + +async fn json_16k() -> HttpResponse { + HttpResponse::Ok() + .content_type("application/json") + .body(payload::json_16k()) +} + async fn json_64k() -> HttpResponse { HttpResponse::Ok() .content_type("application/json") diff --git a/servers/ntex/src/payload.rs b/servers/ntex/src/payload.rs index 177f4e8..fefec61 100644 --- a/servers/ntex/src/payload.rs +++ b/servers/ntex/src/payload.rs @@ -12,12 +12,22 @@ use std::sync::OnceLock; static JSON_1K: OnceLock> = OnceLock::new(); +static JSON_8K: OnceLock> = OnceLock::new(); +static JSON_16K: OnceLock> = OnceLock::new(); static JSON_64K: OnceLock> = OnceLock::new(); pub fn json_1k() -> &'static [u8] { JSON_1K.get_or_init(|| generate(1024)).as_slice() } +pub fn json_8k() -> &'static [u8] { + JSON_8K.get_or_init(|| generate(8192)).as_slice() +} + +pub fn json_16k() -> &'static [u8] { + JSON_16K.get_or_init(|| generate(16384)).as_slice() +} + pub fn json_64k() -> &'static [u8] { JSON_64K.get_or_init(|| generate(65536)).as_slice() } @@ -78,6 +88,16 @@ mod tests { assert_eq!(json_1k().len(), 1026); } + #[test] + fn json_8k_matches_go_size() { + assert_eq!(json_8k().len(), 8286); + } + + #[test] + fn json_16k_matches_go_size() { + assert_eq!(json_16k().len(), 16463); + } + #[test] fn json_64k_matches_go_size() { assert_eq!(json_64k().len(), 65618); diff --git a/servers/servers.go b/servers/servers.go index b2612c6..b72d157 100644 --- a/servers/servers.go +++ b/servers/servers.go @@ -95,6 +95,17 @@ type NativeBinary struct { Lang string BuildSteps []string RunCmd string + + // BinName, when set, is the staged-binary slug this column runs from, + // overriding the column's own Name. It lets several columns share ONE + // native build that switches mode on -engine — the native analogue of + // the Go adapters where gin-h1 and gin-h2 both run servers/gin's binary. + // An h2c column ("-h2", engine h2c-noupg) sets BinName to the + // framework slug so it reuses the h1 column's competitors/ + // binary instead of demanding a non-existent competitors/-h2. + // Empty means "run from competitors/" (the default, one build per + // column). + BinName string } // Kind reports the BuildSpec discriminant ("native"). @@ -275,6 +286,13 @@ var Registry = map[string]Adapter{ Bin: GoBinary{ModuleDir: "servers/gnet"}, Capabilities: Capabilities{Static: true}, }, + // nbio — lesegit/nbio epoll event-loop (the other Go async-IO lib next to + // gnet). Separate module (servers/nbio/go.mod). H1-only, Static. + "nbio-h1": { + Name: "nbio-h1", Category: "go-nbio", Language: "go", Framework: "nbio", Engine: "h1", + Bin: GoBinary{ModuleDir: "servers/nbio"}, + Capabilities: Capabilities{Static: true}, + }, // fasthttp + fiber — H1-only. fiber wraps fasthttp. "fasthttp-h1": { @@ -296,8 +314,9 @@ var Registry = map[string]Adapter{ // release-fat binary into ${bench_root}/competitors/. The // runner invokes that symlink with `-bind ` and waits for // `ready addr=` on stdout. SIGTERM triggers graceful shutdown - // inside servers/start.go's 5-second grace window. H2 cell-columns - // land later — these three are H1-only for wave 4a. + // inside servers/start.go's 5-second grace window. Each now carries an + // h1 column (below) and a prior-knowledge h2c column (-h2): hyper, ntex, + // and axum-on-hyper all serve cleartext h2c, selected via -engine h2c. "axum": { Name: "axum", Category: "rust-tower", Language: "rust", Framework: "axum", Engine: "h1", Bin: NativeBinary{ @@ -310,6 +329,26 @@ var Registry = map[string]Adapter{ }, Capabilities: Capabilities{Static: true}, }, + // axum-h2 — the same servers/axum binary in prior-knowledge h2c mode + // (-engine h2c serves HTTP/2 cleartext only, refusing H1 like + // stdhttp-h2). hyper, which axum sits on, speaks h2c natively. Shares + // the competitors/axum build via Bin.BinName so the deploy builds axum + // once and this column reuses it. Engine "h2c-noupg" → featureSetFor + // gives HTTP2C=true / HTTP1=false, so only the H2 scenarios schedule + // here (the H1 grid stays on the axum column). + "axum-h2": { + Name: "axum-h2", Category: "rust-tower", Language: "rust", Framework: "axum", Engine: "h2c-noupg", + Bin: NativeBinary{ + Lang: "rust", + BuildSteps: []string{ + "source $RUSTUP_HOME/env", + "cd $SRC && cargo build --profile release-fat", + }, + RunCmd: "{bin} -bind {bind}", + BinName: "axum", + }, + Capabilities: Capabilities{Static: true}, + }, "ntex": { Name: "ntex", Category: "rust-ntex", Language: "rust", Framework: "ntex", Engine: "h1", Bin: NativeBinary{ @@ -322,6 +361,17 @@ var Registry = map[string]Adapter{ }, Capabilities: Capabilities{Static: true}, }, + // ntex has NO h2c column. ntex's low-level server::build()+HttpService::h2 + // path serves h2c prior-knowledge for a single connection (curl + // --http2-prior-knowledge → 200) but FAILS the HTTP/2 handshake under the + // loadgen's concurrent connection pool: it stops emitting the server + // SETTINGS frame around the ~15th simultaneous dial, so the loadgen aborts + // with "h2client: dial conn[15]: read server settings: i/o timeout" before + // the bench can start — a guaranteed-DNF cell. Unlike hyper/axum (whose + // hyper-backed h2 server handshakes immediately), ntex's h2c cannot sustain + // the bench's concurrent dial, so we register no ntex-h2 column rather than + // ship a cell that always DNFs (same policy as drogon-h2 / libreactor-h2). + // ntex-h1 is unaffected and stays. Revisit if ntex's h2c handshake improves. // hyper — the raw Rust baseline axum / ntex are all built // on or measured against. No router crate, no tower stack: the adapter @@ -342,6 +392,21 @@ var Registry = map[string]Adapter{ }, Capabilities: Capabilities{Static: true}, }, + // hyper-h2 — raw hyper's http2 server builder serves prior-knowledge + // h2c directly. Shares the competitors/hyper build (see axum-h2). + "hyper-h2": { + Name: "hyper-h2", Category: "rust-hyper", Language: "rust", Framework: "hyper", Engine: "h2c-noupg", + Bin: NativeBinary{ + Lang: "rust", + BuildSteps: []string{ + "source $RUSTUP_HOME/env", + "cd $SRC && cargo build --profile release-fat", + }, + RunCmd: "{bin} -bind {bind}", + BinName: "hyper", + }, + Capabilities: Capabilities{Static: true}, + }, // drogon — the top C++ contender. Built natively on the bench host via // CMake against libdrogon (drogon + trantor + jsoncpp + OpenSSL), the @@ -367,6 +432,12 @@ var Registry = map[string]Adapter{ }, Capabilities: Capabilities{}, }, + // drogon has NO h2c column: drogon 1.9.x exposes no server-side HTTP/2 + // at all (HttpTypes.h Version is kHttp10/kHttp11 only; addListener has no + // protocol/h2c flag). Cleartext h2c prior-knowledge is impossible, so the + // adapter's -engine h2c fails fast and we deliberately register NO + // drogon-h2 column rather than ship a guaranteed-DNF cell. drogon stays + // H1-only; its N/A on the h2 scenarios is correct, not a gap. // fastapi — python adapter, native (NO docker). The launcher script // at {bench}/competitors/fastapi/server is rendered by the python @@ -383,6 +454,21 @@ var Registry = map[string]Adapter{ }, Capabilities: Capabilities{Static: true}, }, + // fastapi-h2 — prior-knowledge h2c via hypercorn (the h1 column stays on + // uvicorn, the tuned fast path). uvicorn has no HTTP/2; hypercorn serves + // cleartext h2c prior-knowledge with no TLS — the H11 reader swaps to the + // H2 protocol on seeing the "PRI * HTTP/2.0" preface. The launcher + // dispatches -engine h2c → hypercorn. Shares the competitors/fastapi + // launcher via Bin.BinName (see axum-h2). + "fastapi-h2": { + Name: "fastapi-h2", Category: "python-fastapi", Language: "python", Framework: "fastapi", Engine: "h2c-noupg", + Bin: NativeBinary{ + Lang: "python", + RunCmd: "{bench}/competitors/{name}/server -bind {bind}", + BinName: "fastapi", + }, + Capabilities: Capabilities{Static: true}, + }, // aspnet — ASP.NET Core (Kestrel, minimal APIs) on .NET 10, built // natively on the bench host like the Rust adapters. `dotnet publish @@ -395,8 +481,9 @@ var Registry = map[string]Adapter{ // middleware, AddServerHeader=false). The JSON payloads come from the // same deterministic generator as every other adapter so /json-1k and // /json-64k are byte-identical across languages. SIGTERM triggers - // Kestrel's graceful shutdown inside the runner's grace window. H1-only - // for this wave. + // Kestrel's graceful shutdown inside the runner's grace window. Carries + // both an h1 column and an aspnet-h2 prior-knowledge h2c column (Kestrel + // HttpProtocols.Http2, selected via -engine h2c). "aspnet": { Name: "aspnet", Category: "dotnet-aspnetcore", Language: "csharp", Framework: "aspnet", Engine: "h1", Bin: NativeBinary{ @@ -408,6 +495,21 @@ var Registry = map[string]Adapter{ }, Capabilities: Capabilities{Static: true}, }, + // aspnet-h2 — Kestrel serves prior-knowledge h2c when the endpoint's + // HttpProtocols is Http2; the -engine h2c mode selects it. Shares the + // competitors/aspnet build (see axum-h2). + "aspnet-h2": { + Name: "aspnet-h2", Category: "dotnet-aspnetcore", Language: "csharp", Framework: "aspnet", Engine: "h2c-noupg", + Bin: NativeBinary{ + Lang: "dotnet", + BuildSteps: []string{ + "cd $SRC && dotnet publish -c Release -o publish", + }, + RunCmd: "{bin} -bind {bind}", + BinName: "aspnet", + }, + Capabilities: Capabilities{Static: true}, + }, // zig_zap — REMOVED from the registry on 2026-06-11. The Zig 0.16 // std.http.Server's accept loop is single-listener / single-thread @@ -433,8 +535,11 @@ var Registry = map[string]Adapter{ // reuse-port primitive; the registry entry is intentionally // absent so the bench never schedules a column against it. - // celeris — 4 engine modes selected at runtime via -engine. The - // binary is the same; entries differ only in Engine + Name. + // celeris — 9 engine modes selected at runtime via -engine. The + // binary is the same; entries differ only in Engine + Name. The first + // 4 are the original headline columns; the next 5 (adaptive ×2, + // epoll-async ×2, iouring-h1-sync) isolate the engine × sync/async × + // adaptive axes the v1.5.x work added. "celeris-iouring-h1-async": { Name: "celeris-iouring-h1-async", Category: "celeris", Language: "go", Framework: "celeris", Engine: "iouring-h1-async", @@ -459,6 +564,41 @@ var Registry = map[string]Adapter{ Bin: GoBinary{ModuleDir: "servers/celeris"}, Capabilities: Capabilities{Static: true, Drivers: true, Middleware: true, WS: true, SSE: true, TLS: true}, }, + // adaptive meta-engine (the v1.5.x headline): starts epoll, promotes + // new conns to io_uring under sustained load. h1 + auto(h2c) variants. + "celeris-adaptive-h1-async": { + Name: "celeris-adaptive-h1-async", Category: "celeris", Language: "go", Framework: "celeris", + Engine: "adaptive-h1-async", + Bin: GoBinary{ModuleDir: "servers/celeris"}, + Capabilities: Capabilities{Static: true, Drivers: true, Middleware: true, WS: true, SSE: true, TLS: true}, + }, + "celeris-adaptive-auto+upg-async": { + Name: "celeris-adaptive-auto+upg-async", Category: "celeris", Language: "go", Framework: "celeris", + Engine: "adaptive-auto+upg-async", + Bin: GoBinary{ModuleDir: "servers/celeris"}, + Capabilities: Capabilities{Static: true, Drivers: true, Middleware: true, WS: true, SSE: true, TLS: true}, + }, + // engine × sync/async grid completers — epoll-async (h1 + h2c) and + // iouring-h1-sync — so the matrix can isolate the sync-vs-async axis on + // a fixed engine, and h2c on epoll (which the original 4 could not express). + "celeris-epoll-h1-async": { + Name: "celeris-epoll-h1-async", Category: "celeris", Language: "go", Framework: "celeris", + Engine: "epoll-h1-async", + Bin: GoBinary{ModuleDir: "servers/celeris"}, + Capabilities: Capabilities{Static: true, Drivers: true, Middleware: true, WS: true, SSE: true, TLS: true}, + }, + "celeris-epoll-auto+upg-async": { + Name: "celeris-epoll-auto+upg-async", Category: "celeris", Language: "go", Framework: "celeris", + Engine: "epoll-auto+upg-async", + Bin: GoBinary{ModuleDir: "servers/celeris"}, + Capabilities: Capabilities{Static: true, Drivers: true, Middleware: true, WS: true, SSE: true, TLS: true}, + }, + "celeris-iouring-h1-sync": { + Name: "celeris-iouring-h1-sync", Category: "celeris", Language: "go", Framework: "celeris", + Engine: "iouring-h1-sync", + Bin: GoBinary{ModuleDir: "servers/celeris"}, + Capabilities: Capabilities{Static: true, Drivers: true, Middleware: true, WS: true, SSE: true, TLS: true}, + }, // hono / elysia — wave 4b. TypeScript adapters running natively // under Bun (no docker, no node). The "binary" the runner exec's @@ -472,9 +612,10 @@ var Registry = map[string]Adapter{ // Both Bun adapters call Bun.serve directly with the framework's // .fetch handler — Hono via app.fetch (the documented fast path, // skipping @hono/node-server) and Elysia via app.fetch (skipping - // Elysia's .listen wrapper). H1-only because Bun.serve does not - // expose H2C without the experimental --tls path, which the H1- - // only competitor cells don't exercise. + // Elysia's .listen wrapper) for the H1 fast path. Bun.serve has no + // cleartext-H2C server option, but `node:http2`'s createServer (which + // Bun implements) does serve cleartext h2c prior-knowledge, so the + // h2 columns below bridge an http2 server to the same app.fetch handler. "hono": { Name: "hono", Category: "bun-ts", Language: "bun", Framework: "hono", Engine: "", Bin: NativeBinary{ @@ -482,6 +623,18 @@ var Registry = map[string]Adapter{ RunCmd: "{name} -bind {bind}", }, }, + // hono-h2 — prior-knowledge h2c via node:http2.createServer bridged to + // Hono's app.fetch; -engine h2c selects it. Shares the competitors/hono + // launcher via Bin.BinName (the launcher forwards "$@", so -engine h2c + // reaches the same dist/server entry). + "hono-h2": { + Name: "hono-h2", Category: "bun-ts", Language: "bun", Framework: "hono", Engine: "h2c-noupg", + Bin: NativeBinary{ + Lang: "bun", + RunCmd: "{name} -bind {bind}", + BinName: "hono", + }, + }, "elysia": { Name: "elysia", Category: "bun-ts", Language: "bun", Framework: "elysia", Engine: "", Bin: NativeBinary{ @@ -489,6 +642,128 @@ var Registry = map[string]Adapter{ RunCmd: "{name} -bind {bind}", }, }, + // elysia-h2 — prior-knowledge h2c via node:http2 bridged to Elysia's + // app.fetch (see hono-h2). Shares the competitors/elysia launcher. + "elysia-h2": { + Name: "elysia-h2", Category: "bun-ts", Language: "bun", Framework: "elysia", Engine: "h2c-noupg", + Bin: NativeBinary{ + Lang: "bun", + RunCmd: "{name} -bind {bind}", + BinName: "elysia", + }, + }, + + // ---- wave-6 native competitors (h1 columns; -h2 siblings follow once + // the h1 grid is cluster-verified). Each rides an existing or new + // toolchain role; see ansible/roles/ + mage_cluster.go specs. ---- + + // actix — actix-web 4.x (rust role, unchanged). Worker-per-core, no TLS. + "actix": { + Name: "actix", Category: "rust-actix", Language: "rust", Framework: "actix-web", Engine: "h1", + Bin: NativeBinary{ + Lang: "rust", + BuildSteps: []string{"source $RUSTUP_HOME/env", "cd $SRC && cargo build --profile release-fat"}, + RunCmd: "{bin} -bind {bind}", + }, + Capabilities: Capabilities{Static: true}, + }, + // starlette — pure ASGI Starlette on uvicorn (python role, like fastapi). + "starlette": { + Name: "starlette", Category: "python-starlette", Language: "python", Framework: "starlette", Engine: "h1", + Bin: NativeBinary{ + Lang: "python", + RunCmd: "{bench}/competitors/{name}/server -bind {bind}", + }, + Capabilities: Capabilities{Static: true}, + }, + // bunraw — raw Bun.serve baseline, no framework (the bun analogue of hyper). + "bunraw": { + Name: "bunraw", Category: "bun-ts", Language: "bun", Framework: "bunraw", Engine: "", + Bin: NativeBinary{ + Lang: "bun", + RunCmd: "{name} -bind {bind}", + }, + }, + // httpzig — karlseguin/http.zig (zig role). HTTP/1.1-only. + "httpzig": { + Name: "httpzig", Category: "zig-httpz", Language: "zig", Framework: "httpzig", Engine: "h1", + Bin: NativeBinary{ + Lang: "zig", + BuildSteps: []string{"cd $SRC && zig build -Doptimize=ReleaseSafe"}, // ReleaseSafe turns http.zig's churn-race UB into a recoverable panic (the bench respawns it) + RunCmd: "{bin} -bind {bind}", + }, + Capabilities: Capabilities{Static: true}, + }, + // lithium — matt-42/lithium (cpp role + Boost.Context). HTTP/1.x-only. + "lithium": { + Name: "lithium", Category: "cpp-lithium", Language: "cpp", Framework: "lithium", Engine: "h1", + Bin: NativeBinary{ + Lang: "cpp", + BuildSteps: []string{ + "cd $SRC && cmake -S . -B build -DCMAKE_BUILD_TYPE=Release", + "cd $SRC && cmake --build build -j", + }, + RunCmd: "{bin} -bind {bind}", + }, + Capabilities: Capabilities{Static: true}, + }, + // h2o — libh2o evloop server (c role EXTENDED to build libh2o into + // {bench}/h2o/prefix; H2O_PREFIX env points the adapter Makefile there). + "h2o": { + Name: "h2o", Category: "c-h2o", Language: "c", Framework: "h2o", Engine: "h1", + Bin: NativeBinary{ + Lang: "c", + BuildSteps: []string{"cd $SRC && make H2O_PREFIX=\"$H2O_PREFIX\" CFLAGS_EXTRA=-march=native"}, + RunCmd: "{bin} -bind {bind}", + }, + Capabilities: Capabilities{Static: true}, + }, + // uws — uWebSockets.js (NEW node role; launcher tier like bun). HTTP/1.1. + "uws": { + Name: "uws", Category: "node-uws", Language: "node", Framework: "uWebSockets.js", Engine: "h1", + Bin: NativeBinary{ + Lang: "node", + RunCmd: "{name} -bind {bind}", + }, + Capabilities: Capabilities{Static: true}, + }, + // fastify — Fastify (node role). Launcher symlinked at competitors/fastify + // (bun-style), so RunCmd is the {name} form, NOT {bench}/.../{name}/server. + "fastify": { + Name: "fastify", Category: "node-fastify", Language: "node", Framework: "fastify", Engine: "h1", + Bin: NativeBinary{ + Lang: "node", + RunCmd: "{name} -bind {bind}", + }, + Capabilities: Capabilities{Static: true}, + }, + // express — Express 5 (node role), the JS baseline floor. HTTP/1.1-only. + "express": { + Name: "express", Category: "node-express", Language: "node", Framework: "express", Engine: "h1", + Bin: NativeBinary{ + Lang: "node", + RunCmd: "{name} -bind {bind}", + }, + Capabilities: Capabilities{Static: true}, + }, + // vertx — Eclipse Vert.x (NEW java role; maven fat jar + launcher). + "vertx": { + Name: "vertx", Category: "java-vertx", Language: "java", Framework: "vertx", Engine: "h1", + Bin: NativeBinary{ + Lang: "java", + RunCmd: "{bin} -bind {bind}", + }, + Capabilities: Capabilities{Static: true}, + }, + // netty — raw Netty HTTP/1.1 (java role), the JVM floor analogue of hyper. + "netty": { + Name: "netty", Category: "java-netty", Language: "java", Framework: "netty", Engine: "h1", + Bin: NativeBinary{ + Lang: "java", + RunCmd: "{name} -bind {bind}", + }, + Capabilities: Capabilities{Static: true}, + }, } // Names returns every registered adapter name in stable sorted order. diff --git a/servers/starlette/app/__init__.py b/servers/starlette/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/servers/starlette/app/payload.py b/servers/starlette/app/payload.py new file mode 100644 index 0000000..b19fe7e --- /dev/null +++ b/servers/starlette/app/payload.py @@ -0,0 +1,72 @@ +"""Deterministic JSON payloads for /json-1k, /json-8k, /json-16k and /json-64k. + +These MUST be byte-identical to the Go reference at +``servers/common/payload.go``. The conformance probe (``cmd/conformance``) +byte-compares response bodies against the Go-generated payload baked into +``servers.common.Endpoints``; any drift here produces a hard failure. + +Equivalence contract with Go's ``encoding/json``: + +* Compact separators — no whitespace between keys, values, or list items. + ``orjson.dumps`` is compact by default; that matches Go's + ``json.Marshal``. +* Key order matches the Go struct declaration (``page``, ``per_page``, + ``total``, ``total_pages``, ``data``; per-item ``id``, ``name``, + ``email``, ``status``, ``created_at``). Python 3.7+ ``dict`` preserves + insertion order, and ``orjson`` honours it on serialise. +* Strings are ASCII; no characters that would trip Go's HTML escaping + (``<`` ``>`` ``&``) appear in the payload, so the two encoders agree + on every byte. +* The growth loop appends items until the marshalled length crosses the + target — same termination predicate as ``generateJSONPayload`` in Go, + which gives the same item count (8 items for 1 KiB, 583 for 64 KiB). + The mid-size 8 KiB / 16 KiB payloads bridge the 1k→64k gap so the bench + keeps differentiating adapters below the 20G fabric ceiling that makes + the 64k cells NIC-bound (see ``servers/common/payload.go`` JSON8KPayload). + Exact byte lengths (byte-compared by cluster conformance): + 1k = 1026, 8k = 8286, 16k = 16463, 64k = 65618. + +This module is shared verbatim with the fastapi adapter — both serve the +identical bytes so a starlette-vs-fastapi delta reflects framework cost, +never a payload artefact. If a future change introduces a non-ASCII char +or a key Go would HTML-escape, the conformance probe will fail loudly — +keep this module's output passing through ``orjson.dumps`` with no +options set. +""" + +from __future__ import annotations + +import orjson + + +def _generate(target_size: int) -> bytes: + """Build a paginated JSON response of at least ``target_size`` bytes.""" + items: list[dict[str, object]] = [] + resp: dict[str, object] = { + "page": 1, + "per_page": 50, + "total": 1000, + "total_pages": 20, + "data": items, + } + i = 1 + while True: + items.append( + { + "id": i, + "name": f"User {i}", + "email": f"user{i}@example.com", + "status": "active", + "created_at": "2024-01-15T09:30:00Z", + } + ) + data = orjson.dumps(resp) + if len(data) >= target_size: + return data + i += 1 + + +JSON_1K_PAYLOAD: bytes = _generate(1024) +JSON_8K_PAYLOAD: bytes = _generate(8192) +JSON_16K_PAYLOAD: bytes = _generate(16384) +JSON_64K_PAYLOAD: bytes = _generate(65536) diff --git a/servers/starlette/app/server.py b/servers/starlette/app/server.py new file mode 100644 index 0000000..69925ef --- /dev/null +++ b/servers/starlette/app/server.py @@ -0,0 +1,277 @@ +"""Starlette competitor adapter — plain ASGI, no FastAPI layer. + +Starlette is the ASGI toolkit FastAPI is built on; running it directly +skips FastAPI's per-route dependency-injection / pydantic validation +machinery, so this column measures the raw routing + ASGI cost. Stack: + +* Plain ``starlette.applications.Starlette`` with an explicit ``routes`` + list of ``Route`` objects — no decorators, no DI, no response-model + validation. Every handler returns a pre-baked ``Response`` of raw + bytes so there is no per-request JSON re-encoding. +* Every handler is ``async def`` — Starlette runs sync endpoints in a + threadpool, which would distort the cell with thread-hop overhead. +* uvicorn[standard] supplies uvloop and httptools transparently; both + are selected at launch time via ``--loop uvloop --http httptools`` in + the launcher script (see ``ansible/roles/python``). + +Argv contract (matches the Go adapters and the fastapi adapter): + + python -m app.server -bind 127.0.0.1:8080 [-engine h1|h2c] + +``-bind`` accepts ``host:port``. ``-engine`` selects the wire protocol: + +* ``h1`` (or absent) — plain HTTP/1.1 on uvicorn + uvloop + httptools, + the tuned fast path, mapped onto ``uvicorn.Server`` in single-process + mode. The cluster launcher script prefers ``uvicorn`` directly with + ``--workers $(nproc)``; this entry-point is for local development and + for the dev-mac smoke import test. +* ``h2c`` — HTTP/2 cleartext, prior-knowledge, no TLS. uvicorn cannot + speak HTTP/2, so this path launches the same ASGI app under + **hypercorn** instead. Hypercorn's h11 reader detects the HTTP/2 + connection preface (``PRI * HTTP/2.0``) on a plaintext bind and swaps + the connection to its H2 protocol with no ALPN / TLS handshake — i.e. + exactly h2c prior-knowledge. With no certfile/keyfile, + ``Config.ssl_enabled`` is False ⇒ insecure/cleartext sockets. + +Both long (``--bind``/``--engine``) and short (``-bind``/``-engine``) +spellings are accepted so the launcher shim and the Go orchestrator can +pass either. + +Readiness banner: + +When run via this entry-point, ``ready addr=`` is printed +to stdout exactly once after the listening socket is open. The cluster +launcher script prints the same banner after polling the bind addr +from outside the uvicorn master, so every worker count produces a +single banner instead of one per worker. + +SIGTERM handling: uvicorn's default ``Server.install_signal_handlers`` +converts SIGTERM/SIGINT into a graceful shutdown that drains in-flight +requests and closes the listener; hypercorn's ``serve`` installs the +same signal handlers. We do not override either. +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import socket +import sys + +import uvicorn +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Route + +from .payload import ( + JSON_1K_PAYLOAD, + JSON_8K_PAYLOAD, + JSON_16K_PAYLOAD, + JSON_64K_PAYLOAD, +) + +# Static byte payloads. Hoisted to module scope so each request reuses the +# same immutable bytes object — no per-request allocation, mirrors the Go +# adapters that serve a pre-baked slice from ``servers/common``. +_HELLO_PLAIN: bytes = b"Hello, World!" +_HELLO_JSON: bytes = b'{"message":"Hello, World!"}' +_OK_PLAIN: bytes = b"OK" + + +def _announce_ready(bound_host: str, bound_port: int) -> None: + """Print the ``ready addr=...`` banner once, unless suppressed. + + The cluster launcher emits the banner from an external TCP probe so + the count is exactly one regardless of worker count / server. It sets + ``PROBATORIUM_SUPPRESS_READY=1`` to silence this in-process banner and + avoid a duplicate. Local-dev runs (no env var) still get the banner. + """ + if os.environ.get("PROBATORIUM_SUPPRESS_READY") == "1": + return + print(f"ready addr={bound_host}:{bound_port}", flush=True) + sys.stdout.flush() + + +# --- Handlers ------------------------------------------------------------- +# +# Each returns a Response of pre-baked bytes with an explicit media_type so +# the wire body is byte-identical to common.Endpoints[...].ResponseBody. No +# JSONResponse re-encoding could drift the field order. + + +async def root(request: Request) -> Response: + return Response(content=_HELLO_PLAIN, media_type="text/plain") + + +async def json_hello(request: Request) -> Response: + return Response(content=_HELLO_JSON, media_type="application/json") + + +async def json_1k(request: Request) -> Response: + return Response(content=JSON_1K_PAYLOAD, media_type="application/json") + + +async def json_8k(request: Request) -> Response: + return Response(content=JSON_8K_PAYLOAD, media_type="application/json") + + +async def json_16k(request: Request) -> Response: + return Response(content=JSON_16K_PAYLOAD, media_type="application/json") + + +async def json_64k(request: Request) -> Response: + return Response(content=JSON_64K_PAYLOAD, media_type="application/json") + + +async def users(request: Request) -> Response: + user_id = request.path_params["user_id"] + return Response(content=f"User ID: {user_id}".encode(), media_type="text/plain") + + +async def upload(request: Request) -> Response: + # Drain the body so the body parser is part of the measured cost, + # matching every other adapter. + async for _ in request.stream(): + pass + return Response(content=_OK_PLAIN, media_type="text/plain") + + +# Explicit route table — the plain-Starlette analogue of FastAPI's +# decorators. Order is irrelevant (Starlette matches by exact path then +# parametrised path), but kept in contract order for readability. +app = Starlette( + routes=[ + Route("/", root, methods=["GET"]), + Route("/json", json_hello, methods=["GET"]), + Route("/json-1k", json_1k, methods=["GET"]), + Route("/json-8k", json_8k, methods=["GET"]), + Route("/json-16k", json_16k, methods=["GET"]), + Route("/json-64k", json_64k, methods=["GET"]), + Route("/users/{user_id}", users, methods=["GET"]), + Route("/upload", upload, methods=["POST"]), + ] +) + + +def _parse_bind(bind: str) -> tuple[str, int]: + """Split ``host:port`` into ``(host, int(port))``. + + IPv6 addresses are accepted in bracketed form (``[::1]:8080``). + """ + if bind.startswith("["): + # IPv6 literal — split on closing bracket, then on the colon + # separating address and port. + rb = bind.index("]") + host = bind[1:rb] + port = int(bind[rb + 2 :]) + return host, port + host, _, port_s = bind.rpartition(":") + if not host or not port_s: + raise ValueError(f"invalid -bind {bind!r}: expected host:port") + return host, int(port_s) + + +def _bind_socket(host: str, port: int) -> socket.socket: + """Open a listening socket on ``host:port`` (port 0 ⇒ OS-assigned).""" + sock = socket.socket(socket.AF_INET if ":" not in host else socket.AF_INET6, + socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((host, port)) + sock.listen(2048) + return sock + + +def _serve_h1(sock: socket.socket, bound_host: str, bound_port: int) -> None: + """HTTP/1.1 fast path: uvicorn + uvloop + httptools on the pre-bound fd.""" + config = uvicorn.Config( + app, + loop="uvloop", + http="httptools", + access_log=False, + log_level="warning", + # `fd=` makes uvicorn adopt our pre-bound socket so the printed + # address below is guaranteed to be the one in use. + fd=sock.fileno(), + ) + server = uvicorn.Server(config) + + _announce_ready(bound_host, bound_port) + + asyncio.run(server.serve()) + + +def _serve_h2c(sock: socket.socket, bound_host: str, bound_port: int) -> None: + """HTTP/2 cleartext (prior-knowledge, no TLS) via hypercorn. + + uvicorn has no HTTP/2 support, so the h2c column runs hypercorn. With + no certfile/keyfile, ``Config.ssl_enabled`` is False and the bind is + served on an insecure (cleartext) socket; hypercorn's h11 reader then + upgrades any connection that opens with the ``PRI * HTTP/2.0`` preface + straight to HTTP/2 — that is h2c prior-knowledge. uvloop is selected + via the loop installed before ``serve``. + """ + try: + import uvloop + from hypercorn.asyncio import serve + from hypercorn.config import Config + except ImportError as exc: # pragma: no cover - dep guard + print( + f"starlette-h2: hypercorn/uvloop unavailable for -engine h2c: {exc}", + file=sys.stderr, + flush=True, + ) + raise SystemExit(3) from exc + + config = Config() + config.bind = [f"{bound_host}:{bound_port}"] + config.insecure_bind = [] + config.accesslog = None + config.errorlog = None + config.loglevel = "WARNING" + # With no certfile/keyfile, ssl_enabled is False, so `bind` is served on + # an insecure (cleartext) socket. Advertise h2 first anyway; on cleartext + # ALPN is never consulted — prior-knowledge keys solely off the preface. + config.alpn_protocols = ["h2", "http/1.1"] + + # We bound the socket up front to learn the final address (port may be + # 0). hypercorn's serve() re-binds from `config.bind`, so release ours + # first to avoid a double-bind on the same address. SO_REUSEADDR was set + # and this is a single process, so there is no contention. + sock.close() + + uvloop.install() + + _announce_ready(bound_host, bound_port) + + asyncio.run(serve(app, config)) + + +def main() -> None: + """Local-dev entry point. The cluster launcher invokes the server directly.""" + parser = argparse.ArgumentParser(prog="probatorium-starlette") + parser.add_argument("-bind", "--bind", dest="bind", default="127.0.0.1:8080") + parser.add_argument( + "-engine", + "--engine", + dest="engine", + default="h1", + choices=["h1", "h2c"], + ) + args = parser.parse_args() + host, port = _parse_bind(args.bind) + + # Bind the socket up front so we know the final address (port may be + # 0 in dev) before announcing readiness. + sock = _bind_socket(host, port) + bound_host, bound_port = sock.getsockname()[:2] + + if args.engine == "h2c": + _serve_h2c(sock, bound_host, bound_port) + else: + _serve_h1(sock, bound_host, bound_port) + + +if __name__ == "__main__": + main() diff --git a/servers/starlette/pyproject.toml b/servers/starlette/pyproject.toml new file mode 100644 index 0000000..c49b7fc --- /dev/null +++ b/servers/starlette/pyproject.toml @@ -0,0 +1,62 @@ +# Starlette competitor adapter for probatorium. +# +# Starlette is the raw ASGI toolkit FastAPI sits on. Running it directly +# (no FastAPI dependency-injection / pydantic layer) is the faster Python +# baseline — this column isolates the routing + ASGI cost FastAPI adds its +# DI/validation overhead on top of. +# +# Always-latest version policy: every dep uses a `>=` floor only — `uv lock` +# resolves at deploy time so each cluster run picks up the freshest stable +# wheel from PyPI. NEVER pin upper bounds here; the bench is intentionally +# a moving target. +# +# The blessed Starlette fast-path: +# * Plain `Starlette(routes=[...])` with `async def` handlers returning +# pre-baked `Response(bytes, media_type=...)` — no JSONResponse +# re-encoding, no FastAPI DI. Sync handlers would be punted to a +# threadpool which would distort the cell. +# * `uvicorn[standard]` — pulls in uvloop, httptools, watchfiles, +# websockets in one extra so we never hand-list the C accelerators. +# * `orjson` — used only by app/payload.py to generate the deterministic +# 1k/8k/16k/64k bodies byte-identically to the Go reference (and to the +# fastapi adapter). Not on the request hot path. +# +# NO docker. The ansible role builds a venv natively under +# {{ bench_root }}/python-venvs/starlette via `uv venv` + `uv pip install`. +# +# Run command (the launcher script in this directory wraps it): +# -engine h1 (or absent): HTTP/1.1 fast path +# uvicorn app.server:app --host 127.0.0.1 --port

\ +# --workers $(nproc) --loop uvloop --http httptools \ +# --no-access-log --log-level warning +# -engine h2c: HTTP/2 cleartext, prior-knowledge, no TLS +# python -m app.server -bind 127.0.0.1:

-engine h2c +# (launches the same ASGI app under hypercorn; uvicorn has no HTTP/2.) +# +# `--workers` only takes effect outside reload mode (which we never enable), +# so the launcher hard-codes a non-reload uvicorn invocation for h1. + +[project] +name = "probatorium-starlette-adapter" +version = "0.0.0" +description = "Starlette/uvicorn/uvloop/httptools adapter for the probatorium contract." +requires-python = ">=3.13" +dependencies = [ + "starlette>=0.40", + "uvicorn[standard]>=0.32", + "orjson>=3.10", + # `-engine h2c` only. uvicorn cannot speak HTTP/2, so the h2c column + # runs the same ASGI app under hypercorn, which serves HTTP/2 cleartext + # prior-knowledge on a plain (no-TLS) bind. `-engine h1` never imports + # it. hypercorn's HTTP/2 stack (h2, hpack, priority) is a core dep, not + # an extra — plain `hypercorn` already pulls it. uvloop comes from + # uvicorn[standard] above, so no `[uvloop]` extra is needed here. + "hypercorn>=0.17", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] diff --git a/servers/uws/package.json b/servers/uws/package.json new file mode 100644 index 0000000..0110fe6 --- /dev/null +++ b/servers/uws/package.json @@ -0,0 +1,16 @@ +{ + "name": "probatorium-uws-server", + "private": true, + "type": "module", + "//purpose": "uWebSockets.js adapter — the JavaScript HTTP speed leader. A thin Node N-API binding over the µWS C++ server. NO web framework, NO bundler: uWebSockets.js ships a prebuilt native .node addon that uws.js require()s at runtime, so the source must run from node_modules (it cannot be esbuild/bun-bundled into a single file). Hence: node server.js, not a bundle.", + "//dep": "uWebSockets.js is NOT on the npm registry under a usable name — the canonical install is the GitHub tarball dependency 'uNetworking/uWebSockets.js#'. npm fetches the repo at that tag; the prebuilt uws___.node binaries are committed in the repo, so `npm install` performs NO native compile (no node-gyp). Supports Node 22/24/26 on glibc Linux, macOS, Windows.", + "//versions": "Pinned to a release tag for reproducible ABI matching (the prebuilt addon must match the bench host's Node ABI; a moving HEAD could ship an addon that lags a brand-new Node). Bump the tag to track upstream — the bench host's Node major (22/24/26) must be one uWS publishes a prebuilt for.", + "//run": "node server.js -bind {bind}. The launcher script the ansible build step writes execs `node

/src/server.js \"$@\"` so the runner's -bind/-engine flags reach process.argv.", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js" + }, + "dependencies": { + "uWebSockets.js": "uNetworking/uWebSockets.js#v20.68.0" + } +} diff --git a/servers/uws/src/payload.js b/servers/uws/src/payload.js new file mode 100644 index 0000000..55e35d0 --- /dev/null +++ b/servers/uws/src/payload.js @@ -0,0 +1,88 @@ +// Deterministic 1/8/16/64 KiB JSON payload generator. +// +// Byte-identical port of probatorium/servers/common/payload.go. The Go +// reference uses encoding/json on (paginatedResponse, paginatedItem), +// which emits compact JSON with field order matching struct declaration +// order: page, per_page, total, total_pages, data for the wrapper, and +// id, name, email, status, created_at per item. +// +// We emit the bytes by hand rather than via JSON.stringify on a JS +// object. Reasons: +// +// 1. Byte-for-byte equivalence with the Go reference is a hard +// conformance requirement (cmd/conformance does bytes.Equal). +// JSON.stringify and encoding/json agree on most things but offer +// no formal cross-language guarantee, so we own the bytes. +// 2. The corpus is tiny and pure-ASCII (no escape hazards), so the +// manual path is correct and trivially auditable. +// 3. Generated once at startup and reused for every request, so the +// build cost is irrelevant. +// +// Termination rule from the Go reference: append items until the +// marshalled length is at least targetSize. Resulting sizes: +// 1 KiB target -> 1026 bytes ending at item 9 +// 8 KiB target -> 8286 bytes ending at item 75 +// 16 KiB target -> 16463 bytes ending at item 147 +// 64 KiB target -> 65618 bytes ending at item 583 + +const HEADER = '{"page":1,"per_page":50,"total":1000,"total_pages":20,"data":['; +const FOOTER = "]}"; + +let json1k; +let json8k; +let json16k; +let json64k; + +export function json1KPayload() { + if (!json1k) json1k = generate(1024); + return json1k; +} + +export function json8KPayload() { + if (!json8k) json8k = generate(8192); + return json8k; +} + +export function json16KPayload() { + if (!json16k) json16k = generate(16384); + return json16k; +} + +export function json64KPayload() { + if (!json64k) json64k = generate(65536); + return json64k; +} + +function generate(targetSize) { + // Emit straight into a string then encode once at the end. Match the + // Go termination rule: stop once buf + footer length is at least + // targetSize. The Go code does a full Marshal per iteration which is + // equivalent — the wrapper struct serialises to a fixed footer length, + // so length(buf) + length(footer) is exact. + let buf = HEADER; + let i = 1; + for (;;) { + if (i > 1) buf += ","; + buf += item(i); + if (buf.length + FOOTER.length >= targetSize) break; + i += 1; + } + buf += FOOTER; + // Pure-ASCII so byte length == char length; Buffer is what uWS.write/ + // end consume most efficiently (a RecognizedString accepts Buffers). + return Buffer.from(buf, "utf8"); +} + +function item(n) { + // Order MUST match the Go struct declaration exactly: + // id, name, email, status, created_at. + return ( + '{"id":' + + n + + ',"name":"User ' + + n + + '","email":"user' + + n + + '@example.com","status":"active","created_at":"2024-01-15T09:30:00Z"}' + ); +} diff --git a/servers/uws/src/server.js b/servers/uws/src/server.js new file mode 100644 index 0000000..323cf96 --- /dev/null +++ b/servers/uws/src/server.js @@ -0,0 +1,345 @@ +// Probatorium uWebSockets.js adapter (Node.js, the JS speed leader). +// +// uWebSockets.js (uNetworking/uWebSockets.js) is a thin Node N-API +// binding over the µWS C++ HTTP server — the fastest HTTP path reachable +// from JavaScript. The npm package ships a prebuilt `.node` addon per +// (platform, arch, Node-ABI); there is NO native compile step (no +// node-gyp), uws.js just `require`s the matching prebuilt. Supported on +// glibc Linux / macOS / Windows for Node 22, 24, 26. +// +// Serves the canonical contract endpoints declared in +// servers/common/contract.go: +// +// GET / -> "Hello, World!" text/plain +// GET /json -> {"message":"Hello, World!"} application/json +// GET /json-1k -> deterministic 1026-byte JSON page +// GET /json-8k -> deterministic 8286-byte JSON page +// GET /json-16k -> deterministic 16463-byte JSON page +// GET /json-64k -> deterministic 65618-byte JSON page +// GET /users/:id -> "User ID: " text/plain +// POST /upload -> read-and-discard body, reply "OK" text/plain +// +// Driver-backed (/db, /cache, /mc, /session) and chain-* endpoints are +// OUT OF SCOPE for this adapter — Capabilities declares Static only, so +// the scheduler never sends those scenarios here. +// +// CLI contract (matched by every probatorium adapter): +// -bind default 0.0.0.0:8080. Pass `:0` (or `host:0`) +// to let the kernel assign a port; the resolved +// address is echoed via the "ready addr=..." line. +// -engine "h1" (or absent) → HTTP/1.1. uWS's App speaks +// HTTP/1.1 only (no cleartext-h2c prior-knowledge in +// its public JS API), so h1 is the only supported +// engine. Any other value exits non-zero. +// stdout "ready addr=" once listening +// (emitted ONCE by the primary, never per worker). +// SIGTERM/SIGINT graceful shutdown (close the listen socket). +// +// Multi-core: uWS's App is single-threaded (one Node event loop). The +// documented Linux fast path for multi-core is to listen to the SAME +// port from N processes — uWS's listen() sets SO_REUSEPORT so the kernel +// load-balances accepts across them (mirrors the fastapi tier's +// `uvicorn --workers $(nproc)` and the hono tier's `reusePort: true`). +// We fork one worker per logical CPU via node:cluster; each worker builds +// its own App and listens on the fixed port. The primary forks, waits for +// the first worker to report "listening", then prints the single ready +// line. Worker count is overridable via UWS_WORKERS (0/1 ⇒ single +// process; useful for local dev and the port-0 path below). +// +// Port 0 (kernel-assigned, used only for local testing — the bench always +// passes a fixed port) forces SINGLE-PROCESS: SO_REUSEPORT with port 0 +// would hand each worker a DIFFERENT ephemeral port, so one ready line +// could not describe all workers. The fixed-port bench path is unaffected. +// +// uWS handler discipline encoded below: +// * Synchronous handlers (every GET here) build the whole response in +// one res.cork(...) so the status line, headers and body coalesce +// into a single write — µWS's documented fast path. +// * The async handler (/upload, which awaits the request body) MUST +// register res.onAborted BEFORE the first await: if the client +// disconnects mid-body, µWS invalidates `res` and any later use is +// undefined behaviour. The aborted flag guards the late end(). +// * res.getParameter(0) reads the :id capture for /users/:id. + +import cluster from "node:cluster"; +import { availableParallelism } from "node:os"; + +import { + App, + us_socket_local_port, + us_listen_socket_close, +} from "uWebSockets.js"; + +import { + json1KPayload, + json8KPayload, + json16KPayload, + json64KPayload, +} from "./payload.js"; + +// Pre-encoded static bodies. µWS's write/end accept a "RecognizedString" +// (string | ArrayBuffer | TypedArray); Buffers qualify and avoid a +// per-request UTF-8 encode. Built once at startup. +const HELLO = Buffer.from("Hello, World!", "utf8"); +const JSON_HELLO = Buffer.from('{"message":"Hello, World!"}', "utf8"); +const OK = Buffer.from("OK", "utf8"); +const JSON_1K = json1KPayload(); +const JSON_8K = json8KPayload(); +const JSON_16K = json16KPayload(); +const JSON_64K = json64KPayload(); + +const TEXT = "text/plain"; +const JSON_CT = "application/json"; + +// writeStatic emits a 200 with the given content-type + body in one cork +// so µWS batches the whole response into a single send. µWS sets +// Content-Length automatically from end()'s body, so we do not write it +// by hand (writing it ourselves would double the header). +function writeStatic(res, contentType, body) { + res.cork(() => { + res.writeHeader("Content-Type", contentType).end(body); + }); +} + +// buildApp wires the canonical contract routes onto a fresh µWS App. +// Called once per process (the primary never builds one; each worker — +// or the single process in the port-0 / UWS_WORKERS<=1 path — does). +function buildApp() { + const app = App(); + + app.get("/", (res) => { + writeStatic(res, TEXT, HELLO); + }); + + app.get("/json", (res) => { + writeStatic(res, JSON_CT, JSON_HELLO); + }); + + app.get("/json-1k", (res) => { + writeStatic(res, JSON_CT, JSON_1K); + }); + + app.get("/json-8k", (res) => { + writeStatic(res, JSON_CT, JSON_8K); + }); + + app.get("/json-16k", (res) => { + writeStatic(res, JSON_CT, JSON_16K); + }); + + app.get("/json-64k", (res) => { + writeStatic(res, JSON_CT, JSON_64K); + }); + + // /users/:id — the one parametrised route. getParameter(0) returns the + // first path capture (the :id segment). Echo it verbatim, matching + // WritePath in servers/common/common.go ("User ID: "). This handler + // reads req synchronously (req is invalid after the handler returns), so + // the body is built inside the same tick — no onAborted needed. + app.get("/users/:id", (res, req) => { + const id = req.getParameter(0); + writeStatic(res, TEXT, "User ID: " + id); + }); + + // POST /upload — drain-and-discard the request body, reply "OK". This is + // the only async path: µWS streams the body via onData and may invalidate + // `res` if the client aborts, so onAborted is registered FIRST and the + // flag is checked before the terminal end(). + app.post("/upload", (res) => { + let aborted = false; + res.onAborted(() => { + aborted = true; + }); + // We discard every chunk; isLast signals the final piece. The chunk + // ArrayBuffer is neutered on return from onData, but since we keep + // nothing there is no copy to make. + res.onData((_chunk, isLast) => { + if (isLast && !aborted) { + res.cork(() => { + res.writeHeader("Content-Type", TEXT).end(OK); + }); + } + }); + }); + + // Catch-all for unmatched (method, path) pairs. µWS has no automatic + // 404/405; any('/*', ...) handles every method on every otherwise + // unrouted path. The contract never exercises this (loadgen only dials + // declared routes), but a clean 404 beats a hung socket if it does. + app.any("/*", (res) => { + res.cork(() => { + res.writeStatus("404 Not Found").end("Not Found"); + }); + }); + + return app; +} + +const { host, port } = parseBind(process.argv); +const engine = parseEngine(process.argv); +if (engine !== "h1") { + // uWS's App is HTTP/1.1-only; h2c is not supported. Fail loudly rather + // than silently serving h1 under an h2c column. + process.stderr.write( + `uws: unsupported -engine ${JSON.stringify(engine)} (supported: h1)\n`, + ); + process.exit(2); +} + +// workerCount decides how many processes listen on the port. Default is +// one per logical CPU (uWS's documented Linux SO_REUSEPORT multi-core +// path). UWS_WORKERS overrides it. Port 0 pins to a single process so the +// one ready line describes the one (kernel-assigned) port — see the +// header note. +function workerCount() { + const env = process.env.UWS_WORKERS; + if (env !== undefined && env !== "") { + const n = Number(env); + if (Number.isInteger(n) && n > 0) return n; + } + return Math.max(1, availableParallelism()); +} + +const workers = port === 0 ? 1 : workerCount(); + +if (workers > 1 && cluster.isPrimary) { + runPrimary(workers); +} else { + runWorker(); +} + +// runPrimary forks `n` workers and emits the SINGLE ready line once the +// first worker reports it is listening (every worker binds the same fixed +// port via SO_REUSEPORT, so one line describes them all). It owns no +// listen socket itself. +function runPrimary(n) { + let announced = false; + let terminating = false; + for (let i = 0; i < n; i++) cluster.fork(); + + cluster.on("message", (_worker, msg) => { + if (msg && msg.type === "listening" && !announced) { + announced = true; + // The runner's TCP probe waits for this exact line on stdout. + process.stdout.write(`ready addr=${host}:${port}\n`); + } + }); + + // If a worker dies before any has announced, the bind failed for all — + // surface it and exit non-zero so the cell fails fast instead of hanging + // on a ready line that never comes. Ignored once we are intentionally + // tearing down (workers exit by our own SIGTERM then). + cluster.on("exit", (_worker, code) => { + if (!terminating && !announced && code !== 0) { + process.stderr.write(`uws: worker exited (code ${code}) before listen\n`); + process.exit(1); + } + }); + + // Forward termination to the workers, then exit once they are gone. + function shutdownPrimary(signal) { + if (terminating) return; + terminating = true; + process.stderr.write(`uws: received ${signal}, shutting down\n`); + for (const id in cluster.workers) cluster.workers[id]?.kill("SIGTERM"); + // Backstop: exit even if a worker is wedged. The runner's SIGKILL is + // the outer bound; this keeps us well inside the grace window. + setTimeout(() => process.exit(0), 200); + } + process.on("SIGTERM", () => shutdownPrimary("SIGTERM")); + process.on("SIGINT", () => shutdownPrimary("SIGINT")); +} + +// runWorker builds the App, listens, and (single-process path) prints the +// ready line directly; under cluster it instead messages the primary so +// the announcement fires exactly once. +function runWorker() { + const app = buildApp(); + let listenSocket = null; + + // listen(host, port, cb): the callback's token is falsy on bind failure. + // On success, us_socket_local_port resolves the kernel-assigned port + // (when the caller passed 0), which we report in the ready line. + app.listen(host, port, (token) => { + if (!token) { + process.stderr.write(`uws: bind ${host}:${port} failed\n`); + process.exit(1); + return; + } + listenSocket = token; + if (cluster.isPrimary) { + // Single-process path (workers<=1 or port 0): print directly. + const boundPort = us_socket_local_port(token); + process.stdout.write(`ready addr=${host}:${boundPort}\n`); + } else { + // Clustered worker: the primary owns the single ready line. We bind a + // fixed port (SO_REUSEPORT), so the primary already knows it. + process.send?.({ type: "listening" }); + } + }); + + // Graceful shutdown: closing the listen socket stops accepting new + // connections and lets µWS's loop drain. The runner's SIGKILL backstop is + // the upper bound, so we exit promptly after closing. + function shutdownWorker(signal) { + process.stderr.write(`uws: received ${signal}, shutting down\n`); + if (listenSocket) { + us_listen_socket_close(listenSocket); + listenSocket = null; + } + // Give µWS's loop a tick to flush in-flight writes, then exit. + setTimeout(() => process.exit(0), 50); + } + process.on("SIGTERM", () => shutdownWorker("SIGTERM")); + process.on("SIGINT", () => shutdownWorker("SIGINT")); +} + +// parseBind walks argv for the canonical -bind flag. Falls back to the +// BIND env var, then 0.0.0.0:8080. Accepts both `-bind 0.0.0.0:0` +// and `-bind=0.0.0.0:0`. Returns {host, port} for app.listen. +function parseBind(argv) { + let raw = process.env.BIND ?? "0.0.0.0:8080"; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === undefined) continue; + if (a === "-bind" || a === "--bind") { + const v = argv[i + 1]; + if (v !== undefined) raw = v; + break; + } + if (a.startsWith("-bind=") || a.startsWith("--bind=")) { + raw = a.slice(a.indexOf("=") + 1); + break; + } + } + const idx = raw.lastIndexOf(":"); + if (idx < 0) { + throw new Error(`uws: invalid -bind value ${JSON.stringify(raw)}`); + } + const host = raw.slice(0, idx); + const port = Number(raw.slice(idx + 1)); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`uws: invalid port in -bind ${JSON.stringify(raw)}`); + } + return { host, port }; +} + +// parseEngine walks argv for -engine (accepts `-engine h1` and +// `-engine=h1`). Absent/empty defaults to "h1". Validation of the value +// against what uWS can serve happens at the call site. +function parseEngine(argv) { + let raw = ""; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === undefined) continue; + if (a === "-engine" || a === "--engine") { + raw = argv[i + 1] ?? ""; + break; + } + if (a.startsWith("-engine=") || a.startsWith("--engine=")) { + raw = a.slice(a.indexOf("=") + 1); + break; + } + } + return raw === "" ? "h1" : raw; +} diff --git a/servers/vertx/.gitignore b/servers/vertx/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/servers/vertx/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/servers/vertx/pom.xml b/servers/vertx/pom.xml new file mode 100644 index 0000000..6d243c8 --- /dev/null +++ b/servers/vertx/pom.xml @@ -0,0 +1,161 @@ + + + + 4.0.0 + + io.probatorium + probatorium-vertx-adapter + 0.0.0 + jar + + + 21 + UTF-8 + + 5.0.0 + + probatorium.vertx.Server + + probatorium-vertx-server + + + + + io.vertx + vertx-core + ${vertx.version} + + + io.vertx + vertx-web + ${vertx.version} + + + + io.netty + netty-transport-native-epoll + ${netty.epoll.version} + ${netty.epoll.classifier} + + + + io.netty + netty-transport-classes-epoll + ${netty.epoll.version} + + + + + ${finalName} + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + false + + + + ${main.class} + + + + + *:* + + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + module-info.class + + + + + + + + + + + + + + default-netty + + true + + + + 4.2.0.Final + linux-x86_64 + + + + diff --git a/servers/vertx/src/main/java/probatorium/vertx/Payload.java b/servers/vertx/src/main/java/probatorium/vertx/Payload.java new file mode 100644 index 0000000..ed5f8d4 --- /dev/null +++ b/servers/vertx/src/main/java/probatorium/vertx/Payload.java @@ -0,0 +1,79 @@ +package probatorium.vertx; + +import io.vertx.core.buffer.Buffer; +import java.nio.charset.StandardCharsets; + +/** + * Deterministic 1k / 8k / 16k / 64k JSON payload generator. + * + *

Byte-identical port of {@code probatorium/servers/common/payload.go}. The + * Go reference marshals a {@code (paginatedResponse, paginatedItem)} struct pair + * with {@code encoding/json}, which emits compact JSON in struct-declaration + * field order: {@code page, per_page, total, total_pages, data} for the wrapper + * and {@code id, name, email, status, created_at} per item. + * + *

We emit the bytes by hand rather than via a JSON library: byte-for-byte + * equivalence with the Go reference is a hard conformance requirement + * (cmd/conformance does a bytes-equal compare) and the corpus is tiny, pure + * ASCII, and generated once at startup — so the manual path is both correct and + * trivially auditable, with no risk of a Jackson/JSON-B formatting drift. + * + *

The Go termination rule is "append items until the marshalled length + * crosses targetSize"; we mirror it exactly. Resulting sizes match the Go + * reference and every other adapter: + *

+ *   1k  -> 1026  bytes   8k -> 8286  bytes
+ *   16k -> 16463 bytes  64k -> 65618 bytes
+ * 
+ */ +final class Payload { + + static final Buffer JSON_1K = generate(1024); + static final Buffer JSON_8K = generate(8192); + static final Buffer JSON_16K = generate(16384); + static final Buffer JSON_64K = generate(65536); + + private Payload() {} + + /** Builds a paginated-response payload of at least {@code targetSize} bytes. */ + private static Buffer generate(int targetSize) { + // Constant prefix + footer — identical for every payload size. + byte[] header = + "{\"page\":1,\"per_page\":50,\"total\":1000,\"total_pages\":20,\"data\":[" + .getBytes(StandardCharsets.US_ASCII); + byte[] footer = "]}".getBytes(StandardCharsets.US_ASCII); + + StringBuilder sb = new StringBuilder(targetSize + 256); + sb.append(new String(header, StandardCharsets.US_ASCII)); + + long i = 1; + while (true) { + if (i > 1) { + sb.append(','); + } + appendItem(sb, i); + // Tentative size = current length + footer. Whole corpus is ASCII so + // String length == byte length; this matches Go's "marshal then measure" + // predicate without re-encoding on every iteration. + if (sb.length() + footer.length >= targetSize) { + break; + } + i++; + } + sb.append("]}"); + return Buffer.buffer(sb.toString().getBytes(StandardCharsets.US_ASCII)); + } + + /** + * Writes one paginatedItem in the exact byte form encoding/json produces: + * {@code {"id":,"name":"User ","email":"user@example.com", + * "status":"active","created_at":"2024-01-15T09:30:00Z"}}. + */ + private static void appendItem(StringBuilder sb, long n) { + sb.append("{\"id\":").append(n) + .append(",\"name\":\"User ").append(n) + .append("\",\"email\":\"user").append(n) + .append("@example.com\",\"status\":\"active\",") + .append("\"created_at\":\"2024-01-15T09:30:00Z\"}"); + } +} diff --git a/servers/vertx/src/main/java/probatorium/vertx/Server.java b/servers/vertx/src/main/java/probatorium/vertx/Server.java new file mode 100644 index 0000000..f660d71 --- /dev/null +++ b/servers/vertx/src/main/java/probatorium/vertx/Server.java @@ -0,0 +1,310 @@ +// probatorium Eclipse Vert.x adapter. +// +// Serves the canonical contract endpoints declared in +// servers/common/contract.go: +// +// GET / -> "Hello, World!" text/plain +// GET /json -> {"message":"Hello, World!"} application/json +// GET /json-1k -> deterministic 1026-byte JSON page +// GET /json-8k -> deterministic 8286-byte JSON page +// GET /json-16k -> deterministic 16463-byte JSON page +// GET /json-64k -> deterministic 65618-byte JSON page +// GET /users/:id -> "User ID: " text/plain +// POST /upload -> read-and-discard body, reply "OK" text/plain +// +// Driver-backed (/db, /cache, /mc, /session) and chain-* endpoints are OUT +// of scope for this adapter; the scenario applicability filter +// (servers/servers.go) never schedules them against a Static-only column, so +// the 404 they return here is never observed by loadgen. +// +// CLI (matches the Go / Rust / Python adapters so the runner invokes this +// binary identically): +// +// -bind default 127.0.0.1:8080. Pass `:0` (or `host:0`) to let +// the kernel allocate a port; the bound address is then +// reported on stdout via the `ready addr=` line the +// runner waits for before opening loadgen. +// -engine default "h1". One of: +// h1 — strict HTTP/1.1. HTTP/2 cleartext is disabled +// on the listener (setHttp2ClearTextEnabled(false)) +// so an h2 preface gets no h2 service, mirroring +// the strict-h1 behaviour of the Rust adapters. +// h2c — HTTP/2 cleartext, prior-knowledge. Vert.x's +// HTTP server speaks h2c out of the box on a +// plaintext socket (no TLS, no ALPN); a client +// that opens with the h2 connection preface is +// served HTTP/2 directly. Matches the h2c-noupg +// convention (no h1->h2 Upgrade negotiation). +// Unknown values exit non-zero. +// +// Scaling: one HttpServerVerticle per event loop. The first instance binds +// the (resolved) port and reports readiness; the remaining instances reuse +// the same host/port — on Linux native epoll with SO_REUSEPORT each gets its +// own acceptor, otherwise Vert.x round-robins accepted connections across the +// event loops internally. +// +// Lifecycle: SIGTERM / SIGINT trigger vertx.close(), which drains in-flight +// requests and closes the listeners well inside the runner's 5-second grace +// window (servers/start.go) before its SIGKILL fallback. + +package probatorium.vertx; + +import io.vertx.core.AbstractVerticle; +import io.vertx.core.DeploymentOptions; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.core.VertxOptions; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.BodyHandler; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; + +public final class Server { + + // Static byte payloads, hoisted so every request reuses one immutable + // Buffer — no per-request allocation, mirroring the Go adapters that serve a + // pre-baked slice. Vert.x writes these verbatim with an auto-computed + // Content-Length and never appends a charset to the Content-Type, so the + // bytes are identical to common.Endpoints[*].ResponseBody. + private static final Buffer HELLO_PLAIN = + Buffer.buffer("Hello, World!".getBytes(StandardCharsets.US_ASCII)); + private static final Buffer HELLO_JSON = + Buffer.buffer("{\"message\":\"Hello, World!\"}".getBytes(StandardCharsets.US_ASCII)); + private static final Buffer OK_PLAIN = + Buffer.buffer("OK".getBytes(StandardCharsets.US_ASCII)); + + private static final String CT_TEXT = "text/plain"; + private static final String CT_JSON = "application/json"; + + private Server() {} + + public static void main(String[] args) { + String bind = "127.0.0.1:8080"; + String engine = "h1"; + for (int i = 0; i < args.length; i++) { + String a = args[i]; + if ((a.equals("-bind") || a.equals("--bind")) && i + 1 < args.length) { + bind = args[++i]; + } else if (a.startsWith("-bind=")) { + bind = a.substring("-bind=".length()); + } else if (a.startsWith("--bind=")) { + bind = a.substring("--bind=".length()); + } else if ((a.equals("-engine") || a.equals("--engine")) && i + 1 < args.length) { + engine = args[++i]; + } else if (a.startsWith("-engine=")) { + engine = a.substring("-engine=".length()); + } else if (a.startsWith("--engine=")) { + engine = a.substring("--engine=".length()); + } + } + + final boolean h2c; + switch (engine) { + case "h1": + h2c = false; + break; + case "h2c": + h2c = true; + break; + default: + System.err.println("vertx: unknown -engine \"" + engine + "\" (want h1|h2c)"); + System.exit(1); + return; + } + + String host; + int port; + try { + int[] hp = new int[1]; + host = parseHost(bind, hp); + port = hp[0]; + } catch (RuntimeException e) { + System.err.println("vertx: bad -bind \"" + bind + "\": " + e.getMessage()); + System.exit(1); + return; + } + + // Prefer the native (epoll on Linux) transport so each verticle binds the + // shared port with SO_REUSEPORT; harmless on platforms without it (Vert.x + // silently falls back to NIO). + Vertx vertx = Vertx.vertx(new VertxOptions().setPreferNativeTransport(true)); + + int instances = Math.max(1, Runtime.getRuntime().availableProcessors()); + + // The verticle deployment is async; block main() until either the first + // server reports its bound port (success) or a verticle fails to start. + CountDownLatch ready = new CountDownLatch(1); + AtomicInteger boundPort = new AtomicInteger(-1); + + DeploymentOptions opts = + new DeploymentOptions().setInstances(instances); + + vertx + .deployVerticle(() -> new HttpVerticle(host, port, h2c, boundPort, ready), opts) + .onFailure( + err -> { + System.err.println("vertx: deploy: " + err.getMessage()); + System.exit(1); + }); + + try { + ready.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + System.exit(1); + return; + } + + // The runner's TCP probe waits for this exact line on stdout. Print and + // flush before the JVM settles into the reactor so the probe never races + // the listener. + System.out.println("ready addr=" + host + ":" + boundPort.get()); + System.out.flush(); + + // Graceful shutdown: SIGTERM/SIGINT -> vertx.close() drains and exits. + Runtime.getRuntime() + .addShutdownHook( + new Thread( + () -> { + CountDownLatch closed = new CountDownLatch(1); + vertx.close().onComplete(ar -> closed.countDown()); + try { + closed.await(); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + })); + } + + /** + * One event-loop verticle: builds its own Router + HttpServer and binds the + * shared host/port. The first instance to bind records the actual port (for + * the :0 case) and releases the readiness latch; later instances reuse the + * resolved port so they all share one listening socket. + */ + static final class HttpVerticle extends AbstractVerticle { + private final String host; + private final int requestedPort; + private final boolean h2c; + private final AtomicInteger boundPort; + private final CountDownLatch ready; + + HttpVerticle( + String host, + int requestedPort, + boolean h2c, + AtomicInteger boundPort, + CountDownLatch ready) { + this.host = host; + this.requestedPort = requestedPort; + this.h2c = h2c; + this.boundPort = boundPort; + this.ready = ready; + } + + @Override + public void start(Promise startPromise) { + Router router = Router.router(vertx); + + // /upload reads-and-discards the body, so the body parser is part of the + // measured cost like every other adapter. BodyHandler is mounted ONLY on + // that route (a global BodyHandler would buffer bodies for every request, + // taxing the GET-heavy cells for nothing). File uploads are disabled so + // the handler never spills to disk (no `file-uploads/` dir side effect) + // and the body stays a discarded in-memory buffer. + router.post("/upload").handler(BodyHandler.create(false)); + + router.get("/").handler(ctx -> sendBuffer(ctx, CT_TEXT, HELLO_PLAIN)); + router.get("/json").handler(ctx -> sendBuffer(ctx, CT_JSON, HELLO_JSON)); + router.get("/json-1k").handler(ctx -> sendBuffer(ctx, CT_JSON, Payload.JSON_1K)); + router.get("/json-8k").handler(ctx -> sendBuffer(ctx, CT_JSON, Payload.JSON_8K)); + router.get("/json-16k").handler(ctx -> sendBuffer(ctx, CT_JSON, Payload.JSON_16K)); + router.get("/json-64k").handler(ctx -> sendBuffer(ctx, CT_JSON, Payload.JSON_64K)); + router + .get("/users/:id") + .handler( + ctx -> + sendBuffer( + ctx, + CT_TEXT, + Buffer.buffer( + ("User ID: " + ctx.pathParam("id")) + .getBytes(StandardCharsets.UTF_8)))); + router.post("/upload").handler(ctx -> sendBuffer(ctx, CT_TEXT, OK_PLAIN)); + + HttpServerOptions options = + new HttpServerOptions() + // SO_REUSEPORT so each verticle gets its own acceptor on the + // native epoll transport (ignored on NIO; harmless either way). + .setReusePort(true) + .setReuseAddress(true) + // h1 mode: refuse the h2 cleartext preface so the column is + // strictly HTTP/1.1 (mirrors the Rust adapters' strict h1). + // h2c mode: leave cleartext h2 enabled (the default) so a + // prior-knowledge h2 client is served HTTP/2 directly. + .setHttp2ClearTextEnabled(h2c); + + vertx + .createHttpServer(options) + .requestHandler(router) + // Bind the requested port for the first instance (may be 0); later + // instances bind the resolved port so they share the socket. + .listen(resolvedPort(), host) + .onSuccess( + server -> { + publishBound(server); + startPromise.complete(); + }) + .onFailure(startPromise::fail); + } + + // resolvedPort returns the port THIS instance should bind: the requested + // one until the first instance has published a concrete port, then that + // concrete port (so a :0 request lands every instance on the same kernel- + // assigned port). + private int resolvedPort() { + int p = boundPort.get(); + return p >= 0 ? p : requestedPort; + } + + private void publishBound(HttpServer server) { + // compareAndSet so only the first instance to bind wins the race to + // record the actual port and release the readiness latch. + if (boundPort.compareAndSet(-1, server.actualPort())) { + ready.countDown(); + } + } + } + + // sendBuffer writes a pre-built body with the exact content-type the contract + // demands. Vert.x sets Content-Length automatically for a non-chunked end() + // and never appends a charset to the header, keeping responses byte-identical + // across adapters. + private static void sendBuffer(RoutingContext ctx, String contentType, Buffer body) { + ctx.response().putHeader("content-type", contentType).end(body); + } + + // parseHost splits host:port into host + port (written into hp[0]). IPv6 + // literals are accepted in bracketed form ([::1]:8080). + private static String parseHost(String bind, int[] hp) { + if (bind.startsWith("[")) { + int rb = bind.indexOf(']'); + if (rb < 0 || rb + 2 > bind.length() || bind.charAt(rb + 1) != ':') { + throw new IllegalArgumentException("expected [host]:port"); + } + hp[0] = Integer.parseInt(bind.substring(rb + 2)); + return bind.substring(1, rb); + } + int c = bind.lastIndexOf(':'); + if (c <= 0 || c == bind.length() - 1) { + throw new IllegalArgumentException("expected host:port"); + } + hp[0] = Integer.parseInt(bind.substring(c + 1)); + return bind.substring(0, c); + } +} diff --git a/validation/refapp/auth_jwt_csrf/go.mod b/validation/refapp/auth_jwt_csrf/go.mod index 12dc19a..4a0f1aa 100644 --- a/validation/refapp/auth_jwt_csrf/go.mod +++ b/validation/refapp/auth_jwt_csrf/go.mod @@ -2,10 +2,10 @@ module github.com/goceleris/probatorium/validation/refapp/auth_jwt_csrf go 1.26.4 -require github.com/goceleris/celeris v1.4.15 +require github.com/goceleris/celeris v1.5.3 require ( - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect ) diff --git a/validation/refapp/auth_jwt_csrf/go.sum b/validation/refapp/auth_jwt_csrf/go.sum index d37fcb0..f317e88 100644 --- a/validation/refapp/auth_jwt_csrf/go.sum +++ b/validation/refapp/auth_jwt_csrf/go.sum @@ -1,8 +1,8 @@ -github.com/goceleris/celeris v1.4.15 h1:et6pbB6R07BB2Ml/98zE8p8d+AamBDM/0CkAfQGnlcE= -github.com/goceleris/celeris v1.4.15/go.mod h1:vEvLb5xgX0Is0O/VMIWAPikcwZ5ZIfdf+qWMXwgVqME= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +github.com/goceleris/celeris v1.5.3 h1:n3/2xAqwqyMG0c9g1bP+F2KdjZngCOgGFHup1f3fRY4= +github.com/goceleris/celeris v1.5.3/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= diff --git a/validation/refapp/auth_session_ratelimit/go.mod b/validation/refapp/auth_session_ratelimit/go.mod index d535497..49a7ceb 100644 --- a/validation/refapp/auth_session_ratelimit/go.mod +++ b/validation/refapp/auth_session_ratelimit/go.mod @@ -2,10 +2,10 @@ module github.com/goceleris/probatorium/validation/refapp/auth_session_ratelimit go 1.26.4 -require github.com/goceleris/celeris v1.4.15 +require github.com/goceleris/celeris v1.5.3 require ( - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect ) diff --git a/validation/refapp/auth_session_ratelimit/go.sum b/validation/refapp/auth_session_ratelimit/go.sum index d37fcb0..f317e88 100644 --- a/validation/refapp/auth_session_ratelimit/go.sum +++ b/validation/refapp/auth_session_ratelimit/go.sum @@ -1,8 +1,8 @@ -github.com/goceleris/celeris v1.4.15 h1:et6pbB6R07BB2Ml/98zE8p8d+AamBDM/0CkAfQGnlcE= -github.com/goceleris/celeris v1.4.15/go.mod h1:vEvLb5xgX0Is0O/VMIWAPikcwZ5ZIfdf+qWMXwgVqME= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +github.com/goceleris/celeris v1.5.3 h1:n3/2xAqwqyMG0c9g1bP+F2KdjZngCOgGFHup1f3fRY4= +github.com/goceleris/celeris v1.5.3/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= diff --git a/validation/refapp/driver_memcached/go.mod b/validation/refapp/driver_memcached/go.mod index b3525ec..856086e 100644 --- a/validation/refapp/driver_memcached/go.mod +++ b/validation/refapp/driver_memcached/go.mod @@ -2,10 +2,10 @@ module github.com/goceleris/probatorium/validation/refapp/driver_memcached go 1.26.4 -require github.com/goceleris/celeris v1.4.15 +require github.com/goceleris/celeris v1.5.3 require ( - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect ) diff --git a/validation/refapp/driver_memcached/go.sum b/validation/refapp/driver_memcached/go.sum index d37fcb0..f317e88 100644 --- a/validation/refapp/driver_memcached/go.sum +++ b/validation/refapp/driver_memcached/go.sum @@ -1,8 +1,8 @@ -github.com/goceleris/celeris v1.4.15 h1:et6pbB6R07BB2Ml/98zE8p8d+AamBDM/0CkAfQGnlcE= -github.com/goceleris/celeris v1.4.15/go.mod h1:vEvLb5xgX0Is0O/VMIWAPikcwZ5ZIfdf+qWMXwgVqME= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +github.com/goceleris/celeris v1.5.3 h1:n3/2xAqwqyMG0c9g1bP+F2KdjZngCOgGFHup1f3fRY4= +github.com/goceleris/celeris v1.5.3/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= diff --git a/validation/refapp/driver_postgres/go.mod b/validation/refapp/driver_postgres/go.mod index a339203..58f26e8 100644 --- a/validation/refapp/driver_postgres/go.mod +++ b/validation/refapp/driver_postgres/go.mod @@ -2,10 +2,10 @@ module github.com/goceleris/probatorium/validation/refapp/driver_postgres go 1.26.4 -require github.com/goceleris/celeris v1.4.15 +require github.com/goceleris/celeris v1.5.3 require ( - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect ) diff --git a/validation/refapp/driver_postgres/go.sum b/validation/refapp/driver_postgres/go.sum index d37fcb0..f317e88 100644 --- a/validation/refapp/driver_postgres/go.sum +++ b/validation/refapp/driver_postgres/go.sum @@ -1,8 +1,8 @@ -github.com/goceleris/celeris v1.4.15 h1:et6pbB6R07BB2Ml/98zE8p8d+AamBDM/0CkAfQGnlcE= -github.com/goceleris/celeris v1.4.15/go.mod h1:vEvLb5xgX0Is0O/VMIWAPikcwZ5ZIfdf+qWMXwgVqME= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +github.com/goceleris/celeris v1.5.3 h1:n3/2xAqwqyMG0c9g1bP+F2KdjZngCOgGFHup1f3fRY4= +github.com/goceleris/celeris v1.5.3/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= diff --git a/validation/refapp/driver_redis/go.mod b/validation/refapp/driver_redis/go.mod index b19ff18..8ef02bc 100644 --- a/validation/refapp/driver_redis/go.mod +++ b/validation/refapp/driver_redis/go.mod @@ -2,10 +2,10 @@ module github.com/goceleris/probatorium/validation/refapp/driver_redis go 1.26.4 -require github.com/goceleris/celeris v1.4.15 +require github.com/goceleris/celeris v1.5.3 require ( - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect ) diff --git a/validation/refapp/driver_redis/go.sum b/validation/refapp/driver_redis/go.sum index d37fcb0..f317e88 100644 --- a/validation/refapp/driver_redis/go.sum +++ b/validation/refapp/driver_redis/go.sum @@ -1,8 +1,8 @@ -github.com/goceleris/celeris v1.4.15 h1:et6pbB6R07BB2Ml/98zE8p8d+AamBDM/0CkAfQGnlcE= -github.com/goceleris/celeris v1.4.15/go.mod h1:vEvLb5xgX0Is0O/VMIWAPikcwZ5ZIfdf+qWMXwgVqME= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +github.com/goceleris/celeris v1.5.3 h1:n3/2xAqwqyMG0c9g1bP+F2KdjZngCOgGFHup1f3fRY4= +github.com/goceleris/celeris v1.5.3/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= diff --git a/validation/refapp/kitchen_sink/go.mod b/validation/refapp/kitchen_sink/go.mod index db1e936..48ca237 100644 --- a/validation/refapp/kitchen_sink/go.mod +++ b/validation/refapp/kitchen_sink/go.mod @@ -2,10 +2,10 @@ module github.com/goceleris/probatorium/validation/refapp/kitchen_sink go 1.26.4 -require github.com/goceleris/celeris v1.4.15 +require github.com/goceleris/celeris v1.5.3 require ( - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect ) diff --git a/validation/refapp/kitchen_sink/go.sum b/validation/refapp/kitchen_sink/go.sum index d37fcb0..f317e88 100644 --- a/validation/refapp/kitchen_sink/go.sum +++ b/validation/refapp/kitchen_sink/go.sum @@ -1,8 +1,8 @@ -github.com/goceleris/celeris v1.4.15 h1:et6pbB6R07BB2Ml/98zE8p8d+AamBDM/0CkAfQGnlcE= -github.com/goceleris/celeris v1.4.15/go.mod h1:vEvLb5xgX0Is0O/VMIWAPikcwZ5ZIfdf+qWMXwgVqME= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +github.com/goceleris/celeris v1.5.3 h1:n3/2xAqwqyMG0c9g1bP+F2KdjZngCOgGFHup1f3fRY4= +github.com/goceleris/celeris v1.5.3/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= diff --git a/validation/refapp/observability/go.mod b/validation/refapp/observability/go.mod index 5a9572b..0b97bb7 100644 --- a/validation/refapp/observability/go.mod +++ b/validation/refapp/observability/go.mod @@ -3,9 +3,9 @@ module github.com/goceleris/probatorium/validation/refapp/observability go 1.26.4 require ( - github.com/goceleris/celeris v1.4.15 - github.com/goceleris/celeris/middleware/metrics v1.4.15 - github.com/goceleris/celeris/middleware/otel v1.4.15 + github.com/goceleris/celeris v1.5.3 + github.com/goceleris/celeris/middleware/metrics v1.5.3 + github.com/goceleris/celeris/middleware/otel v1.5.3 github.com/prometheus/client_golang v1.23.2 ) @@ -16,14 +16,14 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.68.1 // indirect + github.com/prometheus/common v0.69.0 // indirect github.com/prometheus/procfs v0.16.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/validation/refapp/observability/go.sum b/validation/refapp/observability/go.sum index e7a5351..3341481 100644 --- a/validation/refapp/observability/go.sum +++ b/validation/refapp/observability/go.sum @@ -9,12 +9,12 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/goceleris/celeris v1.4.15 h1:et6pbB6R07BB2Ml/98zE8p8d+AamBDM/0CkAfQGnlcE= -github.com/goceleris/celeris v1.4.15/go.mod h1:vEvLb5xgX0Is0O/VMIWAPikcwZ5ZIfdf+qWMXwgVqME= -github.com/goceleris/celeris/middleware/metrics v1.4.15 h1:SRUpaHpi605Kr1n0NKlwFT9VOpI8KhckPhkwthdMqTA= -github.com/goceleris/celeris/middleware/metrics v1.4.15/go.mod h1:E5DOpm2RuTT4vpWYB3pswcn6lQdU/bMwwu125dQiR0M= -github.com/goceleris/celeris/middleware/otel v1.4.15 h1:I9Lq5KCxpPNIiWdcW/SFisZ1BeVXL2bJi5xUg77FlIw= -github.com/goceleris/celeris/middleware/otel v1.4.15/go.mod h1:PSR4LzDmaqvh6zsGJhFYonK3Ju1Gq8hNjskSItLCQjs= +github.com/goceleris/celeris v1.5.3 h1:n3/2xAqwqyMG0c9g1bP+F2KdjZngCOgGFHup1f3fRY4= +github.com/goceleris/celeris v1.5.3/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= +github.com/goceleris/celeris/middleware/metrics v1.5.3 h1:mCt0xzU29+NnwT71rW9PmUAwAEClg9+yUaqgggdqqeE= +github.com/goceleris/celeris/middleware/metrics v1.5.3/go.mod h1:CoMqyeSBZNAmKeaGGrFuBWsoKmiir8huzRj1Dcz5V1g= +github.com/goceleris/celeris/middleware/otel v1.5.3 h1:/uN0whKfMhM4S6ERrK4MM2zzZEZXmZZY5sQjoCSlPgg= +github.com/goceleris/celeris/middleware/otel v1.5.3/go.mod h1:4mUnMXf61RiwtpZcv1by2Ul8fjXNnJ66gNml3GulNec= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -27,8 +27,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY= -github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= +github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= +github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -49,12 +49,12 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/validation/refapp/static_swagger_proxy/go.mod b/validation/refapp/static_swagger_proxy/go.mod index 840853a..5b02a9b 100644 --- a/validation/refapp/static_swagger_proxy/go.mod +++ b/validation/refapp/static_swagger_proxy/go.mod @@ -2,10 +2,10 @@ module github.com/goceleris/probatorium/validation/refapp/static_swagger_proxy go 1.26.4 -require github.com/goceleris/celeris v1.4.15 +require github.com/goceleris/celeris v1.5.3 require ( - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect ) diff --git a/validation/refapp/static_swagger_proxy/go.sum b/validation/refapp/static_swagger_proxy/go.sum index d37fcb0..f317e88 100644 --- a/validation/refapp/static_swagger_proxy/go.sum +++ b/validation/refapp/static_swagger_proxy/go.sum @@ -1,8 +1,8 @@ -github.com/goceleris/celeris v1.4.15 h1:et6pbB6R07BB2Ml/98zE8p8d+AamBDM/0CkAfQGnlcE= -github.com/goceleris/celeris v1.4.15/go.mod h1:vEvLb5xgX0Is0O/VMIWAPikcwZ5ZIfdf+qWMXwgVqME= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +github.com/goceleris/celeris v1.5.3 h1:n3/2xAqwqyMG0c9g1bP+F2KdjZngCOgGFHup1f3fRY4= +github.com/goceleris/celeris v1.5.3/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= diff --git a/validation/runner_test.go b/validation/runner_test.go index c9ab2ba..26023c3 100644 --- a/validation/runner_test.go +++ b/validation/runner_test.go @@ -66,7 +66,7 @@ func TestWriteValidateResults_Tier1OnlyEmitsDocument(t *testing.T) { t.Fatalf("read result: %v", err) } for _, want := range []string{ - `"schema_version": "5.4"`, + `"schema_version": "5.5"`, `"host_arch_pair": "msa2-server-amd64"`, `"adv_sent": 100`, `"adv_wrong_accepted": 5`,