Skip to content

v0.2.0-rc.1 — release candidate

Latest

Choose a tag to compare

@dorianverlaine dorianverlaine released this 20 Aug 13:34
· 1 commit to main since this release

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" 200 no
    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 http tuning knobs are refused instead of parsed and
    ignored (read_buffer, write_buffer, max_response_header, resolvers,
    compression, keepalive_interval and friends). versions is 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_chains is 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 host label 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_files and uri as real directives, import name { … } block
    placeholders, named regexp captures as {re.*} placeholders, vars, error
    as a handler and handle_errors routing 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_response interception, forward_auth, and php_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, /ready and liveness
    endpoints, per-server access logs that actually drive logging, a Prometheus
    metrics endpoint 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-Length refused, +5 refused,
    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-Encoding present.
  • 1MB is 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, a Host differing only in
    capitalisation reaching a different site, mutual TLS trusting everything a CA
    had ever signed, a misspelled client_auth key silently downgrading mTLS,
    world-readable secret files, an unbounded metric-label cardinality, and a
    directory listing naming the files hide was 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.