09d21a4f
build: QUIC deps vendored and pinned — the spike's script grows up
a73x 2026-08-08 14:08
Commit message
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -2,7 +2,16 @@ | |||
| 2 | # default zig is 0.17-dev. Override with ZIG=... if yours lives elsewhere. | 2 | # default zig is 0.17-dev. Override with ZIG=... if yours lives elsewhere. |
| 3 | ZIG ?= $(HOME)/Downloads/zig-x86_64-linux-0.15.2/zig | 3 | ZIG ?= $(HOME)/Downloads/zig-x86_64-linux-0.15.2/zig |
| 4 | 4 | ||
| 5 | .PHONY: build test e2e bench clean | 5 | .PHONY: build test e2e bench deps clean clean-deps |
| 6 | |||
| 7 | # The QUIC stack (deps/quic) is built on demand by build.zig, so no target | ||
| 8 | # here needs to depend on this one. It exists to make the one-time cost | ||
| 9 | # explicit and runnable on its own: the FIRST build after a clean checkout | ||
| 10 | # downloads ~30MB and takes a few minutes. `deps` warms both targets so a | ||
| 11 | # later musl cross-build (wan.sh) does not surprise anyone mid-measurement. | ||
| 12 | deps: | ||
| 13 | ./deps/quic/build-deps.sh native | ||
| 14 | ./deps/quic/build-deps.sh musl | ||
| 6 | 15 | ||
| 7 | build: | 16 | build: |
| 8 | $(ZIG) build | 17 | $(ZIG) build |
| @@ -16,5 +25,11 @@ e2e: | |||
| 16 | bench: | 25 | bench: |
| 17 | $(ZIG) build bench | 26 | $(ZIG) build bench |
| 18 | 27 | ||
| 28 | # Deliberately leaves deps/quic alone: rebuilding it needs the network and | ||
| 29 | # several minutes, and it is pinned by checksum, so a routine clean should | ||
| 30 | # not cost that. `clean-deps` is the one that does. | ||
| 19 | clean: | 31 | clean: |
| 20 | rm -rf zig-out .zig-cache | 32 | rm -rf zig-out .zig-cache |
| 33 | |||
| 34 | clean-deps: | ||
| 35 | rm -rf deps/quic/out deps/quic/work | ||
README.md
| Old | New | ||
|---|---|---|---|
| @@ -29,6 +29,14 @@ of ssh's own channel setup. | |||
| 29 | Requires Zig 0.15.x (ghostty pin); the Makefile points at the pinned | 29 | Requires Zig 0.15.x (ghostty pin); the Makefile points at the pinned |
| 30 | toolchain, override with `make ZIG=...`. | 30 | toolchain, override with `make ZIG=...`. |
| 31 | 31 | ||
| 32 | The first build also builds the vendored QUIC stack (ngtcp2 + wolfSSL, in | ||
| 33 | `deps/quic`), which **downloads ~30MB and takes a few minutes once**; every | ||
| 34 | build after that skips it. `make deps` does it on its own if you would | ||
| 35 | rather pay that cost deliberately, and `make clean` deliberately does *not* | ||
| 36 | throw it away — `make clean-deps` is the one that does. The sources are | ||
| 37 | pinned by version and sha256 and built by the same pinned Zig as everything | ||
| 38 | else; there is no prebuilt binary in the repo on purpose. | ||
| 39 | |||
| 32 | make test && make e2e # verify | 40 | make test && make e2e # verify |
| 33 | make build | 41 | make build |
| 34 | ./zig-out/bin/muxd run & # daemon | 42 | ./zig-out/bin/muxd run & # daemon |
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,8 +1,58 @@ | |||
| 1 | const std = @import("std"); | 1 | const std = @import("std"); |
| 2 | 2 | ||
| 3 | /// The vendored QUIC stack (deps/quic). Built by a script rather than by | ||
| 4 | /// addCSourceFiles, and that is a deliberate v1: wolfSSL's build generates | ||
| 5 | /// its own options header from ~200 feature switches, so compiling its | ||
| 6 | /// sources directly means reproducing that generation in Zig and keeping it | ||
| 7 | /// in step with every version bump. Script-built static libs are the honest | ||
| 8 | /// first version; addCSourceFiles is banked, and the spike's gotchas | ||
| 9 | /// (UBSan default, AES-ECB, library-sources-only) are what it will need. | ||
| 10 | /// | ||
| 11 | /// The step is cheap when the libraries exist — the script exits at once — | ||
| 12 | /// so every build can depend on it. A clean checkout's FIRST build fetches | ||
| 13 | /// ~30MB and takes a few minutes; that is documented in the script and the | ||
| 14 | /// README, and there is no prebuilt blob because "we can build it with our | ||
| 15 | /// own toolchain" is the thing M8 is proving. | ||
| 16 | fn quicDeps(b: *std.Build, target: std.Build.ResolvedTarget) struct { | ||
| 17 | step: *std.Build.Step, | ||
| 18 | dir: []const u8, | ||
| 19 | } { | ||
| 20 | const musl = target.result.abi == .musl; | ||
| 21 | const name = if (musl) "musl" else "native"; | ||
| 22 | const run = b.addSystemCommand(&.{ "deps/quic/build-deps.sh", name }); | ||
| 23 | run.setName(b.fmt("build QUIC deps ({s})", .{name})); | ||
| 24 | // Never cached by the build graph: the script's own marker file is the | ||
| 25 | // cache, and it is the only thing that knows whether the libs are there. | ||
| 26 | run.has_side_effects = true; | ||
| 27 | return .{ | ||
| 28 | .step = &run.step, | ||
| 29 | .dir = b.fmt("deps/quic/out/{s}", .{name}), | ||
| 30 | }; | ||
| 31 | } | ||
| 32 | |||
| 33 | /// Give a compile step the QUIC stack: header path, library path, and the | ||
| 34 | /// three archives in dependency order (crypto backend, core, TLS). | ||
| 35 | /// | ||
| 36 | /// Linking an archive whose symbols nothing references pulls in no objects, | ||
| 37 | /// which is why this is safe to apply before any QUIC code exists. Measured | ||
| 38 | /// rather than assumed at 2b: `nm` finds **zero** ngtcp2 or wolfSSL symbols | ||
| 39 | /// in the resulting muxd. The binary does grow by 80 bytes — deterministic | ||
| 40 | /// across rebuilds, and it is link metadata, not code, since no object from | ||
| 41 | /// those archives is present. Stated exactly because "unchanged" would have | ||
| 42 | /// been the easy sentence and it would have been false. | ||
| 43 | fn linkQuic(b: *std.Build, c: *std.Build.Step.Compile, deps: anytype) void { | ||
| 44 | c.step.dependOn(deps.step); | ||
| 45 | c.addIncludePath(b.path(b.fmt("{s}/include", .{deps.dir}))); | ||
| 46 | c.addLibraryPath(b.path(b.fmt("{s}/lib", .{deps.dir}))); | ||
| 47 | c.linkSystemLibrary2("ngtcp2_crypto_wolfssl", .{ .preferred_link_mode = .static }); | ||
| 48 | c.linkSystemLibrary2("ngtcp2", .{ .preferred_link_mode = .static }); | ||
| 49 | c.linkSystemLibrary2("wolfssl", .{ .preferred_link_mode = .static }); | ||
| 50 | } | ||
| 51 | |||
| 3 | pub fn build(b: *std.Build) void { | 52 | pub fn build(b: *std.Build) void { |
| 4 | const target = b.standardTargetOptions(.{}); | 53 | const target = b.standardTargetOptions(.{}); |
| 5 | const optimize = b.standardOptimizeOption(.{}); | 54 | const optimize = b.standardOptimizeOption(.{}); |
| 55 | const quic = quicDeps(b, target); | ||
| 6 | 56 | ||
| 7 | const ghostty_dep = b.lazyDependency("ghostty", .{ | 57 | const ghostty_dep = b.lazyDependency("ghostty", .{ |
| 8 | .target = target, | 58 | .target = target, |
| @@ -82,6 +132,9 @@ pub fn build(b: *std.Build) void { | |||
| 82 | // sections emitted by gcc >= 16 crt1.o on this system; LLD can. | 132 | // sections emitted by gcc >= 16 crt1.o on this system; LLD can. |
| 83 | exe.use_llvm = true; | 133 | exe.use_llvm = true; |
| 84 | exe.use_lld = true; | 134 | exe.use_lld = true; |
| 135 | // The daemon is the side that will listen (Task 2c). Nothing references | ||
| 136 | // these symbols yet, so nothing is pulled in. | ||
| 137 | linkQuic(b, exe, quic); | ||
| 85 | b.installArtifact(exe); | 138 | b.installArtifact(exe); |
| 86 | 139 | ||
| 87 | const mux_exe = b.addExecutable(.{ .name = "mux", .root_module = mux_mod }); | 140 | const mux_exe = b.addExecutable(.{ .name = "mux", .root_module = mux_mod }); |
| @@ -96,6 +149,13 @@ pub fn build(b: *std.Build) void { | |||
| 96 | const t = b.addTest(.{ .root_module = mod }); | 149 | const t = b.addTest(.{ .root_module = mod }); |
| 97 | t.use_llvm = true; | 150 | t.use_llvm = true; |
| 98 | t.use_lld = true; | 151 | t.use_lld = true; |
| 152 | // The server's tests are where the QUIC listener's in-process | ||
| 153 | // loopback test will live (2c), so this test binary needs the stack | ||
| 154 | // — and wiring it here is also what makes `make test` build the | ||
| 155 | // deps when they are absent. Without it the dependency reached only | ||
| 156 | // `muxd`, and a clean checkout running `make test` first would have | ||
| 157 | // found no libraries and no explanation. | ||
| 158 | if (mod == server_mod) linkQuic(b, t, quic); | ||
| 99 | test_step.dependOn(&b.addRunArtifact(t).step); | 159 | test_step.dependOn(&b.addRunArtifact(t).step); |
| 100 | } | 160 | } |
| 101 | 161 | ||
deps/quic/.gitignore
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,4 @@ | |||
| 1 | # Fetched sources, build trees and installed libs: pinned by build-deps.sh, | ||
| 2 | # rebuilt on demand, never committed. | ||
| 3 | work/ | ||
| 4 | out/ | ||
deps/quic/build-deps.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,121 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # Build the vendored QUIC stack (ngtcp2 + wolfSSL) into static libraries that | ||
| 3 | # build.zig links, using ONLY the repo's pinned Zig as the C toolchain. | ||
| 4 | # | ||
| 5 | # ./build-deps.sh <target> target: native | musl | ||
| 6 | # | ||
| 7 | # Grown up from spike/quic/build.sh (M8 Task 1), which proved this is | ||
| 8 | # possible; this version is the one the real build depends on. It is | ||
| 9 | # idempotent and cheap to re-run: if the libraries for a target are already | ||
| 10 | # present it exits immediately, which is what makes it safe for build.zig to | ||
| 11 | # invoke on every build. | ||
| 12 | # | ||
| 13 | # FIRST RUN NEEDS NETWORK. It downloads two source tarballs (~30MB) and takes | ||
| 14 | # a few minutes; every run after that is a no-op until `make clean-deps`. | ||
| 15 | # There is no offline fallback and no prebuilt blob in the repo — the | ||
| 16 | # toolchain question this milestone answered is precisely "can we build it | ||
| 17 | # ourselves", and shipping a binary would un-answer it. | ||
| 18 | set -eu | ||
| 19 | |||
| 20 | NGTCP2_VER=1.25.0 | ||
| 21 | WOLFSSL_VER=5.9.2-stable | ||
| 22 | NGTCP2_SHA=1c0843076528a87b65e9a9d455100941f4cb65d44f96c5da6ae56df146043955 | ||
| 23 | WOLFSSL_SHA=2f4ef3d4fd387a9b3191d36a6316d69116c46ff69bb9583b6c82b36d7b8ca114 | ||
| 24 | |||
| 25 | T="${1:-native}" | ||
| 26 | case "$T" in | ||
| 27 | native | musl) ;; | ||
| 28 | *) echo "usage: $0 [native|musl]" >&2; exit 2 ;; | ||
| 29 | esac | ||
| 30 | |||
| 31 | SELF="$(cd "$(dirname "$0")" && pwd)" | ||
| 32 | ZIG="${ZIG:-$HOME/Downloads/zig-x86_64-linux-0.15.2/zig}" | ||
| 33 | OUT="$SELF/out/$T" | ||
| 34 | W="$SELF/work" | ||
| 35 | |||
| 36 | # The marker: if this exists the target is built. Checked before anything | ||
| 37 | # else so build.zig can call this unconditionally. | ||
| 38 | [ -f "$OUT/lib/libngtcp2_crypto_wolfssl.a" ] && exit 0 | ||
| 39 | |||
| 40 | [ -x "$ZIG" ] || { echo "deps/quic: no zig at $ZIG (set ZIG=...)" >&2; exit 1; } | ||
| 41 | command -v cmake >/dev/null || { echo "deps/quic: cmake is required" >&2; exit 1; } | ||
| 42 | command -v curl >/dev/null || { echo "deps/quic: curl is required" >&2; exit 1; } | ||
| 43 | |||
| 44 | echo "deps/quic: building the QUIC stack for $T (first run: downloads ~30MB, takes a few minutes)" >&2 | ||
| 45 | mkdir -p "$W/src" "$W/bin" | ||
| 46 | |||
| 47 | cat > "$W/bin/zigcc-native" <<EOF | ||
| 48 | #!/bin/sh | ||
| 49 | exec $ZIG cc "\$@" | ||
| 50 | EOF | ||
| 51 | cat > "$W/bin/zigcc-musl" <<EOF | ||
| 52 | #!/bin/sh | ||
| 53 | exec $ZIG cc -target x86_64-linux-musl "\$@" | ||
| 54 | EOF | ||
| 55 | cat > "$W/bin/zigar" <<EOF | ||
| 56 | #!/bin/sh | ||
| 57 | exec $ZIG ar "\$@" | ||
| 58 | EOF | ||
| 59 | cat > "$W/bin/zigranlib" <<EOF | ||
| 60 | #!/bin/sh | ||
| 61 | exec $ZIG ranlib "\$@" | ||
| 62 | EOF | ||
| 63 | chmod +x "$W"/bin/* | ||
| 64 | |||
| 65 | fetch() { # url sha file | ||
| 66 | [ -f "$W/src/$3" ] || curl -sSL -o "$W/src/$3" "$1" | ||
| 67 | echo "$2 $W/src/$3" | sha256sum -c - >/dev/null || { | ||
| 68 | echo "deps/quic: checksum mismatch for $3 — refusing to build" >&2 | ||
| 69 | rm -f "$W/src/$3" | ||
| 70 | exit 1 | ||
| 71 | } | ||
| 72 | } | ||
| 73 | fetch "https://github.com/ngtcp2/ngtcp2/releases/download/v$NGTCP2_VER/ngtcp2-$NGTCP2_VER.tar.gz" \ | ||
| 74 | "$NGTCP2_SHA" "ngtcp2-$NGTCP2_VER.tar.gz" | ||
| 75 | fetch "https://github.com/wolfSSL/wolfssl/archive/refs/tags/v$WOLFSSL_VER.tar.gz" \ | ||
| 76 | "$WOLFSSL_SHA" "wolfssl-$WOLFSSL_VER.tar.gz" | ||
| 77 | [ -d "$W/src/ngtcp2-$NGTCP2_VER" ] || tar -C "$W/src" -xzf "$W/src/ngtcp2-$NGTCP2_VER.tar.gz" | ||
| 78 | [ -d "$W/src/wolfssl-$WOLFSSL_VER" ] || tar -C "$W/src" -xzf "$W/src/wolfssl-$WOLFSSL_VER.tar.gz" | ||
| 79 | |||
| 80 | CC="$W/bin/zigcc-$T" | ||
| 81 | XTRA="" | ||
| 82 | [ "$T" = musl ] && XTRA="-DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=x86_64" | ||
| 83 | |||
| 84 | # wolfSSL. Three flags are load-bearing, all found by link failure in the | ||
| 85 | # spike (spike/quic/README.md records the diagnosis): | ||
| 86 | # -fno-sanitize=undefined zig cc instruments C by default and the ubsan | ||
| 87 | # runtime is absent from a static musl link. | ||
| 88 | # WOLFSSL_AESECB ngtcp2's wolfSSL backend needs AES-ECB for QUIC | ||
| 89 | # header protection; wolfSSL omits it by default. | ||
| 90 | # WOLFSSL_KEYLOG_EXPORT=no STANDING BUILD RULE. Built with it on, the | ||
| 91 | # binaries write every handshake's secrets to | ||
| 92 | # ./sslkeylog.log with nothing asking them to. | ||
| 93 | # It stays off here permanently; flip it only in | ||
| 94 | # a local throwaway build when decrypting your | ||
| 95 | # own capture, and never commit that. | ||
| 96 | cmake -S "$W/src/wolfssl-$WOLFSSL_VER" -B "$W/build/wolfssl-$T" $XTRA \ | ||
| 97 | -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_FLAGS="-fno-sanitize=undefined -O2" \ | ||
| 98 | -DCMAKE_C_COMPILER="$CC" -DCMAKE_AR="$W/bin/zigar" -DCMAKE_RANLIB="$W/bin/zigranlib" \ | ||
| 99 | -DBUILD_SHARED_LIBS=OFF -DWOLFSSL_QUIC=yes -DWOLFSSL_PSK=yes \ | ||
| 100 | -DWOLFSSL_AESECB=yes -DWOLFSSL_AESCTR=yes -DWOLFSSL_KEYLOG_EXPORT=no \ | ||
| 101 | -DWOLFSSL_SESSION_TICKET=yes -DWOLFSSL_EXAMPLES=no -DWOLFSSL_CRYPT_TESTS=no \ | ||
| 102 | -DWOLFSSL_INSTALL=yes -DCMAKE_INSTALL_PREFIX="$OUT" >/dev/null | ||
| 103 | cmake --build "$W/build/wolfssl-$T" -j"$(nproc)" >/dev/null | ||
| 104 | cmake --install "$W/build/wolfssl-$T" >/dev/null | ||
| 105 | |||
| 106 | # ngtcp2 + its wolfSSL crypto backend. ENABLE_LIB_ONLY is what keeps the | ||
| 107 | # static link clean: ngtcp2's find_package(Libbrotli*) calls live inside | ||
| 108 | # `if(NOT ENABLE_LIB_ONLY)`, so a library-only build never goes looking for | ||
| 109 | # the host's shared brotli. Library sources, not example sources — the | ||
| 110 | # lesson that outlives cmake if this ever moves to addCSourceFiles. | ||
| 111 | PKG_CONFIG_PATH="$OUT/lib/pkgconfig" \ | ||
| 112 | cmake -S "$W/src/ngtcp2-$NGTCP2_VER" -B "$W/build/ngtcp2-$T" $XTRA \ | ||
| 113 | -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_FLAGS="-fno-sanitize=undefined -O2" \ | ||
| 114 | -DCMAKE_C_COMPILER="$CC" -DCMAKE_AR="$W/bin/zigar" -DCMAKE_RANLIB="$W/bin/zigranlib" \ | ||
| 115 | -DENABLE_STATIC_LIB=ON -DENABLE_SHARED_LIB=OFF -DENABLE_WOLFSSL=ON \ | ||
| 116 | -DENABLE_OPENSSL=OFF -DENABLE_GNUTLS=OFF -DENABLE_BORINGSSL=OFF \ | ||
| 117 | -DENABLE_LIB_ONLY=ON -DCMAKE_INSTALL_PREFIX="$OUT" >/dev/null | ||
| 118 | cmake --build "$W/build/ngtcp2-$T" -j"$(nproc)" >/dev/null | ||
| 119 | cmake --install "$W/build/ngtcp2-$T" >/dev/null | ||
| 120 | |||
| 121 | echo "deps/quic: $T ready in $OUT" >&2 | ||
src/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -715,6 +715,11 @@ pub const Server = struct { | |||
| 715 | /// because that module reads descriptors, and this milestone's fourth | 715 | /// because that module reads descriptors, and this milestone's fourth |
| 716 | /// kill-criterion leg is that protocol.zig comes out of it byte-for-byte | 716 | /// kill-criterion leg is that protocol.zig comes out of it byte-for-byte |
| 717 | /// unchanged. Same wire format, read from a buffer instead of an fd. | 717 | /// unchanged. Same wire format, read from a buffer instead of an fd. |
| 718 | /// Two readers of one format is a duplication, and it is watched rather | ||
| 719 | /// than tolerated: `proto.appendFrame` remains the single writer both | ||
| 720 | /// read back, and the seam test below builds its input with it — so a | ||
| 721 | /// change to the header that this reader missed fails that test rather | ||
| 722 | /// than reaching a client. | ||
| 718 | fn pushInbound(self: *Server, i: usize, bytes: []const u8) void { | 723 | fn pushInbound(self: *Server, i: usize, bytes: []const u8) void { |
| 719 | if (self.clients[i] == null) return; | 724 | if (self.clients[i] == null) return; |
| 720 | self.clients[i].?.inbound.appendSlice(self.alloc, bytes) catch { | 725 | self.clients[i].?.inbound.appendSlice(self.alloc, bytes) catch { |