Pingclair 0.2.0-rc.1
A release candidate, not a release. Everything here is on main and has run
for two days without being restarted, but 0.2.0 is not cut until the non-goals
below are decided and the known defect has an answer.
The full entry-by-entry account — 150 entries across Breaking, Changed, Added,
Fixed, Security, Performance and Removed — is in
CHANGELOG.md.
This page is the summary and the evidence.
🧪 Soak evidence
A single process on an aarch64 cloud instance, sampled 3,036 times between
2026-08-18T09:19:28Z and 2026-08-20T13:10:45Z — a little over two days.
| What was watched | Result |
|---|---|
| Resident memory | 43 → 29 MiB, peak 59 MiB — no growth trend (−14 MiB) |
| File descriptors | 37 → 40, peak 232 — stable (+3) |
| Restarts | 0 — the process that started the run is the one that ended it |
| HTTP/1.1, HTTP/2, HTTP/3 | 200 on all 3,036 samples each; no non-200, no failure to connect |
| 5xx responses | 0 for the whole window |
| Upstream errors | 0 for the whole window |
| Certificate | 87 days remaining, renewed by the server itself |
What this run does not cover: it is one instance, one configuration, and a
light request rate. It is evidence that nothing leaks and nothing dies over two
days on all three transports — not a throughput or latency claim. Those live in
benchmarks/README.md
with their own methodology.
🐛 Known defect — WebSocket upgrades under load
Pingclair proxies WebSocket, and roughly 10–15 % of upgrades fail when the
machine is busy. It is stated here rather than left out because the feature is
not missing: it works, and then intermittently does not.
The fault is in pingora-proxy 0.8.1, not in this project's handling of the
upgrade — a trace shows the request reaching the upstream with
Connection: Upgrade and Upgrade: websocket intact. An upgrade request is a
GET with no body, and the end of that empty body is mistaken for the end of
the tunnel, but only when the upstream's 101 is read first. The proxy loses
that race more often the busier the machine: forty of forty upgrades succeed on
an idle ten-core machine, six of forty fail in a two-core container, and any
delay before the 101 — even a bare yield — removes the failures entirely. A
developer machine will report that this defect does not exist.
No configuration avoids it. From outside, a failure looks like a connection torn
down immediately after the 101, both ends seeing EOF with no error. Upstream
issue: cloudflare/pingora#946,
open as of 2026-08-18.
⚠️ Breaking changes since v0.1.7
Read these before upgrading; several of them stop an existing configuration from
loading, by design — a setting that cannot be honoured fails closed instead of
warning once and doing nothing.
- A block must open at the end of its line.
route { respond "hi" 200no
longer compiles. The relaxation was temporary, introduced when the parser front
end was replaced so that one change did not also change what compiles. - Twelve
transport httptuning knobs are refused instead of parsed and
ignored (read_buffer,write_buffer,max_response_header,resolvers,
compression,keepalive_intervaland friends).versionsis the exception:
it is now implemented, and a route that asks for HTTP/1.1 cannot be handed
someone else's HTTP/2 connection. preferred_chainsis refused — this build's ACME client cannot request an
alternate chain at all, so asking for one now fails to start rather than
silently getting whichever chain the authority offered first.- Control-plane changes that need a new listener return
409 restart_required
rather than quietly standing up a side listener without HTTP/3, mutual TLS or
strict SNI. - Request metrics carry no
hostlabel unless the configuration asks, because
a label taken from client input is a cardinality bomb.
✨ What is new
Roughly in the order a configuration meets them.
- Caddyfile compatibility — complete directive and matcher syntax,
try_filesandurias real directives,import name { … }block
placeholders, named regexp captures as{re.*}placeholders,vars,error
as a handler andhandle_errorsrouting raised statuses like requests. - Reverse proxy — active health checks, circuit breakers, rate limiting,
session affinity by header/cookie/query, Unix-socket upstreams, dynamic
upstreams,handle_responseinterception,forward_auth, andphp_fastcgi
over a real FastCGI client. - HTTP/3 — middleware execution unified with the other transports, so a
policy written once applies to all three. - TLS — mutual TLS with leaf pinning, a persistent internal CA, durable ACME
state, wildcard internal certificates, and DNS-01 wildcards through Cloudflare. - Caching — RFC 9111 decides what may be stored.
- Operations — Admin API with an origin allow list,
/readyand liveness
endpoints, per-server access logs that actually drive logging, a Prometheus
metricsendpoint with a global block deciding which series exist, and a real
command line (reload,start,stop,respond,run --watch).
🐛 Fixed, and 🔐 hardened
The two largest sections of the changelog. The shapes worth naming:
- Three ways to ask a static file server for a large file allocated the whole
file. Bodies stream now — this project has shipped full-body buffering twice
and treats it as a correctness defect, not a performance one. - Parity gaps between transports — placeholders, rewritten methods, site
names and raised statuses that worked on HTTP/1.1 and silently did not on
HTTP/2 or HTTP/3. - Framing and encoding — duplicate
Content-Lengthrefused,+5refused,
hop-by-hop headers no longer crossing the hop with credentials attached,
cookies split across field lines reassembled,Accept-Encoding: gzip;q=0
honoured,Vary: Accept-Encodingpresent. 1MBis a million bytes, not 1,048,576.- Security — a constant-time admin key comparison that was not constant-time,
a cleartext client reported to the origin as secure, aHostdiffering only in
capitalisation reaching a different site, mutual TLS trusting everything a CA
had ever signed, a misspelledclient_authkey silently downgrading mTLS,
world-readable secret files, an unbounded metric-label cardinality, and a
directory listing naming the fileshidewas told to conceal.
⚡ Performance
GSO-backed packet batching on HTTP/3, lock-free per-file response metadata for
static files, Via/request-id/forwarding headers built once at load time instead
of per request, and a reused per-connection HPACK scratch buffer.
Removed: two vendored performance forks, 38,532 lines. They were all plausible
and none were measured where the patched component was saturated.
🚫 Non-goals for 0.2.0
Still to be decided before the final release — naming what is out is what lets
the rest converge.
Not yet written down. A release with 150 entries and no stated non-goals has no
edge: every plausible idea is still inside the scope, so nothing can be
finished. This is the one item that blocks calling it 0.2.0.