From c3a6c76727c94e994cc8af618e6583759acca8cc Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 29 Jun 2026 05:56:09 +0700 Subject: [PATCH 1/3] use asterisk as suffix --- .gitignore | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index be75a8f6f..d93742b90 100644 --- a/.gitignore +++ b/.gitignore @@ -16,15 +16,9 @@ frameworks/blitz/zig-linux-* frameworks/blitz/.zig-cache frameworks/fletch/.dart_tool/ !frameworks/hyperf/bin/ -frameworks/zix/.zig-cache -frameworks/zix/zig-out -frameworks/zix/vendor -frameworks/zix-grpc/.zig-cache -frameworks/zix-grpc/zig-out -frameworks/zix-grpc/vendor -frameworks/zix-ws/.zig-cache -frameworks/zix-ws/zig-out -frameworks/zix-ws/vendor +frameworks/zix*/.zig-cache +frameworks/zix*/zig-out +frameworks/zix*/vendor # IDE settings *.user From 0e0317feec076ecf81c5b99c9bfae8df52ea9837 Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 29 Jun 2026 06:31:35 +0700 Subject: [PATCH 2/3] zix 0.5.x-rc1 --- frameworks/zix/.gitignore | 4 + frameworks/zix/Dockerfile | 76 ++++++++-------- frameworks/zix/build.zig.zon | 2 +- frameworks/zix/meta.json | 6 +- frameworks/zix/src/dataset.zig | 2 +- frameworks/zix/src/main.zig | 158 +++++++++++++++++++++++---------- 6 files changed, 163 insertions(+), 85 deletions(-) create mode 100644 frameworks/zix/.gitignore diff --git a/frameworks/zix/.gitignore b/frameworks/zix/.gitignore new file mode 100644 index 000000000..595d20d1a --- /dev/null +++ b/frameworks/zix/.gitignore @@ -0,0 +1,4 @@ +.zig-cache +zig-out +zig-package +vendor diff --git a/frameworks/zix/Dockerfile b/frameworks/zix/Dockerfile index 692929f64..5d38f22db 100644 --- a/frameworks/zix/Dockerfile +++ b/frameworks/zix/Dockerfile @@ -5,8 +5,8 @@ ARG RETRY=6 ARG TARGETARCH ARG RETRY_DELAY=3 ARG ZIG_VERSION=0.16.0 -ARG ZIX_VERSION=0.4.x-rc3 -RUN apk add --no-cache ca-certificates curl git tar xz +ARG ZIX_VERSION=0.5.x-rc1 +RUN apk add --no-cache ca-certificates curl git tar xz openssl RUN set -eu; \ case "${TARGETARCH:-amd64}" in \ @@ -19,51 +19,55 @@ RUN set -eu; \ mv "/opt/zig-${ZIG_ARCH}-linux-${ZIG_VERSION}" /opt/zig ENV PATH="/opt/zig:${PATH}" -# Vendor zix X.Y.Z, separate layer so source-only rebuilds skip the fetch. -# The Http1 raw engine work this image needs (large-body drain plus the per-worker -# response cache used by the /json endpoint) must be present on the X.Y.Z branch. -# Four ordered attempts before giving up: curl the archive tarball from github then -# codeberg, then a shallow git clone from github then codeberg. The github archive -# redirects to codeload.github.com (which the benchmark runner may not resolve), so -# curl can fall through to the codeberg tarball, and git clone talks to github.com -# and codeberg.org directly as the deeper fallback. RETRY and RETRY_DELAY bound -# every attempt. +WORKDIR /src +COPY build.zig build.zig.zon ./ +COPY src ./src + +# Resolve zix with zig fetch, then build in the same layer. The mirror and version fill the +# build.zig.zon placeholder, zig fetch downloads it and prints the hash. Codeberg first, GitHub second. +# +# Add +aes+pclmul: x86_64_v3 omits AES-NI / PCLMUL, so zix TLS would compile the ~40x slower +# software AES-GCM. Every x86_64_v3 CPU has them, so it is safe. RUN set -eu; \ - fetch() { \ - rm -rf /src/vendor/zix; mkdir -p /src/vendor/zix; \ - curl -fsSL --retry ${RETRY} --retry-delay ${RETRY_DELAY} --retry-all-errors "$1" -o /tmp/zix.tar.gz \ - && tar -xz --strip-components=1 -C /src/vendor/zix -f /tmp/zix.tar.gz; \ - }; \ - clone() { \ - attempt=0; \ - while [ "${attempt}" -lt "${RETRY}" ]; do \ - rm -rf /src/vendor/zix; \ - git clone --depth 1 --branch "${ZIX_VERSION}" "$1" /src/vendor/zix && return 0; \ + cp build.zig.zon build.zig.zon.tmpl; \ + save_zix() { \ + base="$1"; \ + attempt=1; \ + while [ "${attempt}" -le "${RETRY}" ]; do \ + sed -e "s|{URL_ZIX_SOURCE}|${base}|g" -e "s|{ZIX_VERSION}|${ZIX_VERSION}|g" \ + build.zig.zon.tmpl > build.zig.zon; \ + if hash="$(zig fetch "${base}/${ZIX_VERSION}.tar.gz")"; then \ + awk -v h="${hash}" '{ print } /\.url = /{ print " .hash = \"" h "\"," }' \ + build.zig.zon > build.zig.zon.next; \ + mv build.zig.zon.next build.zig.zon; \ + return 0; \ + fi; \ + echo "zix: ${base} attempt ${attempt}/${RETRY} failed" >&2; \ attempt=$((attempt + 1)); \ - sleep "${RETRY_DELAY}"; \ + [ "${attempt}" -le "${RETRY}" ] && sleep "${RETRY_DELAY}"; \ done; \ return 1; \ }; \ - fetch "https://github.com/prothegee/zix/archive/refs/heads/${ZIX_VERSION}.tar.gz" \ - || { echo "FAILED: curl ${RETRY} times from github" >&2; \ - fetch "https://codeberg.org/prothegee/zix/archive/${ZIX_VERSION}.tar.gz" \ - || { echo "FAILED: curl ${RETRY} times from codeberg" >&2; \ - clone "https://github.com/prothegee/zix.git" \ - || { echo "FAILED: git clone ${RETRY} times from github" >&2; \ - clone "https://codeberg.org/prothegee/zix.git" \ - || { echo "FAILED: git clone ${RETRY} times from codeberg" >&2; exit 1; }; }; }; } - -WORKDIR /src -COPY build.zig build.zig.zon ./ -COPY src ./src -RUN set -eu; \ + save_zix "https://codeberg.org/prothegee/zix/archive" \ + || { echo "zix: codeberg exhausted ${RETRY} attempts, trying github" >&2; \ + save_zix "https://github.com/prothegee/zix/archive/refs/heads"; } \ + || { echo "zix: github exhausted ${RETRY} attempts" >&2; exit 1; }; \ + rm -f build.zig.zon.tmpl; \ case "${TARGETARCH:-amd64}" in \ amd64) ZIG_TARGET=x86_64-linux-musl; ZIG_CPU=x86_64_v3 ;; \ arm64) ZIG_TARGET=aarch64-linux-musl; ZIG_CPU=baseline ;; \ esac; \ - zig build -Dtarget="${ZIG_TARGET}" -Dcpu="${ZIG_CPU}" --release=fast + zig build -Dtarget="${ZIG_TARGET}" -Dcpu="${ZIG_CPU}+aes+pclmul+adx" --release=fast + +# Self-signed Ed25519 cert generated at image build, baked at /etc/zix-tls. Ed25519 handshake +RUN set -eu; \ + mkdir -p /etc/zix-tls; \ + openssl genpkey -algorithm ED25519 -out /etc/zix-tls/server.key; \ + openssl req -new -x509 -key /etc/zix-tls/server.key -out /etc/zix-tls/server.crt \ + -days 3650 -subj "/CN=localhost" FROM alpine:3.20 COPY --from=build /src/zig-out/bin/zix /zix +COPY --from=build /etc/zix-tls /etc/zix-tls EXPOSE 8080 ENTRYPOINT ["/zix"] diff --git a/frameworks/zix/build.zig.zon b/frameworks/zix/build.zig.zon index e490ba375..a6bde258d 100644 --- a/frameworks/zix/build.zig.zon +++ b/frameworks/zix/build.zig.zon @@ -10,7 +10,7 @@ }, .dependencies = .{ .zix = .{ - .path = "vendor/zix", + .url = "{URL_ZIX_SOURCE}/{ZIX_VERSION}.tar.gz", }, }, } diff --git a/frameworks/zix/meta.json b/frameworks/zix/meta.json index b031b6910..4ea7145b3 100644 --- a/frameworks/zix/meta.json +++ b/frameworks/zix/meta.json @@ -2,8 +2,8 @@ "display_name": "zix", "language": "Zig", "type": "engine", - "engine": "zix", - "description": "Zig HTTP/1.1 server on the zix.Http1 raw engine (no std.http). Shared-nothing: each worker runs its own SO_REUSEPORT multishot accept plus io_uring completion loop and owns its connections. The /json endpoint serves from the per-worker response cache, and request bodies larger than the read buffer are drained rather than buffered.", + "engine": "zix uring", + "description": "Zig HTTP/1.1 server on the zix.Http1 raw engine (no std.http). Shared-nothing: each worker owns its SO_REUSEPORT accept, io_uring loop, and connections. /json serves from a per-worker cache and gzips the body (json-comp) when the client sends Accept-Encoding: gzip. json-tls serves the same /json over TLS 1.3 on a separate port with a baked self-signed Ed25519 cert. Oversized request bodies are drained, not buffered.", "repo": "https://github.com/prothegee/zix", "enabled": true, "tests": [ @@ -11,6 +11,8 @@ "pipelined", "limited-conn", "json", + "json-comp", + "json-tls", "upload", "static" ], diff --git a/frameworks/zix/src/dataset.zig b/frameworks/zix/src/dataset.zig index 4d12be803..a8f5357d3 100644 --- a/frameworks/zix/src/dataset.zig +++ b/frameworks/zix/src/dataset.zig @@ -104,7 +104,7 @@ fn renderItemPrefix(buf: *std.ArrayList(u8), aa: std.mem.Allocator, obj: std.jso try buf.append(aa, ':'); try writeValue(buf, aa, kv.value_ptr.*); } - // Intentionally no closing `}` — caller appends `,"total":N}`. + // Intentionally no closing `}`: the caller appends `,"total":N}`. } fn writeValue(buf: *std.ArrayList(u8), aa: std.mem.Allocator, v: std.json.Value) !void { diff --git a/frameworks/zix/src/main.zig b/frameworks/zix/src/main.zig index 90fe1bd22..a2d2ea00b 100644 --- a/frameworks/zix/src/main.zig +++ b/frameworks/zix/src/main.zig @@ -2,17 +2,21 @@ //! //! zix HttpArena HTTP/1.1 entry point. //! -//! Intent: demonstrate zix.Http1 (EPOLL dispatch model) against the HttpArena -//! HTTP/1.1 benchmark suite (baseline, pipelined, short-lived). +//! Intent: demonstrate zix.Http1 (URING dispatch model) against the HttpArena +//! HTTP/1.1 benchmark suite (baseline, pipelined, limited-conn, json, json-comp, +//! upload, static). json-comp reuses the /json endpoint and gzips the body when +//! the client sends Accept-Encoding: gzip. +//! +//! json-tls runs a second Http1 server on H1TLS_PORT: https over TLS 1.3 with the baked Ed25519 +//! cert at /etc/zix-tls. TLS terminates in the per-core tls_mux (one worker per core via +//! SO_REUSEPORT, no thread-per-connection), its own perf band apart from the cleartext URING engine. //! //! Design choices: -//! - rawIntercept: called before any header parsing for each EPOLL request. -//! Handles /pipeline with zero parse overhead (direct byte-match + sink write), -//! direct byte-match before any parsing, avoiding the header scan loop. Routes that fall -//! through are handled by the Router dispatch with full parsing. +//! - rawIntercept: called before any header parsing on each request. Handles /pipeline by direct +//! byte-match then a raw write, skipping the header scan. Other routes fall through to the Router +//! dispatch with full parsing. //! - Router: comptime route table (StaticStringMap for EXACT, inline for PREFIX). -//! - PIPELINE_RESP: precomputed response bytes; one sink.append per request, no -//! header build overhead. +//! - PIPELINE_RESP: precomputed response bytes, written verbatim per request with no header build. const std = @import("std"); const zix = @import("zix"); @@ -24,19 +28,21 @@ const PORT: u16 = 8080; const LISTEN_IP: []const u8 = "::"; const DISPATCH_MODEL: zix.Http1.DispatchModel = .URING; const KERNEL_BACKLOG: u31 = 16 * 1024; -/// Per-machine tuning profile (ADR-041 increment 5). The dev box is 12 threads -/// / 32 GB (lean, memory-bound), the competition box is 64 cores / 251 GB -/// (throughput, RAM-abundant). Only the recv buffer differs: workers auto-scale -/// (WORKERS = 0), and the backlog and cache are already sized for both. Select -/// .throughput for the 64-core deployment. + +// json-tls: the https port and the baked Ed25519 cert / key paths (generated at +// image build, baked at /etc/zix-tls). Overridable via env so the same binary runs locally. +const H1TLS_PORT: u16 = 8081; +const TLS_CERT_DEFAULT: []const u8 = "/etc/zix-tls/server.crt"; +const TLS_KEY_DEFAULT: []const u8 = "/etc/zix-tls/server.key"; +/// Per-machine tuning profile (ADR-041 increment 5): .lean for the 12-thread / 32 GB dev box, +/// .throughput for the 64-core / 251 GB competition box. Only the recv buffer differs (workers, +/// backlog, and cache are already sized for both). Select .throughput for the 64-core deployment. const Profile = enum { lean, throughput }; -const PROFILE: Profile = .lean; +const PROFILE: Profile = .throughput; -/// Per-connection recv buffer, heap-allocated once at accept time. .lean keeps -/// 4 KiB to hold the working set small (benchmark requests are under 300 bytes, -/// and large upload bodies are drained by the engine in chunks, so only headers, -/// always < 4 KiB, must fit). .throughput raises it to 16 KiB for deeper -/// pipelined batches per recv, which the RAM-abundant box absorbs. +/// Per-connection recv buffer, allocated once at accept. .lean keeps 4 KiB (requests are under +/// 300 bytes and upload bodies are drained in chunks, so only headers must fit). .throughput +/// raises it to 16 KiB for deeper pipelined batches per recv. const MAX_RECV_BUF: usize = switch (PROFILE) { .lean => 4 * 1024, .throughput => 16 * 1024, @@ -44,11 +50,9 @@ const MAX_RECV_BUF: usize = switch (PROFILE) { const MAX_HEADERS: u8 = 16; const WORKERS: usize = 0; -// Response cache (ADR-036), used by the /json endpoint only. The /json body is -// deterministic in (count, m) and large enough to clear the cache crossover -// (~4 KiB), so a hit replays the full response with zero serialization. The -// other endpoints stay below the crossover (baseline, pipeline, upload) or use -// their own zero-copy sendfile cache (static), so none of them enable it. +// Response cache (ADR-036), /json only. The body is deterministic in (count, m) and clears the +// cache crossover (~4 KiB), so a hit replays the full response with zero serialization. Other +// endpoints stay below the crossover or use the sendfile cache (static). const CACHE_MAX_ENTRIES: u32 = 64; /// Per-slot cap. A /json/50 response is near 12 KiB, so 32 KiB leaves headroom. const CACHE_MAX_VALUE_BYTES: u32 = 32 * 1024; @@ -56,12 +60,16 @@ const CACHE_MAX_VALUE_BYTES: u32 = 32 * 1024; /// long TTL means each key is built once and replayed for the whole run. const CACHE_TTL_MS: u32 = 60 * 1000; +/// gzip output buffer size for the json-comp path. The largest JSON body (count 50) +/// is near 12 KiB, so 64 KiB covers the worst case even when the body barely shrinks. +const GZIP_OUT_SIZE: usize = 64 * 1024; + // Data directory, overridable via the ARENA_DATA env var (default /data, the // container mount point). Lets the same binary run against a local data tree. var g_static_base: []const u8 = "/data/static/"; var g_static_base_buf: [256]u8 = undefined; -// Per-worker scratch. The JSON body (count up to 50) tops out near 12 KiB; the +// Per-worker scratch. The JSON body (count up to 50) tops out near 12 KiB. The // assembled response (status line + headers + body) sits a little above it. threadlocal var json_body_buf: [32 * 1024]u8 = undefined; threadlocal var json_resp_buf: [32 * 1024]u8 = undefined; @@ -97,8 +105,7 @@ fn baselineHandler(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std. zix.Http1.writeSimple(fd, 200, "text/plain", out) catch {}; } -// Precomputed response for the pipeline endpoint: one memcpy per request into the -// response sink. No header build overhead on the hot path. +// Precomputed response for the pipeline endpoint: written verbatim per request, no header build. const PIPELINE_RESP: []const u8 = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nok"; // GET /pipeline : fixed tiny response, the pipelined-throughput endpoint. @@ -109,15 +116,10 @@ fn pipelineHandler(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std. zix.Http1.fdWriteAll(fd, PIPELINE_RESP) catch {}; } -// Raw-request interceptor for the EPOLL dispatch model. Called before any header -// parsing on each inbound request. Handles /pipeline with zero parse overhead: -// byte-matches the path directly on rem, then appends PIPELINE_RESP to the -// coalescing RespSink. Unknown -// routes return null and fall through to the Router dispatch with full parsing. -// -// This is intentional benchmark infrastructure, not general HTTP parsing. It -// exploits knowledge that /pipeline is always a bare GET with no body, so the -// consumed length is always header_end + 4 ("\r\n\r\n"). +// Raw-request interceptor (initRaw), called before header parsing on each request. Handles /pipeline +// with zero parse overhead: byte-matches the path on rem, writes PIPELINE_RESP, returns the consumed +// length (always header_end + 4, since /pipeline is a bare GET with no body). Unknown routes return +// null and fall through to the Router with full parsing. fn rawIntercept(rem: []const u8, header_end: usize, fd: std.posix.fd_t) ?usize { // Must start with "GET /p" to qualify for this fast path. if (rem.len < 24 or rem[0] != 'G' or rem[4] != '/' or rem[5] != 'p') return null; @@ -132,18 +134,28 @@ fn rawIntercept(rem: []const u8, header_end: usize, fd: std.posix.fd_t) ?usize { // GET /json/{count}?m=M : render count dataset items, total = price*qty*M. // -// Response-cache aware. The body is deterministic in (count, m) and big enough -// to clear the cache crossover, so the full response is the ideal cache value. -// The cache key is hash(method, path, query), so every distinct /json/{count}?m=M -// caches under its own slot. A hit skips the whole build loop and replays the -// stored bytes; a miss builds the response and stores it on the way out. When -// the cache is disabled or full the path still works, it just always rebuilds. +// Response-cache aware: the body is deterministic in (count, m), so each distinct path caches under +// its own slot (key = hash(method, path, query)). A hit replays the stored bytes, a miss builds and +// stores. With the cache disabled or full it still works, just always rebuilds. fn jsonHandler(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { _ = body; - if (zix.Http1.cacheLookup(head)) |cached| { - zix.Http1.fdWriteAll(fd, cached) catch {}; - return; + // json-comp: when the client accepts gzip, serve a gzip body with Content-Encoding: gzip, cached + // per (key, encoding) so a repeat request replays the compressed bytes with no rebuild and no + // recompression. The plain json test sends no Accept-Encoding and uses the identity cache. + const accept = zix.Http1.getHeader(head, "accept-encoding") orelse ""; + const want_gzip = std.mem.indexOf(u8, accept, "gzip") != null; + + if (want_gzip) { + if (zix.Http1.cacheLookupEncoded(head, "gzip")) |cached| { + zix.Http1.fdWriteAll(fd, cached) catch {}; + return; + } + } else { + if (zix.Http1.cacheLookup(head)) |cached| { + zix.Http1.fdWriteAll(fd, cached) catch {}; + return; + } } const count_str = head.path["/json/".len..]; @@ -175,6 +187,13 @@ fn jsonHandler(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posi buf[pos] = '}'; pos += 1; + // json-comp path: gzip + per-(key, encoding) cache. The first request compresses and stores, + // the rest replay (the early cacheLookupEncoded above already short-circuits a hit). + if (want_gzip) { + writeJsonGzip(head, fd, buf[0..pos]); + return; + } + // Assemble the full response so it can be cached and replayed verbatim. The // header matches the engine's writeJson output (send_date_header is off, so // there is no time-varying field to freeze in the cache). @@ -188,6 +207,13 @@ fn jsonHandler(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posi zix.Http1.writeWithCache(fd, head, resp[0 .. hdr.len + pos], CACHE_TTL_MS) catch {}; } +// json-comp: gzip a JSON body, send it with Content-Encoding: gzip. Delegates to the engine's +// writeGzipCached: a per-worker compressor plus the per-(key, encoding) cache, so after the first +// request the deterministic gzip body is a zero-compression cache replay. +fn writeJsonGzip(head: *const zix.Http1.ParsedHead, fd: std.posix.fd_t, json: []const u8) void { + zix.Http1.writeGzipCached(fd, head, 200, "application/json", json, zix.Http1.cacheTtl()) catch {}; +} + // POST /upload : return the received byte count. The Content-Length header is // authoritative (curl/clients always send it here), and the engine drains the // body for sizes beyond the read buffer, so this never touches the bytes. @@ -440,6 +466,29 @@ const Routes = zix.Http1.Router(&[_]zix.Http1.Route{ // --------------------------------------------------------- // +// json-tls https server on H1TLS_PORT. Under .EPOLL / .URING it terminates TLS in the per-core +// tls_mux (one worker per core, bounded memory), which scales where the thread-per-connection path +// melts down. The same Router dispatch serves the handlers (json-tls only exercises /json). +fn tlsWorker(io: std.Io, tls: *zix.Tls.Context) void { + var server = zix.Http1.Server.init(Routes.dispatch, .{ + .io = io, + .ip = LISTEN_IP, + .port = H1TLS_PORT, + .tls = tls, + .dispatch_model = DISPATCH_MODEL, + .workers = WORKERS, + .kernel_backlog = KERNEL_BACKLOG, + .max_recv_buf = MAX_RECV_BUF, + .max_headers = MAX_HEADERS, + .send_date_header = false, + }); + defer server.deinit(); + + server.run() catch {}; +} + +// --------------------------------------------------------- // + fn sumQuery(query: []const u8) i64 { var sum: i64 = 0; var it = std.mem.tokenizeScalar(u8, query, '&'); @@ -496,6 +545,25 @@ pub fn main(process: std.process.Init) !void { g_dataset = try dataset.load(std.heap.smp_allocator, dataset_path); + // json-tls: load the baked Ed25519 cert from /etc/zix-tls and serve https on H1TLS_PORT. A missing + // or unreadable cert degrades gracefully: the cleartext server still runs, json-tls simply has + // no listener. Ed25519 signing is a cheap per-connection operation (zix.Tls). + const cert_path = process.environ_map.get("ARENA_TLS_CERT") orelse TLS_CERT_DEFAULT; + const key_path = process.environ_map.get("ARENA_TLS_KEY") orelse TLS_KEY_DEFAULT; + + var tls_ctx: ?zix.Tls.Context = zix.Tls.Context.init(std.heap.smp_allocator, process.io, .{ + .cert_path = cert_path, + .key_path = key_path, + .alpn = &.{.HTTP_1_1}, + .min_version = .TLS_1_3, + }) catch null; + + if (tls_ctx) |*tls| { + // One TLS server thread: under .EPOLL / .URING it spawns its own per-core tls_mux workers + // internally, so a thread pool here would over-subscribe. Thread models use one accept loop. + _ = std.Thread.spawn(.{}, tlsWorker, .{ process.io, tls }) catch {}; + } + var server = zix.Http1.Server.initRaw(Routes.dispatch, rawIntercept, .{ .io = process.io, .ip = LISTEN_IP, From b8e82a00a75efcf83e9a481f341d34b661b073e0 Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 29 Jun 2026 20:28:25 +0700 Subject: [PATCH 3/3] add idle pool ceiling uring --- frameworks/zix/src/main.zig | 72 ++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/frameworks/zix/src/main.zig b/frameworks/zix/src/main.zig index a2d2ea00b..d538b5fbe 100644 --- a/frameworks/zix/src/main.zig +++ b/frameworks/zix/src/main.zig @@ -1,22 +1,9 @@ //! HttpArena: zix //! -//! zix HttpArena HTTP/1.1 entry point. -//! -//! Intent: demonstrate zix.Http1 (URING dispatch model) against the HttpArena -//! HTTP/1.1 benchmark suite (baseline, pipelined, limited-conn, json, json-comp, -//! upload, static). json-comp reuses the /json endpoint and gzips the body when -//! the client sends Accept-Encoding: gzip. -//! -//! json-tls runs a second Http1 server on H1TLS_PORT: https over TLS 1.3 with the baked Ed25519 -//! cert at /etc/zix-tls. TLS terminates in the per-core tls_mux (one worker per core via -//! SO_REUSEPORT, no thread-per-connection), its own perf band apart from the cleartext URING engine. -//! -//! Design choices: -//! - rawIntercept: called before any header parsing on each request. Handles /pipeline by direct -//! byte-match then a raw write, skipping the header scan. Other routes fall through to the Router -//! dispatch with full parsing. -//! - Router: comptime route table (StaticStringMap for EXACT, inline for PREFIX). -//! - PIPELINE_RESP: precomputed response bytes, written verbatim per request with no header build. +//! zix.Http1 (.URING) against the HttpArena HTTP/1.1 suite (baseline, pipelined, limited-conn, json, +//! json-comp, upload, static). json-comp reuses /json and gzips on Accept-Encoding: gzip. json-tls +//! runs a second Http1 server on H1TLS_PORT (https over TLS 1.3, baked Ed25519 cert, per-core tls_mux). +//! /pipeline is fast-pathed in rawIntercept (see below), other routes go through the comptime Router. const std = @import("std"); const zix = @import("zix"); @@ -40,16 +27,22 @@ const TLS_KEY_DEFAULT: []const u8 = "/etc/zix-tls/server.key"; const Profile = enum { lean, throughput }; const PROFILE: Profile = .throughput; -/// Per-connection recv buffer, allocated once at accept. .lean keeps 4 KiB (requests are under -/// 300 bytes and upload bodies are drained in chunks, so only headers must fit). .throughput -/// raises it to 16 KiB for deeper pipelined batches per recv. +/// Per-connection recv buffer (allocated at accept). .lean 4 KiB, .throughput 8 KiB (holds a +/// 16-deep pipelined burst, about 4.8 KiB). Held by every live and warm-pool connection, so it +/// pairs with uring_idle_pool_ceiling (below): 8 KiB (down from 16 KiB) trims the resident set +/// behind the high-connection regression at no cell cost (requests tiny, json/static use the send +/// buffer and sendfile, uploads use the large-body path). const MAX_RECV_BUF: usize = switch (PROFILE) { .lean => 4 * 1024, - .throughput => 16 * 1024, + .throughput => 8 * 1024, }; const MAX_HEADERS: u8 = 16; const WORKERS: usize = 0; +/// Warm idle-pool ceiling for .URING: absolute cap on warm connections per worker, so the pool +/// never tracks a full live_count at high concurrency (the engine-side high-connection regression fix). +const URING_IDLE_POOL_CEILING: usize = 256; + // Response cache (ADR-036), /json only. The body is deterministic in (count, m) and clears the // cache crossover (~4 KiB), so a hit replays the full response with zero serialization. Other // endpoints stay below the crossover or use the sendfile cache (static). @@ -116,20 +109,35 @@ fn pipelineHandler(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std. zix.Http1.fdWriteAll(fd, PIPELINE_RESP) catch {}; } -// Raw-request interceptor (initRaw), called before header parsing on each request. Handles /pipeline -// with zero parse overhead: byte-matches the path on rem, writes PIPELINE_RESP, returns the consumed -// length (always header_end + 4, since /pipeline is a bare GET with no body). Unknown routes return -// null and fall through to the Router with full parsing. +// Raw interceptor (initRaw), before header parsing. Handles /pipeline with zero parse overhead: +// byte-matches the path, writes PIPELINE_RESP, returns the consumed length. One call drains every +// consecutive /pipeline request in the buffer (a 16-deep burst answers in one pass, coalesced into +// one write), safe because the entry owns /pipeline as a pure bodyless GET. Other routes return null +// and fall through to the Router. fn rawIntercept(rem: []const u8, header_end: usize, fd: std.posix.fd_t) ?usize { // Must start with "GET /p" to qualify for this fast path. if (rem.len < 24 or rem[0] != 'G' or rem[4] != '/' or rem[5] != 'p') return null; - // "GET /pipeline " is 15 bytes. Verify without scanning the request line. + // Verify "/pipeline " without scanning the request line. if (!std.mem.eql(u8, rem[4..15], "/pipeline ")) return null; + // First request: the engine already found its header end, so its length is header_end + 4. zix.Http1.fdWriteAll(fd, PIPELINE_RESP) catch {}; + var consumed: usize = header_end + 4; + + // Drain further consecutive /pipeline requests (each a bodyless GET, length = its header end + 4). + // Stop at the first non-/pipeline or incomplete request and let the engine resume from there. + while (true) { + const next = rem[consumed..]; + if (next.len < 24 or next[0] != 'G' or next[4] != '/' or next[5] != 'p') break; + if (!std.mem.eql(u8, next[4..15], "/pipeline ")) break; + + const end = std.mem.indexOf(u8, next, "\r\n\r\n") orelse break; + zix.Http1.fdWriteAll(fd, PIPELINE_RESP) catch {}; + consumed += end + 4; + } - return header_end + 4; + return consumed; } // GET /json/{count}?m=M : render count dataset items, total = price*qty*M. @@ -412,11 +420,9 @@ fn staticServeUncached(rel: []const u8, want_br: bool, want_gzip: bool, fd: std. sendfileAll(fd, variant.fd, variant.size) catch {}; } -// GET /static/{file} : serve from /data/static with content negotiation. -// Prefers a precompressed .br then .gz variant when the client accepts it, -// falling back to the identity file. Content-Type is by extension. The first -// request for a path probes the disk and caches fd + size + pre-rendered -// header, every later request is one header send plus one zero-copy sendfile. +// GET /static/{file} : serve from /data/static, content negotiation (prefers .br then .gz when +// accepted, else identity), Content-Type by extension. First request probes the disk and caches +// fd + size + pre-rendered header, later requests are one header send plus one zero-copy sendfile. fn staticHandler(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { _ = body; @@ -479,6 +485,7 @@ fn tlsWorker(io: std.Io, tls: *zix.Tls.Context) void { .workers = WORKERS, .kernel_backlog = KERNEL_BACKLOG, .max_recv_buf = MAX_RECV_BUF, + .uring_idle_pool_ceiling = URING_IDLE_POOL_CEILING, .max_headers = MAX_HEADERS, .send_date_header = false, }); @@ -571,6 +578,7 @@ pub fn main(process: std.process.Init) !void { .dispatch_model = DISPATCH_MODEL, .kernel_backlog = KERNEL_BACKLOG, .max_recv_buf = MAX_RECV_BUF, + .uring_idle_pool_ceiling = URING_IDLE_POOL_CEILING, .max_headers = MAX_HEADERS, .workers = WORKERS, .send_date_header = false,