Pure-Zig QUIC (RFC 9000 / 9001 / 9002), TLS 1.3, HTTP/3, and QPACK. Current release: v1.7.48.
- Zig 0.16.x
zig build # library + server/client binaries
zig build test # unit tests
zig build examples # example programsImport as a package dependency (build.zig.zon):
.zquic = .{
.url = "https://github.com/ch4r10t33r/zquic/archive/refs/tags/v1.7.48.tar.gz",
.hash = "zquic-1.7.0-2zRc1PSAFgDCESpm-vZsUr4O02HM0dpzmVJSx5WXW6ES",
},Or fetch the latest tag automatically:
zig fetch --save "https://github.com/ch4r10t33r/zquic/archive/refs/tags/v1.7.48.tar.gz"See CHANGELOG.md for release notes. Tags publish Linux amd64 binaries via .github/workflows/release.yml.
| RFC | Title | Status |
|---|---|---|
| RFC 9000 | QUIC transport | ✅ |
| RFC 9001 | TLS for QUIC | ✅ |
| RFC 9002 | Loss detection & congestion control | ✅ |
| RFC 9114 | HTTP/3 | ✅ |
| RFC 9204 | QPACK | ✅ |
| RFC 9369 | QUIC v2 | ✅ |
All standard QUIC frame types, flow control, migration, key update, 0-RTT/resumption, ECN, and connection IDs are implemented. DPLPMTUD probing lives in src/transport/path_mtu.zig.
zquic ↔ zquic: all 13 quic-interop-runner cases pass on CI (handshake, transfer, retry, chacha20, keyupdate, resumption, zerortt, http3, connectionmigration, multiplexing, v2, ecn, rebind-port).
Cross-impl (quinn ↔ zquic): per-commit CI runs the P0 subset (handshake, transfer in both directions plus multiplexing(zquic→quinn)); the full matrix runs nightly via .github/workflows/interop-cross-impl.yml and reclassifies the known capability gaps (HTTP/3, 0-RTT, session resumption, key update, Retry, ChaCha20, port rebinding, quinn-driven multiplexing) as UNSUPPORTED rather than failed — see EXPECTED_UNSUPPORTED in that workflow for the current list. Recent quinn-interop hardening: transfer(cross-zquic-quinn) flow-control regression (#201), per-PN-space loss detector with RFC 9001 §4.9 Initial/Handshake abandonment (#211), CONNECTION_CLOSE retransmission during draining (#194), stateless reset emission with rate-limit + 41-byte trigger floor (#206), NEW_TOKEN issuance + replay (#213).
Local interop:
zig build -Doptimize=ReleaseFast -Dtarget=x86_64-linux-gnu
docker build -t zquic:interop -f interop/Dockerfile.prebuilt .
# then run quic-interop-runner against zquic:interopzquic builds for the Shadow network simulator, which gives deterministic, bit-exact replays of multi-peer scenarios — no NIC variance, no scheduler jitter. Shadow's shim intercepts at the libc layer via LD_PRELOAD, so the default pure-Zig Linux build (raw std.os.linux.* syscalls, no libc) is invisible to it; the -Dshadow=true flag fixes that:
zig build -Dtarget=x86_64-linux-gnu -Dshadow=true -Doptimize=ReleaseSafe
file zig-out/bin/server # → dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2What changes under -Dshadow=true:
link_libc = trueon Linux — dynamically linked against glibc so the Shadow shim can inject.compat.zig'sclock_gettimeandgetrandomroute through libc (gated on!builtin.link_libc) so Shadow's virtual clock + deterministic randomness apply.transport/batch_io.zigdisables thesendmmsg(2)/recvmmsg(2)batched path and falls back to per-messagesendto/recvfrom(Shadow's shim doesn't virtualize the batched syscalls).
See docs/shadow.md for a minimal shadow.yaml and the known limitations. The default no-libc Linux build is unaffected.
The stack is built for quic-interop-runner HTTP/0.9/HTTP/3, but the same TLS + QUIC core is reusable for custom ALPN and opaque stream I/O (e.g. zig-libp2p).
| Need | API |
|---|---|
| Custom ALPN | ServerConfig.alpn / ClientConfig.alpn |
| Opaque STREAM bytes | raw_application_streams = true on both sides; RawAppStreamSlot in raw_app_stream.zig |
| External UDP loop | Server.feedPacket, Client.feedPacket, processPendingWork |
| Pre-bound socket | Server.initFromSocket, Client.initFromSocket |
| Heap-allocated client | Client.initInPlace (avoids stack-sized return copies in downstream callers) |
| Open client-initiated streams | rawAllocateNextLocalBidiStream / rawAllocateNextLocalUniStream (emit STREAMS_BLOCKED on cap-hit, #188 / #205) |
| Open server-initiated streams | Server.openRawAppStream (libp2p gossip-over-any-connection, #171) |
| Send / FIN on streams | sendRawStreamData (now splits at packet-build time via sent_in_buf cursor, #199) |
| In-memory TLS certs | cert_pem / key_pem / client_cert_pem / client_key_pem on config structs (#129) |
| Congestion-control profile | CcOptions.aggressive opts into the libp2p-tuned 32·MSS IW / 10·MSS floor; default tracks RFC 9002 §7.2 / §7.6.3 (#195) |
| Conn capacity | MAX_CONNECTIONS = 256, boxed (quinn-style slab) — heap cost scales with active conns, not the cap (#209) |
Demo Endpoint uses a small fixed connection array (max_connections 8) to stay stack-friendly in tests. Production embedders should still heap-size their own connection tables via initFromSocket + feedPacket for unbounded scale.
src/
transport/io.zig Server + client event loop, HTTP/0.9 + HTTP/3
transport/raw_app_stream.zig Opaque STREAM reassembly for embedders
transport/path_mtu.zig Path MTU limits + DPLPMTUD
crypto/ AEAD, keys, QUIC-TLS adapter, session tickets
loss/ RTT, loss detection, New Reno (+ CUBIC option)
http3/ http09/ HTTP layers
packet/ frames/ Wire codecs
vendor/tls/ Vendored ianic/tls.zig (Zig 0.16)
interop/ quic-interop-runner Docker entrypoint
examples/ Small standalone demos
bench/ Loopback throughput vs quiche/ngtcp2
Varint encoding is re-exported from the shared zig-varint package.
- Linux (default): statically linked release binaries; syscalls via
std.os.linux, no OpenSSL/quictls, no libc. - Linux (
-Dshadow=true): dynamically linked against glibc with libc-mediated syscalls so the Shadow simulator's shim can inject. See docs/shadow.md. - macOS: links
libSystem(Darwin has no stable syscall ABI); source remains pure Zig.
zquic emits all diagnostics through Zig's standard logging under the .zquic
scope (std.log.scoped(.zquic)). It never writes to a file or to stderr
directly — the embedder's std_options.logFn decides what is rendered and
where.
Under sustained load the transport is intentionally chatty at warn:
per-stream send backpressure (pending-stream-send queue full), congestion/loss
notices, and connection-lost warnings are normal operational signal during
catch-up or when a peer stops reading a stream. On a busy node this floods the
main log, so the recommended convention (mirroring rust-libp2p) is to gate the
.zquic scope behind a DEBUG_QUIC environment variable — off by
default, with err always passing through so genuine failures stay visible:
// In your root module:
pub const std_options: std.Options = .{ .logFn = quicAwareLogFn };
var quic_debug = std.atomic.Value(bool).init(false); // set at startup from env
fn quicAwareLogFn(
comptime level: std.log.Level,
comptime scope: @TypeOf(.enum_literal),
comptime format: []const u8,
args: anytype,
) void {
// Gate warn/info/debug for the QUIC scope unless DEBUG_QUIC is set; err
// always passes through. (Add `.quic_runtime` etc. if you embed zig-libp2p.)
if (scope == .zquic and
@intFromEnum(level) >= @intFromEnum(std.log.Level.warn) and
!quic_debug.load(.monotonic)) return;
std.log.defaultLog(level, scope, format, args);
}
// At startup:
if (std.posix.getenv("DEBUG_QUIC")) |v| {
if (std.mem.eql(u8, v, "1") or std.mem.eql(u8, v, "true")) quic_debug.store(true, .monotonic);
}Run with DEBUG_QUIC=1 (also accepts true/yes/on) to surface the full
transport log stream when debugging; leave it unset for a quiet main log.
zeam ships exactly this gate (quicAwareLogFn in pkgs/cli/src/main.zig),
covering both .zquic and zig-libp2p's .quic_runtime family of scopes.
A standing quinn-vs-zquic gap analysis tracks remaining capability deltas:
- Tracker: #138 (closed sub-issues linked from the comment thread).
- Open at v1.7.48: RFC 9221 unreliable datagrams (#181), per-stream send buffer abstraction (#184), connection statistics expansion (#186),
ACK_FREQUENCYextension (#187), stream priority API (#191), BBR congestion controller (#192). - Shadow simulator support: Phase 1 (build flag, syscall routing, batched-I/O fallback) tracked in #216 — Phase 2 (Docker image + CI matrix) is the next step.
MIT