From 3eb9d004a3cac2a9e816bd075a30efc0644f9401 Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 29 Jun 2026 06:35:31 +0700 Subject: [PATCH 1/5] zix 0.5.x-rc1 --- frameworks/zix-http2/.gitignore | 4 + frameworks/zix-http2/Dockerfile | 73 ++++ frameworks/zix-http2/build.zig | 26 ++ frameworks/zix-http2/build.zig.zon | 16 + frameworks/zix-http2/meta.json | 16 + frameworks/zix-http2/src/dataset.zig | 167 +++++++++ frameworks/zix-http2/src/main.zig | 516 +++++++++++++++++++++++++++ 7 files changed, 818 insertions(+) create mode 100644 frameworks/zix-http2/.gitignore create mode 100644 frameworks/zix-http2/Dockerfile create mode 100644 frameworks/zix-http2/build.zig create mode 100644 frameworks/zix-http2/build.zig.zon create mode 100644 frameworks/zix-http2/meta.json create mode 100644 frameworks/zix-http2/src/dataset.zig create mode 100644 frameworks/zix-http2/src/main.zig diff --git a/frameworks/zix-http2/.gitignore b/frameworks/zix-http2/.gitignore new file mode 100644 index 000000000..595d20d1a --- /dev/null +++ b/frameworks/zix-http2/.gitignore @@ -0,0 +1,4 @@ +.zig-cache +zig-out +zig-package +vendor diff --git a/frameworks/zix-http2/Dockerfile b/frameworks/zix-http2/Dockerfile new file mode 100644 index 000000000..52a87abdf --- /dev/null +++ b/frameworks/zix-http2/Dockerfile @@ -0,0 +1,73 @@ +# syntax=docker/dockerfile:1.7 + +FROM alpine:3.20 AS build +ARG RETRY=6 +ARG TARGETARCH +ARG RETRY_DELAY=3 +ARG ZIG_VERSION=0.16.0 +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 \ + amd64) ZIG_ARCH=x86_64 ;; \ + arm64) ZIG_ARCH=aarch64 ;; \ + *) echo "unsupported arch: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-${ZIG_ARCH}-linux-${ZIG_VERSION}.tar.xz" \ + | tar -xJ -C /opt; \ + mv "/opt/zig-${ZIG_ARCH}-linux-${ZIG_VERSION}" /opt/zig +ENV PATH="/opt/zig:${PATH}" + +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; \ + 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)); \ + [ "${attempt}" -le "${RETRY}" ] && sleep "${RETRY_DELAY}"; \ + done; \ + return 1; \ + }; \ + 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}+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-http2 /zix-http2 +COPY --from=build /etc/zix-tls /etc/zix-tls +EXPOSE 8082 8443 +ENTRYPOINT ["/zix-http2"] diff --git a/frameworks/zix-http2/build.zig b/frameworks/zix-http2/build.zig new file mode 100644 index 000000000..1e1a29323 --- /dev/null +++ b/frameworks/zix-http2/build.zig @@ -0,0 +1,26 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{ .preferred_optimize_mode = .ReleaseFast }); + + const zix_dep = b.dependency("zix", .{ .target = target, .optimize = optimize }); + const zix_mod = zix_dep.module("zix"); + + const exe = b.addExecutable(.{ + .name = "zix-http2", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .strip = true, + }), + }); + exe.root_module.addImport("zix", zix_mod); + b.installArtifact(exe); + + const run_step = b.step("run", "Run the HTTP/2 server"); + const run_cmd = b.addRunArtifact(exe); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/frameworks/zix-http2/build.zig.zon b/frameworks/zix-http2/build.zig.zon new file mode 100644 index 000000000..a6bde258d --- /dev/null +++ b/frameworks/zix-http2/build.zig.zon @@ -0,0 +1,16 @@ +.{ + .name = .zix_arena, + .version = "0.1.0", + .fingerprint = 0x84cceaddc218682f, + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, + .dependencies = .{ + .zix = .{ + .url = "{URL_ZIX_SOURCE}/{ZIX_VERSION}.tar.gz", + }, + }, +} diff --git a/frameworks/zix-http2/meta.json b/frameworks/zix-http2/meta.json new file mode 100644 index 000000000..0f366b601 --- /dev/null +++ b/frameworks/zix-http2/meta.json @@ -0,0 +1,16 @@ +{ + "display_name": "zix-http2", + "language": "Zig", + "type": "engine", + "engine": "zix uring", + "description": "Zig HTTP/2 server on the zix.Http2 engine (no std.http). h2c runs under the .URING model: shared-nothing, each worker owns its SO_REUSEPORT listener, io_uring loop, and connections, multiplexing streams through a resumable frame state machine. h2 over TLS 1.3 (ALPN h2, baked self-signed Ed25519 cert) terminates in the per-core tls_mux. Serves baseline (sum the query), json (render dataset items), and static (chunked DATA frames from disk).", + "repo": "https://github.com/prothegee/zix", + "enabled": true, + "tests": [ + "baseline-h2", + "static-h2", + "baseline-h2c", + "json-h2c" + ], + "maintainers": ["prothegee"] +} diff --git a/frameworks/zix-http2/src/dataset.zig b/frameworks/zix-http2/src/dataset.zig new file mode 100644 index 000000000..a8f5357d3 --- /dev/null +++ b/frameworks/zix-http2/src/dataset.zig @@ -0,0 +1,167 @@ +//! HttpArena: zix +//! +//! Dataset loader for the /json endpoint. +//! +//! Loads the fixed 50-item benchmark dataset once at startup and pre-renders +//! each item as a JSON object fragment (without the closing brace), so the hot +//! path only appends the per-request total and the closing brace. + +const std = @import("std"); + +pub const ItemCount = 50; + +pub const Item = struct { + /// Pre-rendered JSON object for this item, WITHOUT the closing `}`. + /// Caller appends `,"total":}` per request. + prefix: []const u8, + /// price * quantity, pre-multiplied so per-request work is one *m + /// followed by an integer-to-decimal print. + pq: u64, +}; + +pub const Dataset = struct { + items: []Item, + arena: std.heap.ArenaAllocator, + + pub fn deinit(self: *Dataset) void { + self.arena.deinit(); + } +}; + +pub fn load(gpa: std.mem.Allocator, path: []const u8) !Dataset { + var arena = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const aa = arena.allocator(); + + const raw = try readFileAlloc(aa, path, 4 * 1024 * 1024); + + var parsed = try std.json.parseFromSlice(std.json.Value, aa, raw, .{}); + defer parsed.deinit(); + + const arr = switch (parsed.value) { + .array => |a| a, + else => return error.BadDataset, + }; + if (arr.items.len != ItemCount) return error.BadDataset; + + const items = try aa.alloc(Item, ItemCount); + for (arr.items, 0..) |elem, i| { + const obj = switch (elem) { + .object => |o| o, + else => return error.BadDataset, + }; + const price = jsonInt(obj.get("price") orelse return error.BadDataset); + const quantity = jsonInt(obj.get("quantity") orelse return error.BadDataset); + + var buf: std.ArrayList(u8) = .empty; + try renderItemPrefix(&buf, aa, obj); + items[i] = .{ + .prefix = try buf.toOwnedSlice(aa), + .pq = @as(u64, @intCast(price)) * @as(u64, @intCast(quantity)), + }; + } + + return .{ .items = items, .arena = arena }; +} + +fn readFileAlloc(aa: std.mem.Allocator, path: []const u8, max: usize) ![]u8 { + var path_z: [std.posix.PATH_MAX]u8 = undefined; + if (path.len >= path_z.len) return error.NameTooLong; + @memcpy(path_z[0..path.len], path); + path_z[path.len] = 0; + const fd = try std.posix.openatZ(std.posix.AT.FDCWD, @ptrCast(&path_z), .{ .ACCMODE = .RDONLY }, 0); + defer _ = std.posix.system.close(fd); + + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(aa); + try buf.ensureTotalCapacity(aa, 64 * 1024); + while (buf.items.len < max) { + try buf.ensureUnusedCapacity(aa, 32 * 1024); + const dst = buf.unusedCapacitySlice(); + const n = try std.posix.read(fd, dst); + if (n == 0) break; + buf.items.len += n; + } + return buf.toOwnedSlice(aa); +} + +fn jsonInt(v: std.json.Value) i64 { + return switch (v) { + .integer => |n| n, + .float => |f| @intFromFloat(f), + else => 0, + }; +} + +fn renderItemPrefix(buf: *std.ArrayList(u8), aa: std.mem.Allocator, obj: std.json.ObjectMap) !void { + try buf.append(aa, '{'); + var first = true; + var it = obj.iterator(); + while (it.next()) |kv| { + if (!first) try buf.append(aa, ','); + first = false; + try writeString(buf, aa, kv.key_ptr.*); + try buf.append(aa, ':'); + try writeValue(buf, aa, kv.value_ptr.*); + } + // Intentionally no closing `}`: the caller appends `,"total":N}`. +} + +fn writeValue(buf: *std.ArrayList(u8), aa: std.mem.Allocator, v: std.json.Value) !void { + switch (v) { + .null => try buf.appendSlice(aa, "null"), + .bool => |b| try buf.appendSlice(aa, if (b) "true" else "false"), + .integer => |n| try writeInt(buf, aa, n), + .float => |f| { + var tmp: [32]u8 = undefined; + const s = std.fmt.bufPrint(&tmp, "{d}", .{f}) catch unreachable; + try buf.appendSlice(aa, s); + }, + .number_string => |ns| try buf.appendSlice(aa, ns), + .string => |s| try writeString(buf, aa, s), + .array => |arr| { + try buf.append(aa, '['); + for (arr.items, 0..) |e, i| { + if (i > 0) try buf.append(aa, ','); + try writeValue(buf, aa, e); + } + try buf.append(aa, ']'); + }, + .object => |o| { + try buf.append(aa, '{'); + var first = true; + var it = o.iterator(); + while (it.next()) |kv| { + if (!first) try buf.append(aa, ','); + first = false; + try writeString(buf, aa, kv.key_ptr.*); + try buf.append(aa, ':'); + try writeValue(buf, aa, kv.value_ptr.*); + } + try buf.append(aa, '}'); + }, + } +} + +fn writeInt(buf: *std.ArrayList(u8), aa: std.mem.Allocator, n: i64) !void { + var tmp: [24]u8 = undefined; + const s = std.fmt.bufPrint(&tmp, "{d}", .{n}) catch unreachable; + try buf.appendSlice(aa, s); +} + +fn writeString(buf: *std.ArrayList(u8), aa: std.mem.Allocator, s: []const u8) !void { + try buf.append(aa, '"'); + for (s) |c| { + switch (c) { + '"' => try buf.appendSlice(aa, "\\\""), + '\\' => try buf.appendSlice(aa, "\\\\"), + 0x00...0x1f => { + var esc: [6]u8 = undefined; + _ = std.fmt.bufPrint(&esc, "\\u{x:0>4}", .{c}) catch unreachable; + try buf.appendSlice(aa, esc[0..6]); + }, + else => try buf.append(aa, c), + } + } + try buf.append(aa, '"'); +} diff --git a/frameworks/zix-http2/src/main.zig b/frameworks/zix-http2/src/main.zig new file mode 100644 index 000000000..52f151c00 --- /dev/null +++ b/frameworks/zix-http2/src/main.zig @@ -0,0 +1,516 @@ +//! HttpArena: zix-http2 +//! +//! zix HTTP/2 entry point on the zix.Http2 engine (no std.http). Two listeners run in parallel: +//! - h2c cleartext on H2C_PORT under .URING (shared-nothing per-core io_uring, one SO_REUSEPORT +//! listener plus ring per CPU). Serves baseline-h2c and json-h2c. +//! - h2 over TLS 1.3 on H2_TLS_PORT (ALPN h2, self-signed Ed25519 cert at /etc/zix-tls): the per-core +//! tls_mux (one worker per core, no thread-per-connection). Serves baseline-h2 and static-h2. +//! +//! Endpoints: +//! - GET /baseline2?a=..&b=.. : sum the query values plus the POST body as an integer, text/plain. +//! - GET /json/{count}?m=M : render count dataset items, total = price*quantity*M, json. +//! - GET /static/{file} : serve /data/static by extension, body as chunked DATA frames (<= 16 KiB). +//! +//! One route table serves both listeners: extra routes on each port are simply never hit by the +//! benchmark (h2c hits baseline + json, TLS hits baseline + static). + +const std = @import("std"); +const zix = @import("zix"); +const dataset = @import("dataset.zig"); + +// --------------------------------------------------------- // + +const H2C_PORT: u16 = 8082; +const H2_TLS_PORT: u16 = 8443; +const LISTEN_IP: []const u8 = "::"; +const KERNEL_BACKLOG: u31 = 16 * 1024; +const DISPATCH_MODEL: zix.Http2.DispatchModel = .URING; + +// TLS cert / key: self-signed Ed25519 pair baked at /etc/zix-tls at image build, overridable via env. +const TLS_CERT_DEFAULT: []const u8 = "/etc/zix-tls/server.crt"; +const TLS_KEY_DEFAULT: []const u8 = "/etc/zix-tls/server.key"; + +// Max DATA frame payload. 16384 is the HTTP/2 default SETTINGS_MAX_FRAME_SIZE (the largest a peer is +// guaranteed to accept), so static bodies are framed in <= 16 KiB chunks. +const MAX_DATA_CHUNK: usize = 16 * 1024; + +// Data directory, overridable via ARENA_DATA (default /data, the container mount point). +var g_static_base: []const u8 = "/data/static/"; +var g_static_base_buf: [256]u8 = undefined; + +var g_dataset: dataset.Dataset = undefined; + +// Per-worker scratch for the JSON body (largest, count 50, tops out near 12 KiB). +threadlocal var json_body_buf: [32 * 1024]u8 = undefined; + +// --------------------------------------------------------- // + +fn notFound(fd: std.posix.fd_t, sid: u31) void { + zix.Http2.sendResponse(fd, sid, 404, "text/plain", "Not Found") catch {}; +} + +fn badRequest(fd: std.posix.fd_t, sid: u31) void { + zix.Http2.sendResponse(fd, sid, 400, "text/plain", "bad request") catch {}; +} + +/// Read the ":path" pseudo-header value (the request target, query included). +fn pathFromHeaders(headers: []const zix.Http2.Header) []const u8 { + for (headers) |h| { + if (std.mem.eql(u8, h.name, ":path")) return h.value; + } + + return "/"; +} + +// --------------------------------------------------------- // + +// GET/POST /baseline2?a=..&b=.. : sum query values plus the POST body as an integer, returns text/plain. +fn baselineHandler(method: []const u8, headers: []const zix.Http2.Header, body: []const u8, fd: std.posix.fd_t, sid: u31) void { + const path = pathFromHeaders(headers); + const query = if (std.mem.indexOfScalar(u8, path, '?')) |q| path[q + 1 ..] else ""; + + var sum: i64 = sumQuery(query); + if (std.mem.eql(u8, method, "POST") and body.len > 0) { + sum += parseIntLoose(body); + } + + var body_buf: [32]u8 = undefined; + const out = std.fmt.bufPrint(&body_buf, "{d}", .{sum}) catch return; + + zix.Http2.sendResponse(fd, sid, 200, "text/plain", out) catch {}; +} + +// GET /json/{count}?m=M : render count dataset items, total = price*quantity*M. Body (near 12 KiB) fits +// the 16 KiB frame cap, so it ships as a single DATA frame via sendResponse. +fn jsonHandler(_: []const u8, headers: []const zix.Http2.Header, _: []const u8, fd: std.posix.fd_t, sid: u31) void { + const path = pathFromHeaders(headers); + if (!std.mem.startsWith(u8, path, "/json/")) return badRequest(fd, sid); + + const after = path["/json/".len..]; + const q = std.mem.indexOfScalar(u8, after, '?'); + const count_str = if (q) |i| after[0..i] else after; + const query = if (q) |i| after[i + 1 ..] else ""; + + const count = std.fmt.parseInt(u8, count_str, 10) catch return badRequest(fd, sid); + if (count < 1 or count > dataset.ItemCount) return badRequest(fd, sid); + + const m: u64 = if (queryParam(query, "m")) |s| std.fmt.parseInt(u64, s, 10) catch 1 else 1; + + const buf = &json_body_buf; + var pos: usize = 0; + + pos = appendStr(buf, pos, "{\"items\":["); + var i: usize = 0; + while (i < count) : (i += 1) { + if (i > 0) { + buf[pos] = ','; + pos += 1; + } + const item = g_dataset.items[i]; + @memcpy(buf[pos..][0..item.prefix.len], item.prefix); + pos += item.prefix.len; + pos = appendStr(buf, pos, ",\"total\":"); + pos = appendInt(buf, pos, item.pq * m); + buf[pos] = '}'; + pos += 1; + } + pos = appendStr(buf, pos, "],\"count\":"); + pos = appendInt(buf, pos, count); + buf[pos] = '}'; + pos += 1; + + zix.Http2.sendResponse(fd, sid, 200, "application/json", buf[0..pos]) catch {}; +} + +// --------------------------------------------------------- // + +fn contentType(rel: []const u8) []const u8 { + if (std.mem.endsWith(u8, rel, ".css")) return "text/css"; + if (std.mem.endsWith(u8, rel, ".js")) return "application/javascript"; + if (std.mem.endsWith(u8, rel, ".json")) return "application/json"; + if (std.mem.endsWith(u8, rel, ".html")) return "text/html"; + if (std.mem.endsWith(u8, rel, ".svg")) return "image/svg+xml"; + if (std.mem.endsWith(u8, rel, ".woff2")) return "font/woff2"; + if (std.mem.endsWith(u8, rel, ".webp")) return "image/webp"; + + return "application/octet-stream"; +} + +/// Content type plus content encoding for a cached static name. A ".br" or ".gz" suffix marks a +/// precompressed variant: the encoding is reported and the content type comes from the name with the +/// suffix stripped (so "vendor.js.br" reports application/javascript). +const StaticMeta = struct { + content_type: []const u8, + content_encoding: []const u8, +}; + +fn staticMeta(name: []const u8) StaticMeta { + if (std.mem.endsWith(u8, name, ".br")) { + return .{ .content_type = contentType(name[0 .. name.len - ".br".len]), .content_encoding = "br" }; + } + if (std.mem.endsWith(u8, name, ".gz")) { + return .{ .content_type = contentType(name[0 .. name.len - ".gz".len]), .content_encoding = "gzip" }; + } + + return .{ .content_type = contentType(name), .content_encoding = "" }; +} + +/// Accept-Encoding tokens the client advertised. A substring scan suffices for the fixed benchmark +/// header ("br;q=1, gzip;q=0.8"), no q-value parsing is needed. +const AcceptEncoding = struct { + prefers_br: bool, + accepts_gzip: bool, +}; + +fn acceptEncoding(headers: []const zix.Http2.Header) AcceptEncoding { + for (headers) |h| { + if (std.mem.eql(u8, h.name, "accept-encoding")) { + return .{ + .prefers_br = std.mem.indexOf(u8, h.value, "br") != null, + .accepts_gzip = std.mem.indexOf(u8, h.value, "gzip") != null, + }; + } + } + + return .{ .prefers_br = false, .accepts_gzip = false }; +} + +/// Static cache name cap. Fixture names are short, anything longer is a 404. +const STATIC_NAME_MAX = 96; +/// Static cache capacity: 20 fixtures times their (.br, .gz, identity) candidates plus 404 headroom, +/// sized so the startup pre-warm fits every candidate with room to spare. +const STATIC_CACHE_MAX = 128; + +/// One cached static file: the full bytes read into memory once (HTTP/2 frames the body in userspace, +/// so no sendfile fast path) plus its content type. ok is false for a missing file (caching the 404 so +/// a bad path is not re-probed). content_encoding is "" for identity (raw), or "br" / "gzip" for a +/// precompressed variant. +const StaticEntry = struct { + name_len: u16, + // rel (up to STATIC_NAME_MAX) plus a 3-char precompressed suffix (".br" or ".gz"). + name_buf: [STATIC_NAME_MAX + 3]u8, + bytes: []const u8, + content_type: []const u8, + content_encoding: []const u8, + ok: bool, +}; + +// Append-only cache: readers scan 0..count lock-free (count published release-ordered after the slot +// is fully written), the spinlock only serializes inserts (rare, one per distinct path). +var g_static_entries: [STATIC_CACHE_MAX]StaticEntry = undefined; +var g_static_count: usize = 0; +var g_static_lock: std.atomic.Value(bool) = .init(false); + +fn staticLookup(rel: []const u8, count: usize) ?*const StaticEntry { + for (g_static_entries[0..count]) |*e| { + if (std.mem.eql(u8, e.name_buf[0..e.name_len], rel)) return e; + } + + return null; +} + +/// Read a static file fully into a process-lifetime buffer. Returns null when the file is absent. +fn readStaticFile(rel: []const u8) ?[]const u8 { + var path_buf: [512]u8 = undefined; + const path = std.fmt.bufPrint(&path_buf, "{s}{s}", .{ g_static_base, rel }) catch return null; + if (path.len >= path_buf.len) return null; + + path_buf[path.len] = 0; + + const file_fd = std.posix.openatZ(std.posix.AT.FDCWD, @ptrCast(&path_buf), .{ .ACCMODE = .RDONLY }, 0) catch return null; + defer _ = std.posix.system.close(file_fd); + + var stx: std.os.linux.Statx = undefined; + const stat_rc = std.os.linux.statx(file_fd, "", std.os.linux.AT.EMPTY_PATH, .{ .SIZE = true }, &stx); + if (std.posix.errno(stat_rc) != .SUCCESS) return null; + + const size: usize = @intCast(stx.size); + const buf = std.heap.smp_allocator.alloc(u8, size) catch return null; + + var read: usize = 0; + while (read < size) { + const n = std.posix.read(file_fd, buf[read..]) catch { + std.heap.smp_allocator.free(buf); + return null; + }; + if (n == 0) break; + read += n; + } + + return buf[0..read]; +} + +/// Probe + cache a static path on first request, then return the slot. Caches a not-found slot so a +/// bad path is probed only once. Returns null only when the cache is full. +fn staticInsert(rel: []const u8) ?*const StaticEntry { + while (g_static_lock.swap(true, .acquire)) std.atomic.spinLoopHint(); + defer g_static_lock.store(false, .release); + + const count = @atomicLoad(usize, &g_static_count, .acquire); + if (staticLookup(rel, count)) |e| return e; + if (count == STATIC_CACHE_MAX) return null; + + const e = &g_static_entries[count]; + e.name_len = @intCast(rel.len); + @memcpy(e.name_buf[0..rel.len], rel); + if (readStaticFile(rel)) |bytes| { + const meta = staticMeta(rel); + e.bytes = bytes; + e.content_type = meta.content_type; + e.content_encoding = meta.content_encoding; + e.ok = true; + } else { + e.bytes = &.{}; + e.content_type = "text/plain"; + e.content_encoding = ""; + e.ok = false; + } + + @atomicStore(usize, &g_static_count, count + 1, .release); + + return e; +} + +/// Resolve a static name through the cache (lookup, then insert on a miss). Returns the slot only when +/// the file exists on disk (ok), so a caller can fall through to the next candidate on a missing variant. +fn resolveStatic(name: []const u8) ?*const StaticEntry { + const count = @atomicLoad(usize, &g_static_count, .acquire); + const entry = staticLookup(name, count) orelse staticInsert(name) orelse return null; + if (!entry.ok) return null; + + return entry; +} + +/// Populate the static cache once at startup, single-threaded, before any worker serves. Every file is +/// resolved through all candidates the handler probes (.br, .gz, identity), so the request path only +/// hits the lock-free lookup. Without it the first burst all misses into staticInsert, which holds a +/// spinlock across the file read, and an oversubscribed worker pool livelocks. +fn prewarmStatic() void { + var base_buf: [512]u8 = undefined; + var base = g_static_base; + if (base.len > 1 and base[base.len - 1] == '/') base = base[0 .. base.len - 1]; + if (base.len >= base_buf.len) return; + + @memcpy(base_buf[0..base.len], base); + base_buf[base.len] = 0; + + const dir_fd = std.posix.openatZ(std.posix.AT.FDCWD, @ptrCast(&base_buf), .{ .ACCMODE = .RDONLY, .DIRECTORY = true }, 0) catch return; + defer _ = std.posix.system.close(dir_fd); + + // Iterate with raw getdents64 (this std.fs has no portable Dir.iterate). linux_dirent64 layout: + // d_ino(8) d_off(8) d_reclen(2 @16) d_type(1 @18) d_name(@19, null-terminated). + var dbuf: [4096]u8 = undefined; + while (true) { + const rc = std.os.linux.getdents64(dir_fd, &dbuf, dbuf.len); + const got: isize = @bitCast(rc); + if (got <= 0) break; + + var off: usize = 0; + while (off < @as(usize, @intCast(got))) { + const reclen: usize = @as(usize, dbuf[off + 16]) | (@as(usize, dbuf[off + 17]) << 8); + const d_type = dbuf[off + 18]; + const name = std.mem.sliceTo(dbuf[off + 19 ..], 0); + off += reclen; + + if (d_type == 4) continue; // DT_DIR + if (name.len == 0 or name[0] == '.') continue; + + // Reduce a precompressed name to its base, then warm every candidate the handler probes + // (.br, .gz, identity). A missing variant caches a not-found slot, so the request path never + // inserts under load. + var stem = name; + if (std.mem.endsWith(u8, stem, ".br")) stem = stem[0 .. stem.len - ".br".len] else if (std.mem.endsWith(u8, stem, ".gz")) stem = stem[0 .. stem.len - ".gz".len]; + if (stem.len == 0 or stem.len > STATIC_NAME_MAX) continue; + + var cand_buf: [STATIC_NAME_MAX + 3]u8 = undefined; + if (std.fmt.bufPrint(&cand_buf, "{s}.br", .{stem})) |c| { + _ = resolveStatic(c); + } else |_| {} + if (std.fmt.bufPrint(&cand_buf, "{s}.gz", .{stem})) |c| { + _ = resolveStatic(c); + } else |_| {} + _ = resolveStatic(stem); + } + } +} + +// GET /static/{file} : serve a file from /data/static, content type by extension. The first request +// reads it into memory and caches it, later requests reuse the cached bytes. The body is sent as +// chunked DATA frames since HTTP/2 caps a single frame at the peer's max frame size. +// +// Content negotiation: client accepts br and "{file}.br" exists serves brotli with content-encoding br. +// Else gzip and "{file}.gz" likewise. Otherwise the raw (identity) file. The server never compresses, +// it only serves a precompressed file already on disk. +fn staticHandler(_: []const u8, headers: []const zix.Http2.Header, _: []const u8, fd: std.posix.fd_t, sid: u31) void { + const raw = pathFromHeaders(headers); + const path = if (std.mem.indexOfScalar(u8, raw, '?')) |q| raw[0..q] else raw; + if (!std.mem.startsWith(u8, path, "/static/")) return notFound(fd, sid); + + const rel = path["/static/".len..]; + if (rel.len == 0 or rel.len > STATIC_NAME_MAX or std.mem.indexOf(u8, rel, "..") != null or rel[0] == '/') return notFound(fd, sid); + + const accept = acceptEncoding(headers); + + // Candidates "{rel}.br" / "{rel}.gz" / "{rel}". The buffer holds rel plus a 3-char suffix. + var cand_buf: [STATIC_NAME_MAX + 3]u8 = undefined; + var entry: ?*const StaticEntry = null; + + if (accept.prefers_br) { + const cand = std.fmt.bufPrint(&cand_buf, "{s}.br", .{rel}) catch return notFound(fd, sid); + entry = resolveStatic(cand); + } + if (entry == null and accept.accepts_gzip) { + const cand = std.fmt.bufPrint(&cand_buf, "{s}.gz", .{rel}) catch return notFound(fd, sid); + entry = resolveStatic(cand); + } + if (entry == null) { + entry = resolveStatic(rel); + } + + const served = entry orelse return notFound(fd, sid); + + sendH2File(fd, sid, served.content_type, served.content_encoding, served.bytes); +} + +/// Send a 200 whose body is framed across DATA frames of at most MAX_DATA_CHUNK bytes, the last carrying +/// END_STREAM. Built on the public frame writers so the body can exceed a single HTTP/2 frame +/// (sendResponse emits one DATA frame, which would violate the peer's max frame size for large files). +/// content_encoding is emitted only when non-empty (a precompressed variant). +fn sendH2File(fd: std.posix.fd_t, sid: u31, content_type: []const u8, content_encoding: []const u8, bytes: []const u8) void { + // Flow-controlled send. The cached bytes are process-lifetime, so the mux references and paces them + // by the peer's WINDOW_UPDATE (HTTP/2 send window) and chunks to the max frame size, instead of + // dumping a large body (vendor.js, 300 KB) past the 65535 window. + zix.Http2.sendResponseStream(fd, sid, 200, content_type, content_encoding, bytes); +} + +// --------------------------------------------------------- // + +const ROUTES = &[_]zix.Http2.Route{ + .{ .path = "/baseline2", .handler = baselineHandler }, + .{ .path = "/json", .handler = jsonHandler, .kind = .PREFIX }, + .{ .path = "/static", .handler = staticHandler, .kind = .PREFIX }, +}; + +// --------------------------------------------------------- // + +// TLS h2 listener: the per-core tls_mux terminates TLS 1.3 (ALPN h2) in place (one worker per core, no +// thread-per-connection). A missing or unreadable cert degrades gracefully: this thread returns and the +// cleartext h2c server keeps running. +fn tlsServer(io: std.Io, tls: *zix.Tls.Context) void { + var server = zix.Http2.Server.init(ROUTES, .{ + .io = io, + .ip = LISTEN_IP, + .port = H2_TLS_PORT, + .tls = tls, + .dispatch_model = DISPATCH_MODEL, + .kernel_backlog = KERNEL_BACKLOG, + }) catch return; + defer server.deinit(); + + server.run() catch {}; +} + +// --------------------------------------------------------- // + +fn sumQuery(query: []const u8) i64 { + var sum: i64 = 0; + var it = std.mem.tokenizeScalar(u8, query, '&'); + while (it.next()) |pair| { + if (std.mem.indexOfScalar(u8, pair, '=')) |eq| { + sum += std.fmt.parseInt(i64, pair[eq + 1 ..], 10) catch 0; + } + } + + return sum; +} + +fn queryParam(query: []const u8, name: []const u8) ?[]const u8 { + var it = std.mem.tokenizeScalar(u8, query, '&'); + while (it.next()) |pair| { + if (std.mem.indexOfScalar(u8, pair, '=')) |eq| { + if (std.mem.eql(u8, pair[0..eq], name)) return pair[eq + 1 ..]; + } + } + + return null; +} + +fn parseIntLoose(s: []const u8) i64 { + var i: usize = 0; + while (i < s.len and (s[i] == ' ' or s[i] == '\t' or s[i] == '\r' or s[i] == '\n')) i += 1; + + var neg = false; + if (i < s.len and s[i] == '-') { + neg = true; + i += 1; + } + + var n: i64 = 0; + while (i < s.len and s[i] >= '0' and s[i] <= '9') : (i += 1) { + n = n * 10 + (s[i] - '0'); + } + + return if (neg) -n else n; +} + +fn appendStr(out: []u8, pos: usize, s: []const u8) usize { + @memcpy(out[pos..][0..s.len], s); + + return pos + s.len; +} + +fn appendInt(out: []u8, pos: usize, n: u64) usize { + var tmp: [24]u8 = undefined; + const s = std.fmt.bufPrint(&tmp, "{d}", .{n}) catch unreachable; + @memcpy(out[pos..][0..s.len], s); + + return pos + s.len; +} + +// --------------------------------------------------------- // + +pub fn main(process: std.process.Init) !void { + // Elevate scheduling priority (setpriority -19). Fails silently without CAP_SYS_NICE, so no special + // capability is required for correctness. + _ = std.os.linux.syscall3(.setpriority, 0, 0, @as(usize, @bitCast(@as(isize, -19)))); + + const data_dir = process.environ_map.get("ARENA_DATA") orelse "/data"; + g_static_base = std.fmt.bufPrint(&g_static_base_buf, "{s}/static/", .{data_dir}) catch "/data/static/"; + + // Warm the static cache before any worker serves, so the request path is lock-free (no spinlock held + // across a file read under the oversubscribed worker pool). + prewarmStatic(); + + var dataset_path_buf: [512]u8 = undefined; + const dataset_path = try std.fmt.bufPrint(&dataset_path_buf, "{s}/dataset.json", .{data_dir}); + + g_dataset = try dataset.load(std.heap.smp_allocator, dataset_path); + + // h2 over TLS 1.3 on H2_TLS_PORT (ALPN h2) with the baked Ed25519 cert. The cleartext server runs + // regardless, a missing cert just leaves the TLS port without a listener. + 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 = &.{.H2}, + .min_version = .TLS_1_3, + }) catch null; + + if (tls_ctx) |*tls| { + _ = std.Thread.spawn(.{}, tlsServer, .{ process.io, tls }) catch {}; + } + + // h2c cleartext on H2C_PORT under the native io_uring dispatch model. + var server = try zix.Http2.Server.init(ROUTES, .{ + .io = process.io, + .ip = LISTEN_IP, + .port = H2C_PORT, + .dispatch_model = DISPATCH_MODEL, + .kernel_backlog = KERNEL_BACKLOG, + }); + defer server.deinit(); + + try server.run(); +} From 59f3befc423b18a7b14c06d375b7351e43cd0596 Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 29 Jun 2026 07:02:06 +0700 Subject: [PATCH 2/5] retrigger validate 1 From be368c31178488fc0acdae1cba516ba67bfe925b Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 29 Jun 2026 07:02:46 +0700 Subject: [PATCH 3/5] retrigger validate 1 From 0350ca25a402487f61521cfc0f14f4ed3aeb365f Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 29 Jun 2026 20:36:35 +0700 Subject: [PATCH 4/5] adding max_streams and max_body --- frameworks/zix-http2/src/main.zig | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/frameworks/zix-http2/src/main.zig b/frameworks/zix-http2/src/main.zig index 52f151c00..f3bdc801a 100644 --- a/frameworks/zix-http2/src/main.zig +++ b/frameworks/zix-http2/src/main.zig @@ -26,6 +26,15 @@ const LISTEN_IP: []const u8 = "::"; const KERNEL_BACKLOG: u31 = 16 * 1024; const DISPATCH_MODEL: zix.Http2.DispatchModel = .URING; +/// Advertise enough concurrent streams for h2load (`-m 100`): the config default 16 would refuse the +/// surplus (REFUSED_STREAM) and cap h2c throughput. Mirrors the zix-grpc entry. +const MAX_STREAMS: usize = 128; + +/// Per-stream request body buffer. The mux pre-allocates max_streams * max_body per connection +/// (mux.zig), so 128 streams at the default 64 KiB would reserve 8 MiB. These endpoints take a tiny +/// body, so 16 KiB keeps headroom while capping the footprint at 2 MiB. Mirrors zix-grpc. +const MAX_BODY: usize = 16 * 1024; + // TLS cert / key: self-signed Ed25519 pair baked at /etc/zix-tls at image build, overridable via env. const TLS_CERT_DEFAULT: []const u8 = "/etc/zix-tls/server.crt"; const TLS_KEY_DEFAULT: []const u8 = "/etc/zix-tls/server.key"; @@ -334,13 +343,10 @@ fn prewarmStatic() void { } } -// GET /static/{file} : serve a file from /data/static, content type by extension. The first request -// reads it into memory and caches it, later requests reuse the cached bytes. The body is sent as -// chunked DATA frames since HTTP/2 caps a single frame at the peer's max frame size. -// -// Content negotiation: client accepts br and "{file}.br" exists serves brotli with content-encoding br. -// Else gzip and "{file}.gz" likewise. Otherwise the raw (identity) file. The server never compresses, -// it only serves a precompressed file already on disk. +// GET /static/{file} : serve from /data/static, content type by extension. First request reads it +// into memory and caches it (later requests reuse the bytes), body sent as chunked DATA frames +// (HTTP/2 caps a frame at the peer max). Content negotiation: .br then .gz when accepted, else the +// identity file. The server never compresses, only serves a precompressed file already on disk. fn staticHandler(_: []const u8, headers: []const zix.Http2.Header, _: []const u8, fd: std.posix.fd_t, sid: u31) void { const raw = pathFromHeaders(headers); const path = if (std.mem.indexOfScalar(u8, raw, '?')) |q| raw[0..q] else raw; @@ -404,6 +410,8 @@ fn tlsServer(io: std.Io, tls: *zix.Tls.Context) void { .tls = tls, .dispatch_model = DISPATCH_MODEL, .kernel_backlog = KERNEL_BACKLOG, + .max_streams = MAX_STREAMS, + .max_body = MAX_BODY, }) catch return; defer server.deinit(); @@ -509,6 +517,8 @@ pub fn main(process: std.process.Init) !void { .port = H2C_PORT, .dispatch_model = DISPATCH_MODEL, .kernel_backlog = KERNEL_BACKLOG, + .max_streams = MAX_STREAMS, + .max_body = MAX_BODY, }); defer server.deinit(); From a4e53278b84d074327f2643149ba9ef814233f1a Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 29 Jun 2026 20:41:29 +0700 Subject: [PATCH 5/5] re-validate zix-http2 entry