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* 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 {}; +}