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..e8861129b --- /dev/null +++ b/frameworks/zix-http2/Dockerfile @@ -0,0 +1,72 @@ +# syntax=docker/dockerfile:1.7 + +FROM alpine:3.20 AS build +ARG RETRY=6 +ARG TARGETARCH +ARG RETRY_DELAY=3 +ARG TIMEOUT_SEC=180 +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 + +WORKDIR /server +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 -fSL -m ${TIMEOUT_SEC} "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}" + +COPY build.zig build.zig.zon ./ +COPY src ./src + +# Resolve zix with zig fetch (hash-verified branch tarball, no git), +# codeberg first then github, RETRY attempts each. +# The shipped build.zig.zon points .zix at vendor/zix for local staging: +# this step swaps that one line for the fetched url + hash, +# so a remote build needs no vendor directory. +RUN set -eu; \ + fetch_zix() { \ + url="$1"; attempt=1; \ + while [ "${attempt}" -le "${RETRY}" ]; do \ + if hash="$(zig fetch "${url}")"; then \ + sed -i "s|\.path = \"vendor/zix\",|.url = \"${url}\", .hash = \"${hash}\",|" build.zig.zon; \ + return 0; \ + fi; \ + echo "zix: ${url} attempt ${attempt}/${RETRY} failed" >&2; \ + attempt=$((attempt + 1)); \ + [ "${attempt}" -le "${RETRY}" ] && sleep "${RETRY_DELAY}"; \ + done; \ + return 1; \ + }; \ + fetch_zix "https://codeberg.org/prothegee/zix/archive/${ZIX_VERSION}.tar.gz" \ + || { echo "zix: codeberg exhausted ${RETRY} attempts, trying github" >&2; \ + fetch_zix "https://github.com/prothegee/zix/archive/refs/heads/${ZIX_VERSION}.tar.gz"; } \ + || { echo "zix: github exhausted ${RETRY} attempts" >&2; exit 1; } + +# +aes+pclmul: x86_64_v3 omits AES-NI / PCLMUL, +# so zix TLS would compile the ~40x slower software AES-GCM. +# +adx speeds the RSA / Montgomery path. Every x86_64_v3 CPU has them, so it is safe. +RUN set -eu; \ + 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. +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 /server/zig-out/bin/zix-http2 /zix-http2 +COPY --from=build /etc/zix-tls /etc/zix-tls +EXPOSE 8443/tcp 8443/udp +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..600346fdb --- /dev/null +++ b/frameworks/zix-http2/build.zig.zon @@ -0,0 +1,16 @@ +.{ + .name = .zix_http2_arena, + .version = "0.1.0", + .fingerprint = 0x62ec9e924e0a448, + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, + .dependencies = .{ + .zix = .{ + .path = "vendor/zix", + }, + }, +} diff --git a/frameworks/zix-http2/meta.json b/frameworks/zix-http2/meta.json new file mode 100644 index 000000000..cc6e4c66d --- /dev/null +++ b/frameworks/zix-http2/meta.json @@ -0,0 +1,16 @@ +{ + "display_name": "zix", + "language": "Zig", + "type": "engine", + "engine": "zix.Http2 URING Dispatch Model", + "description": "Zig HTTP/2 server on the zix.Http2 raw engine, .URING dispatch: shared-nothing per-core io_uring with SO_REUSEPORT, per-worker stream-slot pool. Dual listener (config.tls_port): h2c 8082 plus h2 over TLS 1.3 (ALPN h2) on 8443, one process.", + "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/handler.zig b/frameworks/zix-http2/src/handler.zig new file mode 100644 index 000000000..6447fca42 --- /dev/null +++ b/frameworks/zix-http2/src/handler.zig @@ -0,0 +1,365 @@ +//! HttpArena: zix +//! +//! Handler file. zero-copy static file serving and lock-free caching. +//! Supports Brotli/Gzip negotiation, pipelined responses, and cached JSON generation. +//! Optimized for benchmarks via minimal allocations, +//! pre-computed headers, and direct FD handling. + +const std = @import("std"); +const zix = @import("zix"); +const dataset = @import("dataset.zig"); + +// --------------------------------------------------------- // + +/// Static cache name cap. Fixture names are short, anything longer is a 404. +pub const STATIC_NAME_MAX: usize = 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. +pub const STATIC_CACHE_MAX: usize = 128; + +// --------------------------------------------------------- // + +/// 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, +}; + +/// One cached static file: the bytes read into memory once plus its content type. ok is false for a +/// missing file (caches the 404 so a bad path is not re-probed). content_encoding is "" for identity, +/// 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, +}; + +/// Content type plus content encoding for a cached static name. A ".br" / ".gz" suffix reports that +/// encoding, with the content type taken from the stripped name ("vendor.js.br" -> javascript). +const StaticMeta = struct { + content_type: []const u8, + content_encoding: []const u8, +}; + +// --------------------------------------------------------- // + +// Per-worker scratch for the JSON body (largest, count 50, tops out near 12 KiB). +threadlocal var json_body_buf: [32 * 1024]u8 = undefined; + +// 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 name during warmup). +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); + +// 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. +pub var g_static_base: []const u8 = "/data/static/"; +pub var g_static_base_buf: [256]u8 = undefined; + +// --------------------------------------------------------- // + +/// Must initialize in init main. +pub var g_dataset: dataset.Dataset = undefined; + +// --------------------------------------------------------- // + +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; +} + +// --------------------------------------------------------- // + +fn notFound(fd: std.posix.fd_t, sid: u31) void { + zix.Http2.sendResponseFD(fd, sid, 404, "text/plain", "Not Found") catch {}; +} + +fn badRequest(fd: std.posix.fd_t, sid: u31) void { + zix.Http2.sendResponseFD(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 "/"; +} + +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"; +} + +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 }; +} + +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]; +} + +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 = "" }; +} + +/// 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. +pub 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; +} + +/// Send a 200 body through the flow-controlled streaming writer, which frames it into DATA chunks and +/// paces by the peer WINDOW_UPDATE. content_encoding is emitted only when non-empty. +fn sendH2File(fd: std.posix.fd_t, sid: u31, content_type: []const u8, content_encoding: []const u8, bytes: []const u8) void { + // The cached bytes are process-lifetime, so the mux may reference and pace them by WINDOW_UPDATE. + zix.Http2.sendResponseStreamFD(fd, sid, 200, content_type, content_encoding, bytes); +} + +// --------------------------------------------------------- // + +// GET/POST /baseline2?a=..&b=.. : sum query values plus the POST body as an integer, returns text/plain. +pub fn baseline(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.sendResponseFD(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 sendResponseFD. +pub fn json(_: []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.sendResponseFD(fd, sid, 200, "application/json", buf[0..pos]) catch {}; +} + +// GET /static/{file} : serve from /data/static, content type by extension, body cached on first read. +// Negotiates .br then .gz when accepted, else identity. Never compresses, only serves a precompressed +// file already on disk. +pub fn static(_: []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); +} + diff --git a/frameworks/zix-http2/src/main.zig b/frameworks/zix-http2/src/main.zig new file mode 100644 index 000000000..cebbae0bd --- /dev/null +++ b/frameworks/zix-http2/src/main.zig @@ -0,0 +1,160 @@ +//! HttpArena: zix-http2 +//! +//! zix HTTP/2 entry point on the zix.Http2 engine (no std.http). +//! ONE server, two listeners through config.tls_port (dual listener): +//! - h2c cleartext on 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 TLS_PORT +//! (ALPN h2, self-signed Ed25519 cert at /etc/zix-tls), +//! terminated on the same per-core rings: no second launch, +//! no doubled workers or fd tables. 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 handler = @import("handler.zig"); + +// --------------------------------------------------------- // + +const IP: []const u8 = "::"; +const PORT: u16 = 8082; +const DISPATCH_MODEL: zix.Http2.DispatchModel = .URING; + +const TLS_PORT: u16 = 8443; +const TLS_CERT_DEFAULT: []const u8 = "/etc/zix-tls/server.crt"; +const TLS_KEY_DEFAULT: []const u8 = "/etc/zix-tls/server.key"; + +// --------------------------------------------------------- // + +/// Populate the static cache once at startup, +/// single-threaded, warming every candidate the handler probes (.br, .gz, identity) +/// so the request path only hits the lock-free lookup. +/// Without it the first request for each name inserts +/// under the spinlock while opening the file. +fn prewarmStatic() void { + var base_buf: [512]u8 = undefined; + var base = handler.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 (.br, .gz, identity). + // A missing variant caches a null 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 > handler.STATIC_NAME_MAX) continue; + + var cand_buf: [handler.STATIC_NAME_MAX + 3]u8 = undefined; + if (std.fmt.bufPrint(&cand_buf, "{s}.br", .{stem})) |cand| { + _ = handler.resolveStatic(cand); + } else |_| {} + if (std.fmt.bufPrint(&cand_buf, "{s}.gz", .{stem})) |cand| { + _ = handler.resolveStatic(cand); + } else |_| {} + _ = handler.resolveStatic(stem); + } + } +} + +// --------------------------------------------------------- // + +const Routes = &[_]zix.Http2.Route{ + .{ .path = "/baseline2", .handler = handler.baseline }, + .{ .path = "/json", .handler = handler.json, .kind = .PREFIX }, + .{ .path = "/static", .handler = handler.static, .kind = .PREFIX }, +}; + +pub fn main(process: std.process.Init) !void { + // Elevate scheduling priority (setpriority -19). Fails silently when the + // process lacks CAP_SYS_NICE, so no special capability is required for correctness. + _ = std.os.linux.syscall3(.setpriority, 0, 0, @as(usize, @bitCast(@as(isize, -19)))); + + // Warm the static cache before any worker serves, + // so the request path is lock-free (no spinlock + // held across a file open on the first request for each name). + prewarmStatic(); + + var allocator_dataset = std.heap.ArenaAllocator.init(std.heap.smp_allocator); + defer allocator_dataset.deinit(); + + var dataset_path_buf: [512]u8 = undefined; + const data_dir = "/data"; + const dataset_path = try std.fmt.bufPrint(&dataset_path_buf, "{s}/dataset.json", .{data_dir}); + handler.g_dataset = try dataset.load(allocator_dataset.allocator(), dataset_path); + handler.g_static_base = std.fmt.bufPrint(&handler.g_static_base_buf, "{s}/static/", .{data_dir}) catch "/data/static/"; + + var allocator_tls = std.heap.ArenaAllocator.init(std.heap.smp_allocator); + defer allocator_tls.deinit(); + + var tls = zix.Tls.Context.init(allocator_tls.allocator(), process.io, .{ + .cert_path = TLS_CERT_DEFAULT, + .key_path = TLS_KEY_DEFAULT, + .alpn = &.{.H2}, + .min_version = .TLS_1_3, + }) catch |e| { + std.debug.print("Error tls context: {}\n", .{e}); + return; + }; + defer tls.deinit(); + + // Dual listener (config.tls_port): ONE server serves h2c on PORT and h2 + // over TLS on TLS_PORT from the same .URING worker fleet (TLS terminated + // on-ring), instead of a second full launch doubling workers and caches. + var server = zix.Http2.Server.init(Routes, .{ + .io = process.io, + .ip = IP, + .port = PORT, + .tls = &tls, + .tls_port = TLS_PORT, + .dispatch_model = DISPATCH_MODEL, + .kernel_backlog = 24 * 1024, + .max_streams = 1024, + .max_frame_size = 24 * 1024, + .max_recv_buf = 64 * 1024, + .max_body = 32 * 1024, + .tls_write_buf_initial_bytes = 32 * 1024, + }); + defer server.deinit(); + + try server.run(); +}