a73x

09d21a4f

build: QUIC deps vendored and pinned — the spike's script grows up

a73x   2026-08-08 14:08

Commit message
build: QUIC deps vendored and pinned — the spike's script grows up

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 {