Releases: zoxy-io/zrk
Release list
v2.4.2
A bug-fix release, and the bug is a number: under a ramp, achieved_rate
reported the wrong thing. It was requests / duration_s in every mode, which
for -R A:B is the midpoint of the offered range — a figure that describes no
part of the run. -R100:1000 reported ~550 whether the server held 1000 all the
way up or fell over at 200 (#69).
The rate a ramp exists to find is now what achieved_rate reports. Nothing else
about zrk's interface changes: no new flags, no fields renamed or removed.
The number a ramp was run to find
Under -R A:B, achieved_rate is now the run's tail — the throughput over
the last --interval — and bytes_per_sec covers the same window, so the two
stay proportional and still divide out to bytes-per-request. Constant load and
--closed (which cannot ramp) keep the whole-run average, where it was never
misleading. The average remains requests / duration_s, both of which are still
in the report.
rate_ratio follows, and divides by the load offered across that window —
never by target_rate_end. This matters more than it sounds. achieved_rate is
an average over a window during which the ramp keeps climbing, while
target_rate_end is the schedule at the final instant; the two differ by half a
window of slope, and dividing one by the other books a ramp that missed nothing
as short by exactly that much. On -R100:1000 -d6s with a 1s window it is the
difference between 0.9999 and 0.925 for the same run.
Both rows below come from the same run — 2.4.1's figures are recomputed from
requests / duration_s, which is what it printed — so what separates them is
the definition and not the machine.
Against a loopback server it serves comfortably, -c 8 -d 6s -R100:1000,
3300 requests over 6.000s:
achieved_rate |
rate_ratio |
|
|---|---|---|
| 2.4.1 | 550.00 | 1.0000 |
| 2.4.2 | 924.90 | 0.9999 |
Both ratios are right — the old one held the average against the ramp's own
average — but only the new rate answers "what did it sustain". The difference is
sharper where the server cannot keep up. -c 64 -d 5s -R1000:200000, 247,839
requests over 5.001s:
achieved_rate |
rate_ratio |
|
|---|---|---|
| 2.4.1 | 49557.89 | 0.4931 |
| 2.4.2 | 55869.03 | 0.3102 |
55869 req/s is the max sustained rate, and 0.31 is how far up the ramp it got.
The 2.4.1 pair says neither: 49558 is an average over a range that started at
1000, and 0.49 is that average over the range's own midpoint. Read alone, 0.49
suggests a server that managed about half the offered load throughout; it in
fact kept the schedule perfectly for the first stretch and then flattened.
Until now this number was only recoverable from the last --timeseries row,
which meant writing a file to read one figure out of it. That still works, and
achieved_rate_end below makes it unnecessary.
The panel was red on a ramp it should not have been
The same arithmetic was in the live dashboard, and worse there: it compared a
windowed achieved against the schedule at the instant. Under a ramp those
differ by half a window of slope by construction, so a target keeping its
schedule perfectly still showed achieved in red — not only on the final frame,
but for as long as that gap exceeded the 5% the threshold allows.
The gap is fixed while the offered rate climbs, so it bites hardest early. At
-R100:1000 with a 1s window it is 15 req/s over 30s and 110 req/s over 4s:
the 30s ramp read red from the 2s warm-up guard until the schedule passed
300 req/s about seven seconds in, and the 4s ramp — where 110 never falls under
5% of an offered rate that tops out at 1000 — read red from the guard to the
final frame.
Both sides of that comparison now come from the same window, so a kept ramp
reads offered 888 req/s achieved 888 req/s and stays uncoloured, while a
target that genuinely falls behind still goes red. The final frame also stopped
swapping in whole-run averages, which used to make the transfer figure jump on
the last repaint.
The time series had it too
--timeseries rows carried target_rate sampled at the instant the window
closed, next to an achieved_rate averaged across it. Piped into the README's
jplot achieved_rate+target_rate, a run that missed nothing drew a permanent
gap between the two lines. The rows now offer the schedule averaged across the
window, which is what docs/output.md had described all along:
t=1.0 target= 175.0 achieved= 174.9 t=4.0 target= 625.0 achieved= 625.0
t=2.0 target= 325.1 achieved= 326.0 t=5.0 target= 775.0 achieved= 775.0
t=3.0 target= 475.0 achieved= 474.1 t=6.0 target= 925.0 achieved= 924.9
Where a window actually sat
A window closes at the last progress row, which is not duration_s: the two
differ by the fleet join, and by most of an --interval when a signal cuts the
run between rows, since an interrupt raises no row of its own. Reading a ramp's
schedule at duration_s therefore judges the window further up the ramp than it
ran.
A -R100:1000 -d60s --interval 5s run interrupted 4s after its last row was
scored 4s too high — 0.86 for a ramp that missed nothing. It now reports
rate_ratio_end 0.9998, with end_window_s 5.000 against a duration_s of
24.009.
New JSON fields
Additive, all of them:
| field | what it is |
|---|---|
achieved_rate_end |
always the tail, ramp or not, so a consumer can read one key without first working out whether -R A:B was passed |
rate_ratio_end |
that rate over the load offered during its window |
end_window_s |
how long the window was |
target_rate_end |
top level: the offered rate the ramp climbed to |
config.target_rate_end |
the -R endpoints as given — the config section carried only the start |
config.interval_s |
the stats window, and so the basis for everything above |
Under a ramp achieved_rate_end and rate_ratio_end are the same two numbers
as the top-level pair. Under constant load they are the last window rather than
the whole run, which is how a server that degraded partway through a run shows
up at all.
Embedders get end_rate, end_bytes_per_sec, end_window_s and
end_window_at_s on runner.Report; report.writeJson takes them as a
report.Run rather than a tail of positional parameters.
Upgrading
Every field emitted by 2.4.1 keeps its name and type. Two change meaning,
and only under -R A:B:
achieved_rate— the tail, not the whole-run averagebytes_per_sec— the same window, for the same reason
A harness that ramps and stores achieved_rate as a throughput figure wants the
new number; that is the fix. A harness that wants the old one should compute
requests / duration_s, which is exactly what 2.4.1 reported and both fields
are still there. Constant-rate and --closed runs are unaffected: their
achieved_rate is the whole-run average as before.
--timeseries rows keep every field. target_rate shifts by half a window of
slope under a ramp and is unchanged under constant load.
Verified
285 tests pass and zig fmt --check is clean. Eight tests are new, covering the
ramp tail, the constant-load path, an interrupted run's window anchor, a
report.Run built with no window, the time-series row's offered rate, the panel
not reddening on a kept ramp, and the window arithmetic itself.
Live runs against a loopback server: ramp, saturating ramp, constant rate,
--closed, a duration that is not a whole number of intervals, a real SIGINT
mid-window, and the dashboard under a pty. --version reports 2.4.2 from the
built binary.
v2.4.1
A dependency release: zssl moves from 992ee9d to b6a6553, 83 commits.
Nothing about zrk's interface changes — no new flags, no output changes, no
JSON fields added or renamed. What moves is the TLS underneath: a full
handshake costs about a third less CPU, and a post-handshake record carrying
two messages no longer fails the connection.
The handshake got cheaper
Measured here rather than inherited from zssl's benchmark, because zssl's
number is a library microbenchmark and zrk's question is what a request
costs.
Against a loopback TLS 1.3 server with a P-256 ECDSA leaf and full chain
verification on, in --disable-keepalive mode so every request pays for its
own handshake:
| user CPU per request | |
|---|---|
| 2.4.0 | 956.8 µs |
| 2.4.1 | 644.4 µs |
312 µs per handshake, a 32.7% cut. Three 20-second runs per build,
alternating, -R 400 -c 32 -t 2, about 22,500 requests each; user CPU is the
zrk process's own, so the server's cost is not in it.
Almost all of it is one change. zssl's verifyEcdsa ran on
std.crypto.sign.ecdsa and now calls libcrypto: on zssl's bench machine Zig's
P-256 verifier is 333.8 µs against libcrypto's 44.6 µs, which was two thirds of
a full handshake. The rest is zssl's sans-I/O boundary moving. zssl's own
figure for the pair is handshake_full going 495 µs to 175.26 µs, and the
312 µs measured here is what survives of that inside a real request.
Two limits on that number. It only appears where a handshake is per-request —
a keepalive run does one handshake per connection and does not move
measurably — and it only becomes throughput where zrk is the bottleneck rather
than the target. On the same loopback server a closed-loop ceiling went 659 to
896 req/s, but both runs logged errors because the Python server was saturated,
so read that as a direction and not as a number.
Servers with an RSA leaf see less of this: the RSA verification path was
already in libcrypto.
One record, more than one message
§5.1 lets a record hold several handshake messages, and both Go and OpenSSL
pack a NewSessionTicket with a KeyUpdate. The old zssl returned exactly one
Event per record and then refused a non-empty assembler, so the second
message came back UnexpectedMessage and zrk dropped the connection —
legal traffic from two of the most common servers there are, counted as an
error.
BoringSSL's BoGo runner found it as zssl's finding 1. handleRecord now
returns ?Event and a new drain yields the rest of what a record carried;
zrk's two record loops — the handshake and the application read — drain until
null. A run of KeyUpdate requests is answered once now instead of once per
message.
This one rests on zssl's gate rather than on a zrk reproduction. The OpenSSL
server on hand sends its tickets in separate records, so the packing that
triggers it was not reachable locally.
What else the bump carries
zssl was, at the old pin, a stack that interoperated with OpenSSL and
std.crypto.tls and said of itself that it "has not been adversarially
tested". At the new one it runs under three hostile corpora: BoGo (324
passing), tlsfuzzer (22 of 62 scripts, driving zssl's server half), and
TLS-Anvil (115 passing). Still no external audit.
The client-side fixes those gates produced, all of which zrk now has: extensions
we never offered are refused; §5.4's cap on the inner plaintext is enforced;
empty records and KeyUpdates are bounded rather than unbounded; a record
interleaved with a handshake fragment is refused; so is a length the message
type cannot have; a record refused at its header now sends the alert it owes;
duplicate extensions are refused as a pre-pass; the ChangeCipherSpec payload is
read instead of assumed; user_canceled is ignored and bounded while other
warning alerts are refused; and a ticket's lifetime is enforced against a
supplied clock.
What this release does not take up
zssl's client can answer a HelloRetryRequest now, but only when the embedder
supplies Config.retry_key_share_private — the library will not invent a key.
zrk does not supply one, so it still refuses a retry exactly as 2.4.0 did, and
a server that insists on a group zrk did not share still fails the handshake.
zrk offers x25519 only, so that remains reachable.
Likewise 0-RTT: zssl can offer early data now, and zrk offers none.
Both are zrk-side work and neither is in this release.
Verified
267 tests pass and zig fmt --check is clean. Live runs against example.com,
cloudflare.com and go.dev — HTTP/1.1 and h2, and a Go server for the
post-handshake path — completed with errs=0.
No tests were added. The change is a pin and its two call sites, and driving a
packed record through them needs a TLS peer harness zrk does not have; zssl
gates that behaviour on its own side, which is where the code is.
v2.4.0
A new flag, --disable-keepalive: close the connection after every response
and reconnect for the next request — one TCP connection per request, the
ab/oha model. Opt-in, and the only way to measure what connection setup
costs a server that would rather not let you.
Why the header isn't enough
The obvious way to ask for this is -H 'Connection: close', and it does go out
on the wire. What it does not do is decide anything. zrk learns whether a
socket may be reused from the response: keep_alive starts as
version == HTTP/1.1 — true — and only flips on the server's own Connection
header, or on a body that is close-delimited. Nothing about the request enters
into it.
So the header reconnects only against servers that honour it. Servers that
ignore it keep answering keep-alive, zrk keeps reusing the socket, and they
run at full keep-alive speed while their better-behaved neighbours pay for a
connection per request. That is backwards for the one comparison anybody
reaches for the header to make.
This is not a corner case. In the-benchmarker/web-frameworks, 27 of ~350
frameworks ignore Connection: close — the whole httpbeast-derived Nim family,
the Workerman/Swoole PHP family, agoo-c, may_minihttp, httpz, mist — and they
are among the fastest servers in that field, not the slowest.
What the flag does
Closes from our side after every response, whatever the response said. The
decision is ours because leaving it to the server is exactly the bug above:
every target pays the same price, so the numbers are comparable.
The request advertises Connection: close too, so a compliant server can
release its end with the response instead of parking an idle socket until its
own keep-alive timeout. An explicit -H Connection: ... still wins — the flag
governs our socket, -H governs the bytes, and telling a server keep-alive
while closing anyway stays available as a probe of how it copes.
Against a server that answers Connection: keep-alive and never closes,
-c 8 --closed -d 3s:
| throughput | |
|---|---|
| default | 59450 req/s |
-H 'Connection: close' |
59890 req/s |
--disable-keepalive |
5448 req/s |
The middle row is the point: no effect at all, because the server never agreed.
Expect socket errors under this mode on a long run — connection churn exhausts
the client's ephemeral port range, and that ceiling is part of what per-request
connections actually cost. They are reported, not hidden.
Not with --http2
Rejected as a usage error. Connection is a malformed field in HTTP/2 (RFC
9113 §8.2.2, which is why zrk's h2 header block omits it), and one stream per
connection would measure the handshake rather than the protocol.
Also
--format json gains config.disable_keepalive, so a harness collecting both
modes can tell the two apart in the result files rather than tracking it
alongside. Additive: every previously emitted field keeps its name, type and
meaning.
Upgrading
Nothing changes unless you pass --disable-keepalive; keep-alive remains the
default, as it is in HTTP/1.1. zig build test covers 267 tests — 4 new,
exercising the flag end to end: CLI validation and the h2 rejection, the header
it emits and -H taking precedence over it, and a live socket run against a
server that answers keep-alive and never closes, where the connection has to
be retired anyway.
v2.3.0
The per-interval time series is now something an external plotter can consume
directly, so reporting stays outside the binary. Nothing about what zrk
measures changes: pacing, coordinated-omission correction and every number in
the report mean exactly what they did in 2.2.2.
Piping the series into a plotter
--timeseries - streams the NDJSON rows to stdout instead of a file, so they
go straight into a streaming plotter such as jplot,
which reads newline-delimited JSON and addresses fields by dotted path:
zrk -c50 -R1000 -d5m --timeseries - http://127.0.0.1:8080/ \
| jplot achieved_rate+target_rate \
latency_us.p50+latency_us.p90+latency_us.p99 \
error_rateThere is no jaggr stage in that pipeline, unlike vegeta's. Aggregating raw
per-request samples into per-interval percentiles is what zrk already does
in-process, with real HdrHistograms — the rows arrive pre-aggregated and
lossless.
stdout then belongs to the row stream, so the dashboard is suppressed and the
final report goes to --output if set, else stderr. That last part is not
cosmetic: without it the plain append-only lines interleave with the rows and
the reader dies on the first one it meets — [ 0.5s] 100 req/s decodes as
a JSON array, then fails on the s.
Pair it with -o when the report is machine-read. --format json --timeseries - puts the JSON summary on stderr alongside the human notices,
so a parser reading stderr whole can trip over a trailing SLO-breach line;
--format json -o result.json --timeseries - keeps all three streams apart.
What the rows carry now
{"t":1.006,"target_rate":480.0,"achieved_rate":476.2,"requests":476,"errors":3,"error_rate":0.006263,"errors_by_kind":{"connect":0,"read":0,"write":0,"timeout":1,"deadline":2,"non_2xx_3xx":0},"bytes":58852,"bytes_per_sec":58501.0,"max_schedule_lag_us":18524,"latency_us":{"p50":245,"p90":669,"p99":1745,"p99_9":2401,"max":2401}}errors_by_kind splits the errors scalar the way the final summary's
errors object does. A window whose tail broke into deadline misses and one
that broke into connect failures are different findings, and the single number
cannot tell them apart — stacked, they say how a run failed, not just that
it did.
error_rate is that window's failure fraction, computed exactly like the
summary's top-level error_rate and so directly comparable to it and to the
--max-error-rate gate. Plot this rather than the raw errors count: it sits
on a fixed 0..1 axis whatever --interval is set to, where the count silently
rescales with the window. The two share their arithmetic, so they cannot drift.
The gauge that is not a delta
Every count in a row is that interval's delta, so the rows sum to the run.
max_schedule_lag_us is deliberately the exception: it is a running peak
aggregated by max, not a tally, so there is nothing to difference. The row
carries the cumulative high-water mark as of that interval — the same gauge
the summary reports.
An interval-local peak would mean the connections resetting the gauge on the
row cadence, which would cost the final report its true peak. Read the series
as a staircase instead: each riser dates the window in which the client fell
further behind its schedule than it ever had before, which is what puts the
onset of a backlog on the same time axis as the latency it explains.
Redirected output no longer clobbers itself
Every writer onto a shared stream is now streaming rather than positional.
.init pwrites from byte 0 on each fresh Writer — right for a file just
created, wrong for stdout or stderr, where earlier writes have already advanced
the offset. The same hazard was fixed for writeAll in 2.2.0; these are the
remaining paths.
One of them predates this release: zrk --format json >>file wrote the
summary from byte 0 rather than appending, silently overwriting whatever was
already in the file. --timeseries - >>file had the same flaw, and with stderr
redirected to a file an SLO-breach notice could land in the middle of the
report it followed — corrupting the JSON summary outright. All three are fixed
by picking the writer mode from whether --output actually opened a file.
Compatibility
Existing consumers are unaffected. errors stays a scalar count and every
previously emitted field keeps its name, type and meaning; the new fields are
additions. --timeseries <file> behaves exactly as before — only the literal
argument - is newly special.
v2.2.2
--closed runs 17-19% faster, and paced runs shed the same overhead. Nothing
about what zrk measures changes: pacing, coordinated-omission correction and
every number in the report mean exactly what they did in 2.2.1.
What was slow
Every request armed its own wire timeout by spawning a concurrent task that
slept to the bound, then cancelling and joining it once the response landed.
--timeout defaults to 2s and --closed never turned it off, so the
pacing-free mode paid for a task spawn, a cancel and a join on every single
request — to enforce a deadline that, on a healthy server, never came close to
firing.
Measured against nginx at 256 connections over 4 threads, that cost 0.173
context switches and 1.42us of user CPU per request. Running with
--timeout 0 — no wire timeout at all — was 16-22% faster purely by not doing
it.
What changed
Requests are strictly sequential on a connection: at most one is ever on the
wire. So one watcher per connection can enforce every request's deadline, and
the request loop only publishes the in-flight deadline and withdraws it.
The connection now holds that deadline alongside a counter bumped on each arm
and disarm. The watcher sleeps to the deadline and, finding the counter
unchanged, knows the request it timed never left the wire and is safe to abort.
Keying on the counter rather than on the deadline value keeps that sound when
two sends land in the same nanosecond.
A busy connection wakes the watcher about once per timeout period instead of
once per request. Per-request cost becomes four atomic stores: 0.00086 context
switches and 0.70us of user CPU, a 200x drop in the former.
Throughput rises 17.0% at 256 connections and 18.8% at 512 — all of the
headroom --timeout 0 showed at 256, and 85% of it at 512, with the timeout
still enforced.
The timeout still works
This was a mechanism change, not a feature removal, and that distinction is
the point. In --closed there is no send schedule behind a stalled request,
so a server that accepts connections and never answers has nothing else to
give it away: without the timeout those connections would simply stop issuing
requests, and the run would report a lower rate with zero errors — a hung
server reading as a slightly slower one, and --max-error-rate and
--slo-p99 passing on it.
Verified against a peer that accepts and never responds: timeouts are still
raised, still recorded as timeout errors, and still tear the connection
down.
Also
Two bounds became one. The wire timeout runs from the actual send and the
--deadline-abort bound from the scheduled send, but both are absolute
timestamps — so the earlier one binds, a single deadline replaces the two
concurrent timers, and which one fired is recovered by comparison. And since
the timeout runs from the actual send, only a send that paced re-reads the
clock; a send that fired immediately, which is every send in --closed,
reuses the timestamp it already had.
zio moves to 38206678. Most of what that picks up is select and channel
protocol work zrk's connection path never touches, but one commit matters
here: the runtime's cached clock snapshot refreshed before its backend poll,
so a duration timer armed after a poll that slept was backdated by the whole
wait and fired on the next scan instead of at its deadline. The new watcher
arms exactly such a timer. Its own deadline re-check meant no request was ever
wrongly aborted, but an idle connection's watcher could re-arm in a spin until
the snapshot caught up. It measures as no throughput change, as expected for
select and channel work.
zig build test covers 243 tests, all passing, plus
zig fmt --check clean across four cross-compiled targets.
v2.2.1
The live dashboard no longer flickers. Each repaint is now wrapped in
synchronized output (DEC private mode 2026), so the terminal holds its display
still and presents the panel as one finished frame.
Nothing about what zrk measures changes. Requests, pacing, latency recording
and every number in the report are identical to 2.2.0.
What you were seeing
The panel repaints by moving to its first line, erasing everything below, and
drawing the whole thing again. Between the erase and the last cell there is a
window where the terminal is free to put a blanked or half-filled panel on
screen — and it takes that window on every frame, twelve and a half times a
second at the default --refresh 80ms.
How bad it looked scaled with the panel. A colored spectrogram cell costs
about 17 bytes once the SGR and its reset are counted, so a wide terminal
sends up to ~15KB of cells per frame, landing wherever the terminal happens to
draw them. Narrow terminals and NO_COLOR runs got off lighter, which is part
of why this survived as long as it did.
Mode 2026 closes that window: the terminal buffers everything between the
opening and closing sequences and swaps it in at once.
If your terminal doesn't support it
Then nothing changes for you. Both sequences are unknown private modes to a
terminal that doesn't implement them, and unknown private modes are ignored —
so there is no capability query, no TERM sniffing, and no fallback path to
get wrong.
--plain, a piped stdout, and --output are untouched, as before: those
paths emit no escape sequences at all, and the redirectable report still
carries none.
Also
zig build test covers 243 tests. One is new, and it pins that a frame is
opened and closed exactly once, that the erase happens inside that bracket
rather than before it, and that the bracket adds no lines — the in-place
repaint depends on knowing exactly how tall the last frame was, and a stray
newline in the wrong place is how that gets silently wrong.
v2.2.0
A new flag, --closed: send each connection's next request the instant its
previous response completes, instead of pacing to a fixed -R schedule. The
classic wrk/ab model, opt-in, and the first way to point zrk at a target
without already knowing a rate worth measuring against.
Why
-R is right when you have a rate to test against — a regression gate, an
SLO. It has no good answer for "what's the most this service can actually
sustain at this concurrency?" Guess low and you've only confirmed the service
beats your guess. Guess high and the backlog from that guess dominates: with
nothing to correct against but a target the server never agreed to, the
coordinated-omission-corrected latency histogram fills with queueing delay
that has nothing to do with the server's own response time.
That failure mode is not hypothetical. Driving a real Node/Express server with
a fixed -R well above its ceiling produced a 98% error rate — almost
entirely --deadline misses from chasing a target the server never had a
chance at — with every latency percentile pinned to the deadline itself.
--closed against the same server found the same ~14.9k req/s ceiling
independently measured elsewhere, with a clean p50 of 71µs.
What you get, and what you give up
Coordinated-omission correction needs an intended send time to correct
against. --closed has none — the next send is "now," always — so the
correction doesn't apply, and zrk says so: the final report reads "latency
(closed-loop round-trip)," not "corrected." What comes back is real, not
adjusted: however fast -c connections and the server, between them, actually
managed.
--format json stays schema-compatible. target_rate mirrors achieved_rate
and rate_ratio is always 1.0000 under --closed (config.closed: true),
so a harness that doesn't know about the flag yet still gets coherent numbers
instead of a comparison against the unrelated -R default.
--closed is rejected alongside a ramp (-R A:B) or --deadline — both
presuppose a schedule to ramp along or fall behind, and closed-loop has
neither.
Also
- Fixed:
stdout/stderrwrites used positional mode, so two sequential
writes to the same redirected file (the usage-error message, then the
usage block) each started at byte 0 and the second silently clobbered the
first — invisible against a terminal or pipe, real the moment output went
to a log file. Found while chasing why--closed's new validation errors
vanished under2> filebut not2>&1 | head.
Upgrading
Nothing changes unless you pass --closed. zig build test covers 241
tests — 8 new, exercising the flag end to end: CLI validation, the schedule
itself, a live socket run, and the JSON report.
v2.1.1
HTTP/1.1 response parsing now comes from
zurl instead of the parser zrk hand-rolled.
What zrk measures does not change. Requests are byte-identical, latency is
recorded the same way, and Transfer/sec counts the same bytes — that last one
verified against the old parser rather than assumed, at 87.00 bytes per request
over ~4000 round trips against a server writing exactly 87.
What could bite you
Framing is stricter now, and three malformed shapes that 2.1.0 accepted are
read errors in 2.1.1. All three are servers doing something wrong, but if your
target is one of them the symptom is a run that suddenly reports read errors
where it used to report results:
- Bare LF line endings in the head. RFC 9112 says CRLF; the old scanner
tolerated LF alone. If a target does this, you will see it immediately and
everywhere, not intermittently. - A head over 16 KiB in total. The old parser bounded the longest single
header line; zurl scans the head as one contiguous slice, so the connection
read buffer bounds the whole head. Many small headers can now add up to
HeaderTooLongwhere each line individually fit. - Conflicting
Content-Length. Two different lengths on one response is
the request-smuggling primitive, and it is refused rather than silently
resolved to the last one. Duplicates that agree still pass.
Nothing else about a well-formed response changed. Chunked bodies, trailers,
bodyless statuses, Transfer-Encoding beating Content-Length, HEAD framing
and keep-alive all behave as they did.
Why change something that worked
This was the third copy of HTTP/1.1 framing in the org, and the least complete
one had the subtlest job. zurl exists because the other two were wrong in ways
a shared parser makes structurally impossible: one read a chunked body as plain
text for months, splicing hex chunk-size lines into whatever metric line
straddled a 2 KiB boundary; the other leaked on timeout because it could not
cancel a thread. zrk's copy was correct, and being correct in a third place is
still three places to be correct in.
zurl's own design docs set the bar for this — zrk adopts nothing from here
until these benchmarks say parity — and it was measured before it was taken.
The parser came in at 0.55–0.85× the hand-rolled one's cost depending on
response shape. You will not see that in a benchmark: a load generator's inner
loop is syscall-bound, and this is tens of nanoseconds inside a round trip
measured in microseconds.
Only the parser moved. Building the request stays in zrk, because zrk builds it
once and replays the bytes, where zurl re-serializes per call — 93–103 ns
against 2.6 ns, on a path that runs millions of times per run. The HTTP/2 path
is untouched; zurl does not speak it.
Also
zig build test covers 233 tests. Three of them are new, and they pin the
stricter cases above so those stay deliberate rather than incidental.
v2.1.0
https:// benchmarks now run on OpenSSL 3.5.7 instead of BoringSSL. Same
protocol, same code above it, a different implementation underneath — if you
compare TLS numbers across this release, compare them knowing that. Cleartext
benchmarks are untouched.
Why change it again
2.0.0 moved from std.crypto.tls to ztls over a Zig-built BoringSSL, because
HTTP/2 over TLS needs ALPN and std.crypto.tls has none. That worked. It also
left zrk and zoxy — which share a TLS stack,
a benchmark harness and a comparability rule — sitting on two different
libcryptos.
Only one of the two could move. BoringSSL has no CRYPTO_set_mem_functions,
and zoxy installs a fixed libcrypto heap through exactly that API; without it
the proxy refuses to start rather than run without its allocation budget. So
the shared libcrypto had to be OpenSSL, and zrk is the side that moves.
zrk gains nothing functionally from this — it installs no allocation hooks, and
BoringSSL served it perfectly well. What it gains is not being the odd one out:
one libcrypto, one set of CVEs to track, one thing to audit.
What it is
zoxy-io/openssl — OpenSSL 3.5.7, the
LTS line, supported to April 2030 — carrying
allyourcodebase/openssl#5
for the aarch64 assembly upstream ships only for x86_64.
Still one static binary per platform, still four platforms, still
cross-compiled from a single runner.
Also
- The release workflow builds the four targets in parallel (15 minutes down to
about 5) and can be rehearsed without publishing anything.
Upgrading
Nothing to do. No flags, output or behaviour change. If you pin zrk beside
zoxy for comparable runs, move both.
v2.0.0
zrk speaks HTTP/2.
zrk --http2 http://host/path # h2c, prior knowledge
zrk --http2 https://host/path # ALPN; fails if the server declines h2One request is in flight per connection, exactly as over HTTP/1.1. -c, the
pacing, the coordinated-omission correction, the deadline shedding and every
latency number keep the meaning they had — only the wire format changed. That is
deliberate, and multiplexing is tracked separately in #50 because it is a
measurement question before it is a code change.
Breaking: no more Windows binaries
If you run zrk on Windows, this release does not include a binary for you, and
v1.4.3 remains the last one that does.
HTTP/2 over TLS is selected by ALPN, which std.crypto.tls does not implement,
so --http2 could only ever have worked over cleartext. Getting it required a
TLS stack that does — ztls — and ztls is
Linux and macOS by design: its entropy shim is a compile error anywhere else.
This cost a working platform. Windows binaries did HTTP/1.1 over TLS perfectly
well, and they were traded for HTTP/2 over TLS on the others. The alternative —
keeping Windows on the old TLS stack behind a compile-time seam — was considered
and not taken, in favour of one code path everywhere.
Releases now ship four archives: x86_64 and aarch64, Linux and macOS.
Breaking: different TLS
https:// benchmarks now run on ztls with a Zig-built BoringSSL, not on
std.crypto.tls. Same protocol, different implementation — if you compare
numbers across this boundary, compare them knowing that.
zrk also links C for the first time. BoringSSL is built by Zig and linked in
statically, so the binaries still have nothing to install alongside them, and
every target is still cross-compiled from one machine.
Also in this release
--http2(--h2c), documented in--helpand the README, which are now
checked against each other by a test rather than kept in step by hand.- The HTTP/2 codec is its own package,
zoxy-io/h2 — RFC 9113 framing, RFC 7541
HPACK, and the RFC 9113 §8.2/§8.3 field rules, with no allocator in its
public API. It is shared with zoxy. - The request block is HPACK-encoded once at startup and replayed
byte-identically on every stream, so a fixed request costs no per-request
compression work. SETTINGS_HEADER_TABLE_SIZE = 0is advertised, which forbids the peer the
dynamic table and keeps response decoding stateless — HPACK makes header
decoding mandatory where HTTP/1.1 was a scan for CRLF, and this keeps that off
the hot path.- CI now cross-compiles every release target on every change, so a dependency
that breaks one is caught before a tag rather than by it.
Upgrading
Nothing changes for existing HTTP/1.1 invocations except the TLS implementation
under https://. --http2 is opt-in.