From f91d785acb94b270b4e7d4825e62fe181c755dbf Mon Sep 17 00:00:00 2001 From: prothegee Date: Sun, 12 Jul 2026 17:01:18 +0700 Subject: [PATCH 1/5] zix http2 entry --- frameworks/zix-http2/.gitignore | 4 + frameworks/zix-http2/Dockerfile | 72 ++++++ 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/handler.zig | 365 +++++++++++++++++++++++++++ frameworks/zix-http2/src/main.zig | 155 ++++++++++++ 8 files changed, 821 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/handler.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..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..40b93d748 --- /dev/null +++ b/frameworks/zix-http2/src/main.zig @@ -0,0 +1,155 @@ +//! 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 alloc_dataset = std.heap.ArenaAllocator.init(std.heap.smp_allocator); + defer alloc_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(alloc_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 = 16 * 1024, + }); + defer server.deinit(); + + try server.run(); +} From 1be7e50fa91c41b85056c84505afc96983930a02 Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 13 Jul 2026 01:58:40 +0700 Subject: [PATCH 2/5] rename allocator --- frameworks/zix-http2/src/main.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frameworks/zix-http2/src/main.zig b/frameworks/zix-http2/src/main.zig index 40b93d748..39c4f0c2c 100644 --- a/frameworks/zix-http2/src/main.zig +++ b/frameworks/zix-http2/src/main.zig @@ -114,13 +114,13 @@ pub fn main(process: std.process.Init) !void { // held across a file open on the first request for each name). prewarmStatic(); - var alloc_dataset = std.heap.ArenaAllocator.init(std.heap.smp_allocator); - defer alloc_dataset.deinit(); + 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(alloc_dataset.allocator(), dataset_path); + 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); From 9ad4142ee2e743d43e146c830fce73f96f285559 Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 13 Jul 2026 17:03:21 +0700 Subject: [PATCH 3/5] tuned 20260713-170300 --- frameworks/zix-http2/src/main.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frameworks/zix-http2/src/main.zig b/frameworks/zix-http2/src/main.zig index 39c4f0c2c..cebbae0bd 100644 --- a/frameworks/zix-http2/src/main.zig +++ b/frameworks/zix-http2/src/main.zig @@ -147,7 +147,12 @@ pub fn main(process: std.process.Init) !void { .tls = &tls, .tls_port = TLS_PORT, .dispatch_model = DISPATCH_MODEL, - .kernel_backlog = 16 * 1024, + .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(); From 64112b6e892ce8f24d4c8f36d1f598b440976b60 Mon Sep 17 00:00:00 2001 From: prothegee Date: Sat, 15 Aug 2026 19:50:41 +0700 Subject: [PATCH 4/5] refactor(zix-http2): use engine router - Drop manual static cache, dataset loading, and pre-serialization. - Move handlers to separate modules and use zix comptime Router. - Serve static files via engine's public_dir instead of custom code. - Dockerfile: use zig fetch --save, bump zix to 0.5.x, fix cert ext. --- frameworks/zix-http2/Dockerfile | 29 +- frameworks/zix-http2/build.zig.zon.template | 12 + frameworks/zix-http2/meta.json | 6 +- frameworks/zix-http2/src/dataset.zig | 167 -------- frameworks/zix-http2/src/handler.zig | 365 ------------------ .../zix-http2/src/handlers/baseline.zig | 61 +++ frameworks/zix-http2/src/handlers/json.zig | 142 +++++++ frameworks/zix-http2/src/main.zig | 159 ++------ frameworks/zix-http2/src/shared/dataset.zig | 148 +++++++ frameworks/zix-http2/src/shared/paths.zig | 13 + frameworks/zix-http2/src/shared/response.zig | 18 + 11 files changed, 441 insertions(+), 679 deletions(-) create mode 100644 frameworks/zix-http2/build.zig.zon.template delete mode 100644 frameworks/zix-http2/src/dataset.zig delete mode 100644 frameworks/zix-http2/src/handler.zig create mode 100644 frameworks/zix-http2/src/handlers/baseline.zig create mode 100644 frameworks/zix-http2/src/handlers/json.zig create mode 100644 frameworks/zix-http2/src/shared/dataset.zig create mode 100644 frameworks/zix-http2/src/shared/paths.zig create mode 100644 frameworks/zix-http2/src/shared/response.zig diff --git a/frameworks/zix-http2/Dockerfile b/frameworks/zix-http2/Dockerfile index e8861129b..bd3fa884b 100644 --- a/frameworks/zix-http2/Dockerfile +++ b/frameworks/zix-http2/Dockerfile @@ -6,7 +6,7 @@ ARG TARGETARCH ARG RETRY_DELAY=3 ARG TIMEOUT_SEC=180 ARG ZIG_VERSION=0.16.0 -ARG ZIX_VERSION=0.5.x-rc1 +ARG ZIX_VERSION=0.5.x RUN apk add --no-cache ca-certificates curl git tar xz openssl WORKDIR /server @@ -21,20 +21,16 @@ RUN set -eu; \ mv "/opt/zig-${ZIG_ARCH}-linux-${ZIG_VERSION}" /opt/zig ENV PATH="/opt/zig:${PATH}" -COPY build.zig build.zig.zon ./ +COPY build.zig ./ +COPY build.zig.zon.template ./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. +# Resolve zix with zig fetch git+https 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; \ + if $(zig fetch --save ${url}); then \ return 0; \ fi; \ echo "zix: ${url} attempt ${attempt}/${RETRY} failed" >&2; \ @@ -43,10 +39,10 @@ RUN set -eu; \ 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; } + fetch_zix "git+https://codeberg.org/prothegee/zix#${ZIX_VERSION}" \ + || { echo "zix: codeberg exhausted ${RETRY} attempts" >&2; \ + fetch_zix "git+https://github.com/prothegee/zix#${ZIX_VERSION}"; } \ + || { 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. @@ -56,13 +52,16 @@ RUN set -eu; \ 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 + zig build \ + -Dtarget="${ZIG_TARGET}" \ + -Dcpu="${ZIG_CPU}+aes+pclmul+adx" \ + --release=fast --summary failures; # 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 \ + openssl req -new -x509 -key /etc/zix-tls/server.key -out /etc/zix-tls/server.cert \ -days 3650 -subj "/CN=localhost" FROM alpine:3.20 diff --git a/frameworks/zix-http2/build.zig.zon.template b/frameworks/zix-http2/build.zig.zon.template new file mode 100644 index 000000000..f0964a5bb --- /dev/null +++ b/frameworks/zix-http2/build.zig.zon.template @@ -0,0 +1,12 @@ +.{ + .name = .zix_http2_arena, + .version = "0.1.0", + .fingerprint = 0x62ec9e924e0a448, + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, + .dependencies = .{}, +} diff --git a/frameworks/zix-http2/meta.json b/frameworks/zix-http2/meta.json index cc6e4c66d..e68bdf091 100644 --- a/frameworks/zix-http2/meta.json +++ b/frameworks/zix-http2/meta.json @@ -3,7 +3,7 @@ "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.", + "description": "HTTP/2 on the zix.Http2 raw engine, .URING dispatch: per-worker SO_REUSEPORT io_uring loops, per-worker stream-slot pool, comptime Router. Dual listener: h2c 8082, h2 over TLS 1.3 (ALPN h2) on 8443, one process. No response cache and no startup pre-serialization anywhere: json serializes every field per request, static is the engine's public_dir.", "repo": "https://github.com/prothegee/zix", "enabled": true, "tests": [ @@ -12,5 +12,7 @@ "baseline-h2c", "json-h2c" ], - "maintainers": ["prothegee"] + "maintainers": [ + "prothegee" + ] } diff --git a/frameworks/zix-http2/src/dataset.zig b/frameworks/zix-http2/src/dataset.zig deleted file mode 100644 index a8f5357d3..000000000 --- a/frameworks/zix-http2/src/dataset.zig +++ /dev/null @@ -1,167 +0,0 @@ -//! 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 deleted file mode 100644 index 6447fca42..000000000 --- a/frameworks/zix-http2/src/handler.zig +++ /dev/null @@ -1,365 +0,0 @@ -//! 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/handlers/baseline.zig b/frameworks/zix-http2/src/handlers/baseline.zig new file mode 100644 index 000000000..d00bf3636 --- /dev/null +++ b/frameworks/zix-http2/src/handlers/baseline.zig @@ -0,0 +1,61 @@ +//! GET/POST /baseline2?a=..&b=.. : sum the query values, plus the POST body +//! read as an integer. Answers the sum as text/plain. + +const std = @import("std"); +const zix = @import("zix"); + +// --------------------------------------------------------- // + +pub const PATH = "/baseline2"; + +/// Longest decimal the sum can print, sign included. +const SUM_BUF: usize = 32; + +// --------------------------------------------------------- // + +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, '=')) |equals| { + sum += std.fmt.parseInt(i64, pair[equals + 1 ..], 10) catch 0; + } + } + + return sum; +} + +/// Read a leading integer out of `text`, skipping surrounding whitespace and +/// stopping at the first non-digit. A body with no digits reads as 0. +fn parseIntLoose(text: []const u8) i64 { + var index: usize = 0; + while (index < text.len and (text[index] == ' ' or text[index] == '\t' or text[index] == '\r' or text[index] == '\n')) index += 1; + + var negative = false; + if (index < text.len and text[index] == '-') { + negative = true; + index += 1; + } + + var value: i64 = 0; + while (index < text.len and text[index] >= '0' and text[index] <= '9') : (index += 1) { + value = value * 10 + (text[index] - '0'); + } + + return if (negative) -value else value; +} + +// --------------------------------------------------------- // + +pub fn RESPONSE(req: *zix.Http2.Request, res: *zix.Http2.Response, _: *zix.Http2.Context) !void { + var sum: i64 = sumQuery(req.query); + if (std.mem.eql(u8, req.method, "POST") and req.body.len > 0) { + sum += parseIntLoose(req.body); + } + + var body_buf: [SUM_BUF]u8 = undefined; + const out = std.fmt.bufPrint(&body_buf, "{d}", .{sum}) catch return; + + try res.sendText(out); +} diff --git a/frameworks/zix-http2/src/handlers/json.zig b/frameworks/zix-http2/src/handlers/json.zig new file mode 100644 index 000000000..37f001145 --- /dev/null +++ b/frameworks/zix-http2/src/handlers/json.zig @@ -0,0 +1,142 @@ +//! GET /json/{count}?m=M : render `count` dataset items, each carrying +//! total = price * quantity * m. +//! +//! Note: +//! - The body is serialized on every request through jzon on .GENERATED, the +//! write path its own benchmark puts at 4.31x the std-backed one. Nothing is +//! memoized and nothing is pre-rendered at startup, so the work the json-h2c +//! profile measures actually happens per request. + +const std = @import("std"); +const zix = @import("zix"); + +const dataset = @import("../shared/dataset.zig"); +const response = @import("../shared/response.zig"); + +const jzon = zix.jzon; + +// --------------------------------------------------------- // + +pub const PATH = "/json"; + +/// Per-worker render buffer. Sized above the shipped fixture's worst case, +/// which the first load checks. +const BODY_BUF: usize = 128 * 1024; + +/// One item as the response carries it: the fixture's own fields, plus the +/// per-request total. +const ResponseItem = struct { + id: i64, + name: []const u8, + category: []const u8, + price: i64, + quantity: i64, + active: bool, + tags: []const []const u8, + rating: dataset.Rating, + total: u64, +}; + +const Body = struct { + items: []const ResponseItem, + count: u8, +}; + +threadlocal var tl_body: [BODY_BUF]u8 = undefined; +threadlocal var tl_items: [dataset.ITEM_COUNT]ResponseItem = undefined; + +var g_dataset: dataset.Dataset = undefined; + +/// Load state, so the fixture is read once however many workers ask at once. +/// A failed read is remembered, so a broken fixture is not re-read per request. +const Load = enum(u8) { UNSET, READY, FAILED }; + +var g_load: std.atomic.Value(u8) = .init(@intFromEnum(Load.UNSET)); +var g_load_lock: std.atomic.Value(bool) = .init(false); + +// --------------------------------------------------------- // + +/// The fixture, read on the first request that needs it. +/// +/// Note: +/// - Lazy rather than a startup call, so main stays the route table plus the +/// server. Workers race here on the first request and one of them wins the +/// lock, the rest see READY. +/// +/// Return: +/// - []const dataset.Item +/// - null when the fixture is missing or does not match the schema +fn items() ?[]const dataset.Item { + switch (@as(Load, @enumFromInt(g_load.load(.acquire)))) { + .READY => return g_dataset.items, + .FAILED => return null, + .UNSET => {}, + } + + while (g_load_lock.swap(true, .acquire)) std.atomic.spinLoopHint(); + defer g_load_lock.store(false, .release); + + switch (@as(Load, @enumFromInt(g_load.load(.acquire)))) { + .READY => return g_dataset.items, + .FAILED => return null, + .UNSET => {}, + } + + g_dataset = dataset.load(std.heap.smp_allocator) catch { + g_load.store(@intFromEnum(Load.FAILED), .release); + + return null; + }; + if (dataset.bodyMaxBytes(g_dataset.items) > BODY_BUF) { + g_load.store(@intFromEnum(Load.FAILED), .release); + + return null; + } + + g_load.store(@intFromEnum(Load.READY), .release); + + return g_dataset.items; +} + +// --------------------------------------------------------- // + +/// Build the response value for (count, m) and render it into `out`. +fn renderBody(out: []u8, rows: []const dataset.Item, count: u8, multiplier: u64) !usize { + for (rows[0..count], 0..) |item, index| { + tl_items[index] = .{ + .id = item.id, + .name = item.name, + .category = item.category, + .price = item.price, + .quantity = item.quantity, + .active = item.active, + .tags = item.tags, + .rating = item.rating, + .total = @as(u64, @intCast(item.price * item.quantity)) * multiplier, + }; + } + + return jzon.serialize(out, Body{ .items = tl_items[0..count], .count = count }, .{ + .strategy = .GENERATED, + }); +} + +pub fn RESPONSE(req: *zix.Http2.Request, res: *zix.Http2.Response, ctx: *zix.Http2.Context) !void { + // The PREFIX route also matches a bare /json with no trailing slash, which + // would slice out of bounds below. + if (req.path.len < PATH.len + 1) return response.badRequest(ctx.fd, ctx.sid); + + const rows = items() orelse return response.serviceUnavailable(ctx.fd, ctx.sid); + + const count = std.fmt.parseInt(u8, req.path[PATH.len + 1 ..], 10) catch return response.badRequest(ctx.fd, ctx.sid); + if (count < 1 or count > dataset.ITEM_COUNT) return response.badRequest(ctx.fd, ctx.sid); + + const multiplier: u64 = if (req.queryParam("m")) |raw| + std.fmt.parseInt(u64, raw, 10) catch 1 + else + 1; + + const body_len = renderBody(&tl_body, rows, count, multiplier) catch return response.badRequest(ctx.fd, ctx.sid); + + try res.sendJson(tl_body[0..body_len]); +} diff --git a/frameworks/zix-http2/src/main.zig b/frameworks/zix-http2/src/main.zig index cebbae0bd..f2981865c 100644 --- a/frameworks/zix-http2/src/main.zig +++ b/frameworks/zix-http2/src/main.zig @@ -1,152 +1,51 @@ -//! HttpArena: zix-http2 +//! 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). +//! zix.Http2 (.URING), Router-only: every request goes through the engine's +//! frame path and the comptime Router, one handler module per route +//! (src/handlers/). One server, two listeners through tls_port: h2c on 8082 +//! and h2 over TLS 1.3 (ALPN h2) on 8443, from the same worker fleet. +//! /static is served by the engine from public_dir. 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 baseline = @import("handlers/baseline.zig"); +const json = @import("handlers/json.zig"); - 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 paths = @import("shared/paths.zig"); // --------------------------------------------------------- // -const Routes = &[_]zix.Http2.Route{ - .{ .path = "/baseline2", .handler = handler.baseline }, - .{ .path = "/json", .handler = handler.json, .kind = .PREFIX }, - .{ .path = "/static", .handler = handler.static, .kind = .PREFIX }, -}; +const Routes = zix.Http2.Router(&[_]zix.Http2.Route{ + .{ .path = baseline.PATH, .handler = baseline.RESPONSE }, + .{ .path = json.PATH, .handler = json.RESPONSE, .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_alloc = std.heap.ArenaAllocator.init(std.heap.smp_allocator); + defer tls_alloc.deinit(); - var tls = zix.Tls.Context.init(allocator_tls.allocator(), process.io, .{ - .cert_path = TLS_CERT_DEFAULT, - .key_path = TLS_KEY_DEFAULT, + var tls = zix.Tls.Context.init(tls_alloc.allocator(), process.io, .{ + .cert_path = paths.TLS_CERT, + .key_path = paths.TLS_KEY, .alpn = &.{.H2}, .min_version = .TLS_1_3, }) catch |e| { - std.debug.print("Error tls context: {}\n", .{e}); - return; + return e; }; - 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, .{ + var server = zix.Http2.Server.init(Routes.dispatch, .{ .io = process.io, - .ip = IP, - .port = PORT, + .ip = "::", + .port = 8082, + .workers = 0, + .dispatch_model = .URING, .tls = &tls, - .tls_port = TLS_PORT, - .dispatch_model = DISPATCH_MODEL, + .tls_port = 8443, + // + .public_dir = paths.DATA_DIR, + .public_dir_cache_ttl_ms = 30 * 1000, + // .kernel_backlog = 24 * 1024, .max_streams = 1024, .max_frame_size = 24 * 1024, diff --git a/frameworks/zix-http2/src/shared/dataset.zig b/frameworks/zix-http2/src/shared/dataset.zig new file mode 100644 index 000000000..1b7d90950 --- /dev/null +++ b/frameworks/zix-http2/src/shared/dataset.zig @@ -0,0 +1,148 @@ +//! Dataset loader for the /json endpoint. +//! +//! Loads the fixed 50-item benchmark dataset once at startup and keeps it as +//! TYPED values: integers stay integers, strings stay raw strings. +//! +//! Note: +//! - Nothing is pre-rendered. The json handler serializes every field on every +//! request, which is the work the json profile is defined to measure. A +//! startup pre-render would turn the hot path into a memcpy of bytes built +//! once, so this loader deliberately does not build any. +//! - The read runs through jzon on .GENERATED, the path its own benchmark puts +//! at 4.47x the std-backed one. Strings borrow the document rather than being +//! copied, which is safe here because both live in the arena below. + +const std = @import("std"); +const zix = @import("zix"); + +const paths = @import("paths.zig"); + +const jzon = zix.jzon; + +// --------------------------------------------------------- // + +/// Items the fixture carries. The /json route takes a count in 1..ITEM_COUNT. +pub const ITEM_COUNT: u8 = 50; + +/// Upper bound on the fixture file. The shipped dataset.json is about 12 KB. +const FILE_MAX_BYTES: usize = 4 * 1024 * 1024; + +// --------------------------------------------------------- // + +pub const Rating = struct { + score: i64, + count: i64, +}; + +/// One dataset row, exactly the fixture's schema and key order. +pub const Item = struct { + id: i64, + name: []const u8, + category: []const u8, + price: i64, + quantity: i64, + active: bool, + tags: []const []const u8, + rating: Rating, +}; + +pub const Dataset = struct { + items: []const Item, + arena: std.heap.ArenaAllocator, + + pub fn deinit(self: *Dataset) void { + self.arena.deinit(); + } +}; + +// --------------------------------------------------------- // + +/// Longest body one render of ITEM_COUNT items can produce, measured from the +/// loaded values. The json handler reserves this before rendering. +/// +/// Note: +/// - Every string is budgeted at its escaped worst case, so a fixture carrying +/// quotes or control bytes still fits. +pub fn bodyMaxBytes(items: []const Item) usize { + // {"items":[ ... ],"count":NN} + var total: usize = "{\"items\":[".len + "],\"count\":".len + INT_MAX_DIGITS + 1; + + for (items) |item| { + total += itemMaxBytes(item) + 1; // the separating comma + } + + return total; +} + +/// Longest decimal an i64 or u64 can print, sign included. +const INT_MAX_DIGITS: usize = 24; + +/// Bytes one escaped character can expand to (`\u00xx`). +const ESCAPE_MAX_EXPANSION: usize = 6; + +fn itemMaxBytes(item: Item) usize { + // The literal field names, punctuation, and braces of one object. + const FIXED: usize = 160; + + var total: usize = FIXED; + total += item.name.len * ESCAPE_MAX_EXPANSION; + total += item.category.len * ESCAPE_MAX_EXPANSION; + total += INT_MAX_DIGITS * 6; // id, price, quantity, score, count, total + + for (item.tags) |tag| { + total += tag.len * ESCAPE_MAX_EXPANSION + 3; // quotes plus comma + } + + return total; +} + +// --------------------------------------------------------- // + +fn readFileAlloc(allocator: 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(allocator); + + try buf.ensureTotalCapacity(allocator, 64 * 1024); + while (buf.items.len < max) { + try buf.ensureUnusedCapacity(allocator, 32 * 1024); + + const read = try std.posix.read(fd, buf.unusedCapacitySlice()); + if (read == 0) break; + + buf.items.len += read; + } + + return buf.toOwnedSlice(allocator); +} + +/// Read and decode the fixture. +/// +/// Return: +/// - Dataset (caller owns it, call deinit) +/// - error.JzonMissingField and friends when the fixture and Item disagree +pub fn load(gpa: std.mem.Allocator) !Dataset { + var arena = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + + const allocator = arena.allocator(); + const raw = try readFileAlloc(allocator, paths.DATASET, FILE_MAX_BYTES); + + // BORROW is safe: `raw` and the parsed value share this arena, so the + // document outlives everything pointing into it. + const items = try jzon.deserialize([]const Item, allocator, raw, .{ + .strategy = .GENERATED, + .strings = .BORROW, + }); + if (items.len != ITEM_COUNT) return error.BadDataset; + + return .{ .items = items, .arena = arena }; +} diff --git a/frameworks/zix-http2/src/shared/paths.zig b/frameworks/zix-http2/src/shared/paths.zig new file mode 100644 index 000000000..07354fe3d --- /dev/null +++ b/frameworks/zix-http2/src/shared/paths.zig @@ -0,0 +1,13 @@ +//! Where the fixtures and the certificate are, for every module that needs one. +//! +//! Note: +//! - Constants, the way the HttpArena entries name /data and /etc/zix-tls. The +//! entry names the path and the harness guarantees it: there a Dockerfile and +//! a compose mount put the files in place + +pub const DATA_DIR: []const u8 = "/data"; + +pub const DATASET: []const u8 = "/data/dataset.json"; + +pub const TLS_CERT: []const u8 = "/etc/zix-tls/server.cert"; +pub const TLS_KEY: []const u8 = "/etc/zix-tls/server.key"; diff --git a/frameworks/zix-http2/src/shared/response.zig b/frameworks/zix-http2/src/shared/response.zig new file mode 100644 index 000000000..b2ca05eb6 --- /dev/null +++ b/frameworks/zix-http2/src/shared/response.zig @@ -0,0 +1,18 @@ +//! Shared error responders for the handlers: 400, 404, and 503. + +const std = @import("std"); +const zix = @import("zix"); + +// --------------------------------------------------------- // + +pub fn badRequest(fd: std.posix.fd_t, sid: u31) void { + zix.Http2.sendResponseFD(fd, sid, 400, "text/plain", "Bad Request") catch {}; +} + +pub fn notFound(fd: std.posix.fd_t, sid: u31) void { + zix.Http2.sendResponseFD(fd, sid, 404, "text/plain", "Not Found") catch {}; +} + +pub fn serviceUnavailable(fd: std.posix.fd_t, sid: u31) void { + zix.Http2.sendResponseFD(fd, sid, 503, "text/plain", "Service Unavailable") catch {}; +} From a609bf10c1d9fd73daa7b75339f369920b2bd173 Mon Sep 17 00:00:00 2001 From: prothegee Date: Sat, 15 Aug 2026 20:15:28 +0700 Subject: [PATCH 5/5] adding zig suffix but in current --- frameworks/zix-http2/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/frameworks/zix-http2/.gitignore b/frameworks/zix-http2/.gitignore index 595d20d1a..0e6a877fe 100644 --- a/frameworks/zix-http2/.gitignore +++ b/frameworks/zix-http2/.gitignore @@ -2,3 +2,4 @@ zig-out zig-package vendor +/zig*