Releases: prothegee/zix
Releases · prothegee/zix
Release list
0.4.0
0.4.0 (2026-06-19)
Update:
- io_uring churn scaling and on-ring response overflow (ADR-041):
zix.Http1.URINGteardown now rings the close (prep_close, tagged with a new sharedOpKind.close) instead of a synchronouslinux.close, recycling the connection slot first and falling back to a synchronous close only when the SQ is momentarily full. Under connection churn the synchronous close blocked the worker between connections, so the ring barely engaged its cores. With the ring close the worker keeps reaping completions across teardowns. On the 64-core box this lifts the churn cells (limited-conn, json) from far behind.EPOLLto parity or better, at a fraction of the memory, so.URINGnow reaches parity or better on every measured cell.RespSink(tcp/http1/core.zig) grows its staging buffer on overflow when backed by an allocator: the.URINGloop installs it over the per-connectionsend_bufwith a 1 MiB cap (URING_SEND_BUF_MAX), so a response larger than the staged buffer grows in place (power-of-two realloc, never shrinks, reused by the recycled connection) and still leaves as one on-ring send, instead of stalling the worker on a blocking off-ring write. The.EPOLLpath installs no grow allocator and is unchanged (flush-on-overflow).- The shared io_uring
OpKindand ring helpers moved fromsrc/tcp/io_uringtosrc/multiplexers/ring.zig. Every io_uring engine carries a.close => {}arm. Onlyzix.Http1arms the ring close for now.
- Server
iointo config andzix.Udshandler-at-init (ADR-039):zix.Tcp,zix.Udp, andzix.Udsnow carryio: std.Ioas the first config field, sorun()takes no argument, matching the five engine servers. Every zix server is now constructed with a config that carriesioand served with a no-argumentrun().zix.Udsadopts the ADR-038 factory shape:Server.init(comptime handler, config)bakes the handler into the type, and the built-inzix.Uds.echoHandleris passed explicitly. Therun(io, handler)/runWithpath is removed.- Breaking: every
zix.Tcp/zix.Udp/zix.Udsserver call site adds.io = process.ioand drops therunargument. Clients keepioas aconnect()parameter (deferred to a separate decision).
- io_uring dispatch model (
.URING, ADR-037):- New shared-nothing
.URING = 4dispatch model: same thread-per-core topology as.EPOLL(oneSO_REUSEPORTlistener and one completion ring per worker, no shared queue), but completion-based, so most syscall transitions are batched into the ring. Linux-only, falls back to.POOLon non-Linux. - Native across
zix.Http1(reference engine, plus the WebSocket pump on aBufferGroup),zix.Http,zix.Grpc(multiplexed h2), andzix.Fix(resumablecore.processFixRingper readable batch).zix.Http2folds to.POOLand thezix.Tcpper-connection handler folds to.EPOLL. - Request bodies on the ring (
zix.Http1): a chunked request body fully present in the recv buffer is decoded in place, and a body larger thanmax_recv_bufis answered then its remainder is drained off the socket with a singleMSG_TRUNCrecv (the kernel discards the bytes in place, zero copy, capped at the declared length), mirroring the.EPOLLdrain. So.URINGserves large uploads and chunked requests, not only buffered ones. - On loopback
.URINGmatches.EPOLLon throughput and total CPU, winning mainly on per-request cache locality. Prefer.EPOLLby default,.URINGfor sustained, pipelined load.
- New shared-nothing
zix.Tcpserver API reshape (ADR-038):- The handler is baked into the server type at
init, soruntakes no handler argument, mirroringzix.Http1/zix.Grpc(ADR-039 then movesiointo config, sorun()takes nothing).zix.Tcp.Serveris now a fieldless namespace with comptime constructorsinit(handler, config)/initArgs(handler, config, args)(per-connection) andinitFramed(frame_fn, config)/initFramedArgs(frame_fn, config, args)(per-frame ring). - Breaking:
runWithandrunFramedare removed. The built-in echo default is the publiczix.Tcp.echoHandler, passed explicitly. The per-connection handler runs.ASYNC/.POOL/.MIXED/.EPOLL(.URINGfolds to.EPOLL). The new per-frameFrameFncallback (initFramed) runs natively on the.URINGring.
- The handler is baked into the server type at
Http2ServerConfig.logger:- New optional
logger: ?*Loggerfield onHttp2ServerConfig, for consistency with the other server configs. When set,zix.Http2lifecycle lines route throughlogger.system(.INFO, "http2", ...)instead of the Debug-onlystd.debug.print.
- New optional
zix.Http2frame constants:- The HTTP/2 frame-type bytes are renamed from
FT_*to the spelled-outFRAME_TYPE_*(FT_DATA->FRAME_TYPE_DATA, and so on). Breaking for any code referencingzix.Http2.FT_*. - New
pub const FRAME_HEADER_LEN(9) in the h2 frame module (re-exported fromzix.Http2) names the 9-octet frame header length, replacing the inline9literals across the h2 and gRPC frame codecs.
- The HTTP/2 frame-type bytes are renamed from
- Response cache awareness (opt-in, ADR-036):
- New shared
src/utils/response_cache.zig: a per-worker, lock-free precomputed-response cache (structure-of-arrays slab, open addressing, lazy on-access TTL). Off by default, installed under.EPOLLand.URING. The other dispatch models leave it uninstalled and the API degrades to a plain send. - Five flat config fields with identical names across
Http1ServerConfig,HttpServerConfig, andGrpcServerConfig:response_cache(bool, defaultfalse),cache_max_entries(u32),cache_max_value_bytes(u32),cache_ttl_ms(u32), andcache_max_total_bytes(usize). zix.Http:res.serveCached(req)andres.sendCached(req, body, ttl)cache the full serialized response, keyed on method, path, and query.zix.Http1keepscacheLookup/cacheStore/writeWithCache.zix.Grpc(unary):ctx.serveCached(content_type)andctx.sendCached(content_type, data, ttl)cache the response message, keyed on path plus request body, re-framed per stream so HPACK and stream id stay correct.- Measured crossover near 4 KiB: heavy ~32 KiB JSON +34% throughput at c512, zero regression below ~2 KiB. See ADR-036.
- New shared
- WebSocket build-once broadcast fanout:
- New
zix.Http1.WebSocket.broadcast(conns, opcode, payload): serializes the frame once and writes the same bytes to every fd in a caller-maintained room, so a broadcast costs one serialization regardless of member count. A failed write to a dead peer is skipped (the EPOLL engine reaps that fd on its next event), and the large-payload path builds the header once and writes the payload without a staging copy. zix.Http.WebSocket.RoomMap.broadcastreuses a single staging buffer across all members instead of re-creating one per connection (build once, fan out).
- New
- Http epoll shared-nothing:
zix.Http.EPOLLwas rewritten from a centralized model (one accept thread pushing to a sharedConnQueue, pool workers popping) into a shared-nothing architecture matchingzix.Http1. Each worker binds its ownSO_REUSEPORTlistener, creates its ownepollinstance, and runs its own level-triggered event loop. The kernel distributes new connections across workers with no shared queue, no mutex, and no fd handoff.workers(notpool_size) is now the EPOLL worker count forzix.Http.0selects cpu_count.pool_sizeis silently ignored for.EPOLL(callers using.pool_size = Nwith.EPOLLmust migrate to.workers = N).- Level-triggered
EPOLLINreplacesEPOLLONESHOT. No explicit re-arm after each request: connections stay registered and re-fire when new data arrives. - Throughput: 428k to 451k req/s at c1000 (
wrk -c1000 -t4 -d10s), closing the gap vszix.Http1from 11% to 6.8%. Remaining gap is structural (arena allocation per request). See ADR-034.
- Http1 EPOLL slab, RawFn, and Date control:
zix.Http1.EPOLLnow backs each registered connection with a per-connection receive buffer slab (ConnTable), sized bymax_recv_buf, so a connection accumulates a full request without re-allocating per event.- New
zix.Http1.RawFnhandler type pluszix.Http1.Server.initRaw: a raw handler receives the connection fd and the parsed head and owns the wire directly, bypassing the managed response path for full control (streaming, custom framing). - New
send_date_headerconfig field (defaulttruefor RFC 7231 compliance). Setfalseto drop theDateheader and save 37 bytes per response on hot paths where the client does not need it. buildSimpleHeaderIntowrites the status line and headers into a caller sink, the fast path for the slab writer.
- WebSocket optimization:
- SIMD unmask:
parseFramein bothzix.Http1andzix.HttpWebSocket engines now unmasks the client payload with a 16-wide@Vector(16, u8)XOR against a replicated 4-byte mask, with a scalar tail for the remainder. Replaces the per-bytei % 4loop. - New
ws_recv_bufconfig field onHttp1ServerConfig(default0, falls back tomax_recv_buf). Set larger thanmax_recv_bufto give EPOLL WebSocket connections more room to accumulate pipelined frames before a compact and re-read. zix.Http1EPOLL WebSocket reads now drain toEAGAINper wakeup (read all available frames in one event) and coalesce writes, instead of one frame per wakeup.zix.HttpWebSocket:buildHeader(header-only framing into a caller buffer), cleanedRoomMapbroadcast path.
- SIMD unmask:
- gRPC mux per-connection staging and corking:
GrpcMuxConnnow owns a 64 KBstage_buf(was an inline 4096-byteReplyStage.buf). One streaming call of ~5000 messages (~85 KB peak) flushes in two writes, and ~100 concurrent unary replies (~6 KB) c...
0.3.0
0.3.0 (2026-06-10)
Update:
- Http1 router prefix param:
zix.Http1.Routergains.PREFIXand.PARAMroute kinds (addedRouteKindand akindfield onzix.Http1.Route, default.EXACT), reaching parity with thezix.Httprouter and itsexact > param > prefixpriority (ADR-004). Captured path params are read with the new free functionzix.Http1.pathParam(name)(a per-handler thread-local, since the Http1 handler has noRequest, see ADR-029), capped at 8 params per match.- The prefix pass now guards the boundary byte behind
startsWith. The same fix was applied to thezix.Httprouter, which read one byte past a request path shorter than a registered prefix (a panic in Debug/ReleaseSafe, a masked out-of-bounds read in ReleaseFast). - Backward compatible:
.kinddefaults to.EXACT, so existing exact-only Http1 route tables are unchanged.examples/http1_static.zignow routes/secretvia a.PREFIXroute. See ADR-033.
- Epoll max events 512:
- The epoll batch (max events drained per
epoll_wait) is raised from 256 to 512 across all native epoll servers (zix.Tcp,zix.Http,zix.Fix,zix.Grpc,zix.Http1) and unified into one named, documented file-level constantEPOLL_MAX_EVENTS: usize = 512per server. The previous mix of a lowercaseepoll_max_eventsconst and inline256literals is removed. - 512 lets a worker clear its ready-fd set in a single syscall at high connection counts: a worker holding more than 256 readable fds no longer needs a second
epoll_wait. No public API change, the constant is an internal tuned default. See ADR-032.
- The epoll batch (max events drained per
- Httpconfig naming consistency:
HttpServerConfigfield renames for API-wide consistency (defaults unchanged):max_kernel_backlogbecomeskernel_backlog(now matchingTcp,Fix,Http1,http2, andGrpc, which already used the bare name), andmax_client_requestbecomesmax_recv_buf(matchingzix.Http1).- Migration: rename the fields at the call site.
.max_kernel_backlog = Nbecomes.kernel_backlog = N, and.max_client_request = Nbecomes.max_recv_buf = N.max_allocator_sizeandmax_client_responseare unchanged (no equivalent exists outsidezix.Http).
- Http1 handler at init:
zix.Http1.Server.initnow takes the comptime handler as its first argument and bakes it into the server type, sorun()takes no argument. This matcheszix.Httpandzix.Grpc, which register routes at init. The server core stays routing-agnostic: the handler may be aRouter(routes).dispatch, a bareHandlerFn, or a middleware chain.- Migration:
Server.init(.{ ... })thenserver.run(Routes.dispatch)becomesServer.init(Routes.dispatch, .{ ... })thenserver.run().
- Grpc epoll multiplexed:
zix.Grpc.EPOLLwas rewritten from a blocking thread-per-connection pool into a shared-nothing multiplexed event loop. Each worker owns a privateSO_REUSEPORTlistener, its own epoll instance, and a private fd-indexed connection table, the kernel balances connections across workers. One worker drives many non-blocking connections through a resumable HTTP/2 state machine (GrpcMuxConn/grpcMuxOnReadable), so concurrency is bounded by connection count, not thread count.- Every route, including server-streaming, is dispatched inline on the worker under
.EPOLL(no per-stream thread, no connection write mutex). A streaming handler runs on the event loop and must stay bounded, use.ASYNCfor unbounded streams. The blockingserveGrpcConnpath is unchanged for.ASYNC/.POOL/.MIXED. pool_sizeis now the multiplexing worker count for.EPOLL(0 = cpu count), not a blocking pool size. See ADR-031.
- Grpc unary hotpath:
- Unary and streaming replies (initial HEADERS, every DATA, the trailer, and control frames) are coalesced into one
write()per readable event via a per-connectionReplyStagecork. SETTINGS_INITIAL_WINDOW_SIZEraised to 16 MB with a one-time connection-window bump, so small request bodies no longer trigger a per-DATAWINDOW_UPDATE, the connection window is replenished in bulk only past a threshold.- Buffered frame reads (a HEADERS plus DATA pair costs one
read()), and per-streambody/header_scratchmoved to per-connection backing slices sized tomax_body/max_header_scratchinstead of fixed inline arrays. - The constant reply header blocks (
:status 200+content-type: application/grpc+proto, and thegrpc-status: 0trailer) are HPACK-encoded once at comptime and memcpy'd on the hot path.HpackEncoder.writeStringnow types the Huffman result as?usizeso the encoder runs at comptime. Other content-types / statuses use the dynamic encoder. - Combined effect: unary ~110k to ~420k req/s (exceeds Kestrel at 256 connections), streaming ~2.6k to ~28k calls/s. See ADR-031.
- Unary and streaming replies (initial HEADERS, every DATA, the trailer, and control frames) are coalesced into one
- Gttp1 logger field:
Http1ServerConfig.logger: ?*Loggeradded. The server routes lifecycle lines (listening, EPOLL fallback) through it.- Per-request access logging is handler-side: the Http1 handler writes to the fd and returns void, so the server cannot observe response status or bytes. Handlers call
logger.access()themselves (examples use a module global).
- Gttp1 examples parity and completion:
- The 9 existing
http1_*examples were brought tohttp_*presentation parity (full tunable constant block, commented logger scaffolding in the basic family). - 6 new examples complete the set (15 total):
http1_manual_concurrent,http1_sse,http1_xtra_headers,http1_client,http1_timeout_resp,http1_websocket.
- The 9 existing
- Gttp1 handler timeout:
Http1ServerConfig.handler_timeout_mspluszix.Http1.setTimeout()andzix.Http1.isExpired(). The server arms a thread-local deadline before each dispatch across all four models.statusPhrasegained408 Request Timeout. See ADR-029.
- Http1 websocket:
- New
zix.Http1.WebSocketmodule: RFC 6455 frame codec (parseFrame/buildFrame/buildHeader/acceptKey) andupgrade()over raw fd I/O. - Engine-owned frame loop under
.EPOLL: a handler callsWebSocket.serve(fd, key, on_frame)to hand the connection to the epoll loop. The engine echoes viaon_frameper readable event (fn(fd, opcode, payload) void), auto-ponging ping and auto-echoing close. No worker is parked per connection. WebSocket.sendcoalesces every frame produced during one readable event into a singlewrite(), so a pipelined burst costs one syscall instead of one per frame.zix.Http1.WsFrameFnexported. Engine-owned WebSocket is.EPOLLonly: under.ASYNC/.POOLthe handoff is cleared and the connection ends. See ADR-030.
- New
- Http1 large body drain:
- Under
.EPOLL, a request body larger thanmax_recv_bufno longer returns431. The engine dispatches the handler with an empty body (large-body endpoints use the Content-Length value), then reads and discards the remaining body bytes across events so the connection stays usable for keep-alive. Bodies that fit the buffer are unchanged.
- Under
- Http client version selector:
zix.Http.Clientgained aversionconfig field (zix.Http.ClientVersion:HTTP_1,HTTP_2,HTTP_3, defaultHTTP_1).HTTP_2andHTTP_3returnerror.UnsupportedVersionuntil backends are wired. See ADR-028.
- Http1 writesimple hotpath:
zix.Http1.writeSimplenow builds the response header with a direct byte encoder (buildSimpleHeaderviaappendStatusCode/appendDec/appendBytes), replacingstd.fmt.bufPrint.- Small bodies (up to 3840 bytes) are copied with the header into one contiguous stack buffer and sent with a single
write(). Bodies above 3840 bytes fall back to inlinewritevto avoid copying a large payload. cachedDate()callsclock_gettimeonly every 256 requests via a thread-local tick counter, not per-request.- Measured ~450k to ~612k req/s at c128 vs the prior
writev-only path. See ADR-026.
- Response header default minimal:
HttpServerConfig.max_response_headersdefault lowered from.COMMON(32) to.MINIMAL(16).zix.Http1:MAX_HEADERScap 32 to 16, newHttp1ServerConfig.max_headers: u8 = 16.- Behavioral change: handlers adding 17 to 32 custom headers now hit
error.TooManyHeadersuntil the tier is raised. See ADR-027.
Fix:
- Http1 websocket epoll echo:
zix.Http1WebSocket echo did not work under.EPOLL: the handshake succeeded but no frame was ever echoed. The handler's blockingread()loop returnedEAGAINat once on the engine's non-blocking sockets. The engine-owned frame loop (WebSocket.serve, see ADR-030) replaces that pattern. Thehttp1_websocketexample now uses.EPOLL.
0.2.2
0.2.2 (2026-06-06)
Update:
- Grpc unary inline dispatch:
- Unary routes (
Route.is_server_streaming = false, the default) now dispatch synchronously on the connection thread. No per-call Task alloc, no 4 KBheader_scratchcopy, noio.asyncenqueue, no ConnMutex acquire/release. - Server-streaming routes require
is_server_streaming = trueon theRouteentry to use thread-per-stream dispatch. - New field on
zix.Grpc.Route:is_server_streaming: bool = false.
- Unary routes (
- Grpc bench fixtures:
- Added
examples/grpc_hello_req.binandexamples/grpc_location_req.bin: properly gRPC-framed binary fixtures for h2load and ghz benchmarking. - h2load and ghz benchmark commands added to all 8 gRPC server examples.
- Added
Fix:
- n/a
0.2.1
0.2.1 (2026-06-05)
Update:
- n/a
Fix:
- Grpc content type:
- https://codeberg.org/prothegee/zix/issues/67
sendGrpcErroromittedcontent-typein the trailers-only HEADERS frame. gRPC clients rejected the response with a content-type error. All HEADERS frames sent by the server now includecontent-type: application/grpc+protoper the gRPC spec.
- Grpc concurrent stream:
- https://codeberg.org/prothegee/zix/issues/68
- Concurrent server-streaming RPCs on the same h2 connection could deadlock when the TCP send buffer filled under backpressure. Each stream is now dispatched on a dedicated thread sharing a connection-level write mutex, preventing frame interleaving.
0.2.0
0.2.0 (2026-06-02)
Update:
- Adding TCP raw
- Adding gRPC h2c
- Adding FIX (over TCP)
- Adding EPOLL dispatch model
- ASYNC is default dispatch model
- Handler/router (Http & gRPC) now use comptime
- Documentation split into English (en) and Bahasa (id)
Fix:
- n/a
0.1.0
0.1.0 (2026-05-16)
Update:
- Initial release, Zig 0.16.x network library (minimum_zig_version: 0.16.0-dev.2974+83c7aba12):
- HTTP:
- Server with three dispatch models: POOL, ASYNC, MIXED
- Router with exact, param, and prefix matching
- Middleware (comptime, zero-allocation)
- WebSocket upgrade
- Server-Sent Events (SSE)
- Multipart upload
- Static file serving
- HTTP client
- UDP:
- Generic server and client over user-defined packet type
- Broadcast peer snapshot per packet
- Unix Domain Sockets (UDS):
- Framed server and client
- Channel:
- In-process ring-buffer message passing, generic over element type
- Utils:
- File save helper, MIME type resolution
- HTTP:
*Fix:
- n/a