From 9d8453bd262c2e7346207c124c39638f99ae5ce0 Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 23 Aug 2026 08:04:54 +0100 Subject: [PATCH 01/10] perf(http): resolve peer/local address once per connection, not per request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit +2.8% on the load-balancer benchmark (48,073 -> 49,432 rps), with nginx and haproxy measured in the same runs as controls and moving under 0.3%. std/net/aether_http_server.c fetched getpeername and getsockname on every request, with a comment reasoning they are "cache-warm syscalls" and so cheap enough to run per request. Profiling std.http.server.lb against nginx on the same box put a number on that (#1719): syscall aether nginx getpeername 1.0 0 133,162 calls over the run getsockname 1.0 0 133,162 calls over the run Neither address can change while a socket is open, so nginx resolves them once and Aether was paying 2 syscalls, 2 inet_ntop calls and 2 strdups per request for an answer it already had. Caching them on HttpConn is cheap now in a way it was not when that comment was written: connection parking (#1663) made HttpConn heap-allocated and connection-lifetime, so there is somewhere to put them. After: getpeername is gone from the strace profile entirely and getsockname falls from 133,162 calls to 50 — one per connection. Under strace (which makes syscalls expensive and so amplifies the effect) throughput rose 12,969 -> 15,774 rps, +22%. `addrs_resolved` is a separate flag rather than testing for an empty string: a failed lookup — a Unix-domain socket, an fd closed under us — is a real answer worth caching, and without the flag it would retry every request, which is the cost being removed. The request still owns its copies. http_request_free frees req->remote_addr and req->local_addr, so the per-request strdups stay and only the syscalls go; the ownership contract is unchanged. The accessor test now drives three requests down ONE connection, because the single-request version could not tell a working cache from a broken one. It could not before this commit either: the test server never enabled keep-alive, so curl reconnected per request and the cache was repopulated each time. Turning keep-alive on in server.ae is what makes the assertion mean anything — verified by injecting a stale-cache bug, which the test now catches (requests 2 and 3 report peer=) and did not catch before. Constraint held: 305/305 regression, 38/40 http integration. The two failures (http_server_h2's known framing-layer error, forward_proxy's port contention) reproduce on stashed baseline code. Refs #1719 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 17 ++ std/net/aether_http_server.c | 164 ++++++++++++------ .../http_request_conn_accessors/server.ae | 11 ++ .../test_http_request_conn_accessors.sh | 30 +++- 4 files changed, 167 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42fc22ff2..b56698e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `main`, the release pipeline automatically replaces `[current]` with the next version number before tagging the release. +## [current] + +### Changed + +- **`std.http.server` resolves a connection's peer and local address once per + connection rather than once per request** (#1719). The old comment reasoned + that `getpeername`/`getsockname` are cache-warm and therefore cheap enough to + run per request; measured against nginx on the same box, that was 2 syscalls, + 2 `inet_ntop` calls and 2 `strdup`s on every request, where nginx makes none + of them — neither address can change while a socket is open. Caching them on + `HttpConn` (which connection parking, #1663, had already made + connection-lifetime) took the load-balancer benchmark from 48,073 to 49,432 + rps, **+2.8%**, with nginx and haproxy measured in the same runs as controls + moving under 0.3%. Under `strace`, `getpeername` disappears from the profile + and `getsockname` falls from 133,162 calls to 50. The request still owns its + own copies, so `http_request_free`'s contract is unchanged. + ## [0.575.0] ### Added diff --git a/std/net/aether_http_server.c b/std/net/aether_http_server.c index b886373bd..cae58f888 100644 --- a/std/net/aether_http_server.c +++ b/std/net/aether_http_server.c @@ -231,6 +231,32 @@ typedef struct HttpConn { * back to a different worker, so the count has to live with the * connection rather than on the worker's stack (#1663). */ int requests_served; + /* Peer and local address, resolved once per CONNECTION (#1719). + * + * These used to be fetched per request, on the reasoning that + * getpeername/getsockname are cache-warm and therefore cheap. Measured + * against nginx on the same box, that cost 2 syscalls, 2 inet_ntop calls + * and 2 strdups on every request — nginx makes zero of any of them, + * because neither address can change while a connection is open. + * + * Cached as text because that is the shape every consumer wants + * (http_request_remote_addr returns a string). Empty string means the + * lookup failed — a Unix-domain socket, or an fd that closed under us — + * and is a valid cached answer, so `addrs_resolved` distinguishes + * "looked and found nothing" from "not looked yet". Without that flag a + * failing lookup would retry on every request, which is the cost this + * removes. + * + * The request still owns its own copies: http_request_free frees + * req->remote_addr and req->local_addr, so per-request strdups from + * these are what get handed out. That keeps the ownership contract + * unchanged; the saving is the syscalls and the inet_ntop, not the + * allocation. */ + char remote_addr[INET6_ADDRSTRLEN]; + char local_addr[INET6_ADDRSTRLEN]; + int remote_port; + int local_port; + int addrs_resolved; } HttpConn; /* The parking lot holds HttpConn by pointer and needs exactly two things from @@ -2791,6 +2817,60 @@ static int conn_recv_timed_out(void) { #endif } +/* Resolve this connection's peer and local address, once (#1719). + * + * Idempotent: after the first call `addrs_resolved` is set and the cached + * text is returned unchanged, including when the lookup failed and the + * cache holds "". A failed lookup is a real answer for a Unix-domain + * socket, and retrying it per request is exactly the cost being removed. + * + * Not thread-safe by design: an HttpConn is owned by one worker at a + * time. Parking hands it between workers, but never concurrently. */ +static void conn_resolve_addrs(HttpConn* c) { + if (!c || c->addrs_resolved) return; + c->addrs_resolved = 1; + c->remote_addr[0] = '\0'; + c->local_addr[0] = '\0'; + c->remote_port = 0; + c->local_port = 0; + if (c->fd < 0) return; + + struct sockaddr_storage ss; + socklen_t sslen = sizeof(ss); + if (getpeername(c->fd, (struct sockaddr*)&ss, &sslen) == 0) { + const char* src = NULL; + if (ss.ss_family == AF_INET) { + struct sockaddr_in* sa = (struct sockaddr_in*)&ss; + src = inet_ntop(AF_INET, &sa->sin_addr, + c->remote_addr, sizeof(c->remote_addr)); + c->remote_port = ntohs(sa->sin_port); + } else if (ss.ss_family == AF_INET6) { + struct sockaddr_in6* sa = (struct sockaddr_in6*)&ss; + src = inet_ntop(AF_INET6, &sa->sin6_addr, + c->remote_addr, sizeof(c->remote_addr)); + c->remote_port = ntohs(sa->sin6_port); + } + if (!src) { c->remote_addr[0] = '\0'; c->remote_port = 0; } + } + + sslen = sizeof(ss); + if (getsockname(c->fd, (struct sockaddr*)&ss, &sslen) == 0) { + const char* src = NULL; + if (ss.ss_family == AF_INET) { + struct sockaddr_in* sa = (struct sockaddr_in*)&ss; + src = inet_ntop(AF_INET, &sa->sin_addr, + c->local_addr, sizeof(c->local_addr)); + c->local_port = ntohs(sa->sin_port); + } else if (ss.ss_family == AF_INET6) { + struct sockaddr_in6* sa = (struct sockaddr_in6*)&ss; + src = inet_ntop(AF_INET6, &sa->sin6_addr, + c->local_addr, sizeof(c->local_addr)); + c->local_port = ntohs(sa->sin6_port); + } + if (!src) { c->local_addr[0] = '\0'; c->local_port = 0; } + } +} + static int handle_one_request(HttpServer* server, HttpConn* conn, int requests_served, int max_requests) { long t_start = http_now_us(); @@ -2958,64 +3038,40 @@ static int handle_one_request(HttpServer* server, HttpConn* conn, req->body_length = (size_t)content_length; } - /* Populate the connection-level metadata that handlers learn from - * the kernel rather than from the request bytes. Cheap (two - * cache-warm syscalls + two inet_ntop's) so it runs per request; - * failures (Unix-domain socket, EBADF on a just-closed fd) leave - * each field at its zero default and the accessors return ""/0. + + /* Connection-level metadata that handlers learn from the kernel rather + * than from the request bytes. + * + * Resolved ONCE per connection (#1719) — neither address can change + * while the socket is open, and fetching them per request cost 2 + * syscalls, 2 inet_ntop calls and 2 strdups each time. nginx makes none + * of those calls at all. + * + * - remote_addr/remote_port (getpeername): the trusted peer. The + * X-Forwarded-For header is client-supplied and a wrong basis for an + * allow/deny decision on a direct listener; this is the kernel's view + * of the socket, which is unspoofable. + * - local_addr/local_port (getsockname): which NIC this accepted fd is + * bound to. Needed when the listener binds 0.0.0.0 and the handler + * gates on the receiving interface (admin-on-loopback, multi-tenant + * per-IP routing). + * - is_tls: the connection wrapper, not anything in the wire bytes. + * Drives scheme/redirect/cookie-Secure decisions. * - * - remote_addr/remote_port (from getpeername): the trusted peer. - * The X-Forwarded-For header is client-supplied and a wrong - * basis for an allow/deny decision on a direct listener; this - * is the kernel's view of the socket, which is unspoofable. - * - local_addr/local_port (from getsockname): which NIC this - * accepted fd is bound to. Needed when the listener binds - * 0.0.0.0 and the handler wants to gate behaviour on which - * interface received the request (admin-on-loopback, multi- - * tenant per-IP routing). - * - is_tls: the connection wrapper, not anything in the wire - * bytes. Drives scheme/redirect/cookie-Secure decisions. + * The request keeps owning its copies — http_request_free frees both — + * so the strdups stay and only the syscalls go. A failed lookup leaves + * the cache empty and the accessors return ""/0, as before. * - * IPv4 + IPv6 supported via sockaddr_storage. */ + * IPv4 + IPv6 via sockaddr_storage. */ { - struct sockaddr_storage ss; - socklen_t sslen = sizeof(ss); - if (getpeername(conn->fd, (struct sockaddr*)&ss, &sslen) == 0) { - char buf[INET6_ADDRSTRLEN]; - const char* src = NULL; - int port = 0; - if (ss.ss_family == AF_INET) { - struct sockaddr_in* sa = (struct sockaddr_in*)&ss; - src = inet_ntop(AF_INET, &sa->sin_addr, buf, sizeof(buf)); - port = ntohs(sa->sin_port); - } else if (ss.ss_family == AF_INET6) { - struct sockaddr_in6* sa = (struct sockaddr_in6*)&ss; - src = inet_ntop(AF_INET6, &sa->sin6_addr, buf, sizeof(buf)); - port = ntohs(sa->sin6_port); - } - if (src) { - req->remote_addr = strdup(src); - req->remote_port = port; - } + conn_resolve_addrs(conn); + if (conn->remote_addr[0]) { + req->remote_addr = strdup(conn->remote_addr); + req->remote_port = conn->remote_port; } - sslen = sizeof(ss); - if (getsockname(conn->fd, (struct sockaddr*)&ss, &sslen) == 0) { - char buf[INET6_ADDRSTRLEN]; - const char* src = NULL; - int port = 0; - if (ss.ss_family == AF_INET) { - struct sockaddr_in* sa = (struct sockaddr_in*)&ss; - src = inet_ntop(AF_INET, &sa->sin_addr, buf, sizeof(buf)); - port = ntohs(sa->sin_port); - } else if (ss.ss_family == AF_INET6) { - struct sockaddr_in6* sa = (struct sockaddr_in6*)&ss; - src = inet_ntop(AF_INET6, &sa->sin6_addr, buf, sizeof(buf)); - port = ntohs(sa->sin6_port); - } - if (src) { - req->local_addr = strdup(src); - req->local_port = port; - } + if (conn->local_addr[0]) { + req->local_addr = strdup(conn->local_addr); + req->local_port = conn->local_port; } req->is_tls = (conn->ssl != NULL) ? 1 : 0; } diff --git a/tests/integration/http_request_conn_accessors/server.ae b/tests/integration/http_request_conn_accessors/server.ae index a5d3d6f17..1517380bf 100644 --- a/tests/integration/http_request_conn_accessors/server.ae +++ b/tests/integration/http_request_conn_accessors/server.ae @@ -49,6 +49,17 @@ main() { println("READY") + // Keep-alive on, so the multi-request half of the shell test actually + // reuses one connection. Without it curl reconnects per request, the + // per-connection address cache (#1719) is repopulated each time, and + // the assertion that cached values survive a reused connection would + // pass without testing anything. + ka_err = http.server_set_keepalive(raw, 1, 0, 10000ms) + if ka_err != "" { + println("FAIL: server_set_keepalive: ${ka_err}") + exit(1) + } + http.server_get(raw, "/whoami", handle_whoami, 0) spawn(SchedHelper()) diff --git a/tests/integration/http_request_conn_accessors/test_http_request_conn_accessors.sh b/tests/integration/http_request_conn_accessors/test_http_request_conn_accessors.sh index 7604237e9..61724541e 100755 --- a/tests/integration/http_request_conn_accessors/test_http_request_conn_accessors.sh +++ b/tests/integration/http_request_conn_accessors/test_http_request_conn_accessors.sh @@ -74,4 +74,32 @@ VER=$(get_field ver) [ "$IS_TLS" = "0" ] || { echo " [FAIL] is_tls expected 0 (cleartext listener), got '$IS_TLS'"; echo " raw: $RESP"; exit 1; } [ "$VER" = "HTTP/1.1" ] || { echo " [FAIL] ver expected HTTP/1.1, got '$VER'"; echo " raw: $RESP"; exit 1; } -echo " [PASS] http_request_conn_accessors (remote_port + local_addr/port + scheme + is_tls + http_version)" +# The addresses are resolved once per CONNECTION and cached (#1719), not +# fetched per request. That is only correct if every request on a reused +# connection still reports them — a cache populated for request 1 and not +# read back for request 2 would leave the later ones empty, which no +# single-request check would notice. +# +# curl's multi-URL form reuses one connection, so this drives three +# requests down one socket and requires all three to agree. The peer PORT +# is the sharpest field here: it is kernel-assigned per connection, so if +# the cache were somehow refreshed mid-connection it would still match, +# but if it were dropped the field would go empty. +MULTI=$(curl --silent --show-error --max-time 5 "$URL" "$URL" "$URL" 2>"$TMPDIR/m.err") || { + echo " [FAIL] keep-alive curl failed:"; cat "$TMPDIR/m.err"; exit 1 +} + +# The three bodies concatenate with no separator, so a line-oriented +# count would see "ver=HTTP/1.1peer=127.0.0.1" as one field. Count +# occurrences instead. +MULTI_PEERS=$(echo "$MULTI" | grep -o 'peer=127\.0\.0\.1' | wc -l) +[ "$MULTI_PEERS" = "3" ] || { + echo " [FAIL] expected peer=127.0.0.1 on all 3 keep-alive requests, got $MULTI_PEERS" + echo " raw: $MULTI"; exit 1; } + +MULTI_LOCALS=$(echo "$MULTI" | grep -o 'local_port=18294' | wc -l) +[ "$MULTI_LOCALS" = "3" ] || { + echo " [FAIL] expected local_port=18294 on all 3 keep-alive requests, got $MULTI_LOCALS" + echo " raw: $MULTI"; exit 1; } + +echo " [PASS] http_request_conn_accessors (remote_port + local_addr/port + scheme + is_tls + http_version; cached addrs survive keep-alive)" From 4bd99a792dc4ec9af129862ca0aca4d73552d0e2 Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 23 Aug 2026 08:40:15 +0100 Subject: [PATCH 02/10] fix(test): strip BSD wc padding in conn-accessor keep-alive counts The keep-alive assertions added with the address cache compared `wc -l` output against the string "3". GNU wc prints a bare count; BSD/macOS wc left-pads it to width 8 (" 3"), so the compare succeeded on Linux and failed on both macOS legs -- the test was counting correctly and reporting the right number, then rejecting it on whitespace. Piping through `tr -d '[:space:]'` normalises the count while keeping it a string, so the failure branch still prints a readable number. The three other `wc -l` sites under tests/ feed numeric comparisons, where the shell strips the padding itself, so they were never exposed. Co-Authored-By: Claude Opus 5 --- tests/integration/http_request_conn_accessors/c -l)|X| | 0 .../test_http_request_conn_accessors.sh | 9 +++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 tests/integration/http_request_conn_accessors/c -l)|X| diff --git a/tests/integration/http_request_conn_accessors/c -l)|X| b/tests/integration/http_request_conn_accessors/c -l)|X| new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/http_request_conn_accessors/test_http_request_conn_accessors.sh b/tests/integration/http_request_conn_accessors/test_http_request_conn_accessors.sh index 61724541e..fbaf46995 100755 --- a/tests/integration/http_request_conn_accessors/test_http_request_conn_accessors.sh +++ b/tests/integration/http_request_conn_accessors/test_http_request_conn_accessors.sh @@ -92,12 +92,17 @@ MULTI=$(curl --silent --show-error --max-time 5 "$URL" "$URL" "$URL" 2>"$TMPDIR/ # The three bodies concatenate with no separator, so a line-oriented # count would see "ver=HTTP/1.1peer=127.0.0.1" as one field. Count # occurrences instead. -MULTI_PEERS=$(echo "$MULTI" | grep -o 'peer=127\.0\.0\.1' | wc -l) +# `wc -l` pads its count with leading blanks on BSD/macOS (" 3"), +# so a string compare against "3" passes on GNU and fails on Darwin -- +# which is exactly how this test went red on both macOS legs and green +# everywhere else. Strip the padding rather than compare numerically, so +# the failure message still prints a clean count. +MULTI_PEERS=$(echo "$MULTI" | grep -o 'peer=127\.0\.0\.1' | wc -l | tr -d '[:space:]') [ "$MULTI_PEERS" = "3" ] || { echo " [FAIL] expected peer=127.0.0.1 on all 3 keep-alive requests, got $MULTI_PEERS" echo " raw: $MULTI"; exit 1; } -MULTI_LOCALS=$(echo "$MULTI" | grep -o 'local_port=18294' | wc -l) +MULTI_LOCALS=$(echo "$MULTI" | grep -o 'local_port=18294' | wc -l | tr -d '[:space:]') [ "$MULTI_LOCALS" = "3" ] || { echo " [FAIL] expected local_port=18294 on all 3 keep-alive requests, got $MULTI_LOCALS" echo " raw: $MULTI"; exit 1; } From bb73de14ab3c5af499698b5169dd8c8629a648d2 Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 23 Aug 2026 08:57:04 +0100 Subject: [PATCH 03/10] perf(http): stop re-applying identical socket timeouts per request Under strace against the LB benchmark, setsockopt was the third costliest syscall: 202,552 calls for 20,000 requests, ~10 per request, 15.2% of syscall time. None of them changed anything. Two sites, both on the keep-alive path where the socket already carries the value being set: - the upstream proxy connection. http_apply_timeouts runs on every reuse of a pooled connection (aether_http.c:1663), setting the same SO_RCVTIMEO and SO_SNDTIMEO the previous request left there. A Transport travels with its connection into the idle pool, so it can remember what it applied; transport_apply_timeouts skips the pair when the value is unchanged. The unguarded form stays for the dial path, where the socket is new and its option state is unknown. - the client connection. conn_serve applies the idle timeout on entry, and with connection parking (#1663) a kept-alive connection re-enters conn_serve once per request. The parking comment already claimed the window was "only re-applied when it changes" -- that guard did not exist. Now it does, on HttpConn. Both sentinels are -1 rather than 0, because 0 is a legitimate timeout meaning "block indefinitely" and must not read as "never configured". HttpConn is calloc'd, so its field is set explicitly after allocation rather than relying on the zeroing. Co-Authored-By: Claude Opus 5 --- std/net/aether_http.c | 38 ++++++++++++++++++++++++++++++++++-- std/net/aether_http_server.c | 21 ++++++++++++++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/std/net/aether_http.c b/std/net/aether_http.c index d8e9710cc..09abc4fdc 100644 --- a/std/net/aether_http.c +++ b/std/net/aether_http.c @@ -269,6 +269,20 @@ static char* ssl_err_string(const char* prefix) { typedef struct { int sockfd; + /* The SO_RCVTIMEO/SO_SNDTIMEO value currently on this socket, or -1 when + * nothing has been applied yet (#1719). + * + * A Transport travels with its connection into the idle pool, so a reused + * connection already carries the timeouts the last request set. Re-applying + * an identical value costs 2 setsockopt syscalls per request and changes + * nothing: under strace against the LB benchmark, setsockopt was the third + * costliest syscall at 202,552 calls for 20,000 requests -- roughly 10 per + * request, on sockets whose options were already correct. + * + * A sentinel of -1 rather than 0 because 0 is a legitimate timeout value + * meaning "block indefinitely", and a socket set to block forever must not + * be confused with one never configured. */ + int64_t applied_timeout_ns; #ifdef AETHER_HAS_OPENSSL SSL* ssl; /* A per-request SSL_CTX, owned by this transport, or NULL when the @@ -1210,6 +1224,19 @@ static void http_apply_timeouts(int sockfd, int64_t timeout_ns) { #endif } +/* Apply timeouts only when they differ from what the socket already carries. + * + * The unguarded http_apply_timeouts stays for the dial path, where the socket + * is new and its option state is genuinely unknown. This form is for the reuse + * path, where the answer is usually "already correct" -- see the comment on + * Transport::applied_timeout_ns for the measurement. */ +static void transport_apply_timeouts(Transport* t, int64_t timeout_ns) { + int64_t want = timeout_ns < 0 ? 0 : timeout_ns; + if (t->applied_timeout_ns == want) return; + http_apply_timeouts(t->sockfd, want); + t->applied_timeout_ns = want; +} + /* Is a pooled connection still usable? A peer that closed leaves the socket * readable at EOF, and anything readable on an idle keep-alive connection is * unexpected in either direction (a stray byte would desynchronise the next @@ -1391,6 +1418,9 @@ static int http_dial(HttpClientRequest* req, struct sockaddr_in* serv_addr_in, } out->sockfd = sockfd; + /* The dial path below applies the timeouts unconditionally on a fresh + * socket; record the value so a later reuse can skip re-applying it. */ + out->applied_timeout_ns = req->timeout_ns < 0 ? 0 : req->timeout_ns; /* HTTPS via a forward proxy: establish a CONNECT tunnel over the raw socket * BEFORE the TLS handshake, so TLS runs end-to-end through the proxy (the @@ -1646,7 +1676,11 @@ static HttpResponse* http_request_internal(HttpClientRequest* req) { serv_addr.sin_family = AF_INET; serv_addr.sin_port = htons(dial_port); - Transport t; + /* Zero-initialised so applied_timeout_ns starts at the "nothing applied" + * sentinel rather than stack garbage, which the reuse guard would read as + * a real value and wrongly skip the setsockopt. */ + Transport t = {0}; + t.applied_timeout_ns = -1; char pool_key[HTTP_POOL_KEY_MAX]; http_pool_key(pool_key, sizeof(pool_key), host, port, use_tls, dial_host, dial_port, req->insecure, req->cafile); @@ -1660,7 +1694,7 @@ static HttpResponse* http_request_internal(HttpClientRequest* req) { if (pool_this && http_pool_take(pool_key, &t)) { if (transport_is_live(&t)) { reused = 1; - http_apply_timeouts(t.sockfd, req->timeout_ns); + transport_apply_timeouts(&t, req->timeout_ns); } else { transport_close(&t); } diff --git a/std/net/aether_http_server.c b/std/net/aether_http_server.c index cae58f888..3372b424a 100644 --- a/std/net/aether_http_server.c +++ b/std/net/aether_http_server.c @@ -257,6 +257,17 @@ typedef struct HttpConn { int remote_port; int local_port; int addrs_resolved; + + /* The SO_RCVTIMEO value currently on this socket, or -1 when none has been + * applied yet (#1719). + * + * conn_serve applies the idle timeout on entry, and with connection parking + * a kept-alive connection re-enters conn_serve once per request -- so an + * unguarded apply is one setsockopt per request setting the value that is + * already there. The comment on the parking path already claimed the window + * was "only re-applied when it changes"; this is the guard that makes that + * true. -1 rather than 0 because 0 is a valid "block indefinitely". */ + int applied_recv_timeout_ms; } HttpConn; /* The parking lot holds HttpConn by pointer and needs exactly two things from @@ -3593,9 +3604,12 @@ static int64_t conn_now_ms(void) { return (int64_t)time(NULL) * 1000; } -static void conn_apply_recv_timeout(HttpServer* server, int fd) { +static void conn_apply_recv_timeout(HttpServer* server, HttpConn* c) { + int fd = c->fd; int idle_ms = server->keep_alive_idle_ms > 0 ? server->keep_alive_idle_ms : 30000; + if (c->applied_recv_timeout_ms == idle_ms) return; + c->applied_recv_timeout_ms = idle_ms; #ifdef _WIN32 DWORD rcv_timeout = (DWORD)idle_ms; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, @@ -3652,7 +3666,7 @@ static void conn_serve(HttpServer* server, HttpConn* conn) { * The wait is a plain blocking recv either way: a poll before it would be * a syscall per request, and measured at 8 concurrent clients that cost * 9% against simply blocking. */ - conn_apply_recv_timeout(server, conn->fd); + conn_apply_recv_timeout(server, conn); #ifdef AETHER_HAS_NGHTTP2 if (conn->is_h2) { @@ -3753,6 +3767,9 @@ void http_server_drain_connection(HttpServer* server, int client_fd) { conn->read_pos = 0; conn->write_pos = 0; conn->requests_served = 0; + /* calloc would leave this 0, which is a legitimate timeout meaning "block + * indefinitely"; the guard must see "nothing applied yet" instead. */ + conn->applied_recv_timeout_ms = -1; #ifdef AETHER_HAS_OPENSSL if (server->tls_enabled && server->tls_ctx) { From 1b5d1e1973ba71f7440fd8eda347b6fba3587f98 Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 23 Aug 2026 09:01:43 +0100 Subject: [PATCH 04/10] docs(changelog): record the setsockopt elimination (#1719) Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b56698e85..da688445b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,21 @@ version number before tagging the release. ### Changed +- **`std.http.server` and `std.http.client` no longer re-apply socket timeouts + that are already set** (#1719). Under `strace` against the load-balancer + benchmark, `setsockopt` was the third costliest syscall — 202,552 calls for + 20,000 requests, ~10 per request, 15.2% of syscall time — and not one of them + changed a socket option. Two sites, both on the keep-alive path: every reuse + of a pooled upstream connection re-applied `SO_RCVTIMEO`/`SO_SNDTIMEO`, and + every request on a parked client connection re-applied the idle timeout. Both + now remember the value they applied and skip the call when it is unchanged; + the unguarded form stays on the dial path, where the socket is new. Measured + effect: `setsockopt` falls from 202,552 calls to **72**, total syscalls per + request from **83.0 to 14.0**, and throughput rises 2.6% (47,852 → 49,083 rps, + three alternating A/B rounds, new ahead in every round). Note the parking + path's comment already claimed the window was "only re-applied when it + changes" — that guard did not exist until now. + - **`std.http.server` resolves a connection's peer and local address once per connection rather than once per request** (#1719). The old comment reasoned that `getpeername`/`getsockname` are cache-warm and therefore cheap enough to From 29a32790192dba2f88d5e0997eb06cbbf5eadb4b Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 23 Aug 2026 09:19:02 +0100 Subject: [PATCH 05/10] fix: remove junk file with a Windows-illegal name A malformed sed invocation while editing the conn-accessor test created an empty file literally named `c -l)|X|`, and a `git add -A` on that directory committed it. `|` cannot appear in a Windows filename, so every native Windows leg failed in `actions/checkout` with error: invalid path 'tests/integration/http_request_conn_accessors/c -l)|X|' before compiling a line -- which is why all four failed in 9-18s and their logs were empty, while `Windows / cross-build` (which builds the same sources on Linux) passed. Not a portability bug in the C. Co-Authored-By: Claude Opus 5 --- tests/integration/http_request_conn_accessors/c -l)|X| | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/integration/http_request_conn_accessors/c -l)|X| diff --git a/tests/integration/http_request_conn_accessors/c -l)|X| b/tests/integration/http_request_conn_accessors/c -l)|X| deleted file mode 100644 index e69de29bb..000000000 From c3cdd00140218f64e839217d21978de675efeea1 Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 23 Aug 2026 10:20:52 +0100 Subject: [PATCH 06/10] perf(http): stop walking the idle pool list on every request http_pool_take and http_pool_put each swept the whole idle connection list under the global pool mutex, on every request. With the default 15s idle window and a proxy reusing upstreams continuously, that sweep frees nothing on the overwhelming majority of calls -- it is a list walk per request to discover there is nothing to do. The pool now records when its earliest connection becomes eligible and returns immediately while that deadline is in the future, recomputing the watermark from the survivors whenever it does sweep. All five paths that can invalidate it are handled: put lowers it (re-arming an empty pool), clear resets it to INT64_MAX, and reconfigure forces a sweep -- that last one matters because shortening the idle window would otherwise leave a deadline further out than the new setting allows and delay every eviction. Separately, http_pool_put counted per-key entries by walking to the end of the list with a strcmp per node, when only whether the count REACHES the cap matters. It now stops at the cap, and skips the walk entirely when the global cap already rejects the connection. NO MEASURABLE THROUGHPUT WIN. Three alternating A/B rounds on the LB benchmark: 48,854 rps before, 48,870 after (+0.03%, baseline ahead in two of three rounds). http_pool_expire_locked showed as 0.52% of cycles, but that figure is inclusive of the transport_close/free work a real expiry still has to do, so skipping the walk recovers much less than the profile implied. Kept because it is strictly less work under a global mutex -- shorter hold times matter more with more upstreams than this two-backend benchmark has -- but it is not a performance claim. Behaviour is unchanged: the watermark is a lower bound, so a stale-early value costs one redundant sweep and never a missed expiry. Co-Authored-By: Claude Opus 5 --- std/net/aether_http.c | 49 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/std/net/aether_http.c b/std/net/aether_http.c index 09abc4fdc..e7075a2d0 100644 --- a/std/net/aether_http.c +++ b/std/net/aether_http.c @@ -44,6 +44,7 @@ const char* http_response_read_chunk_raw(HttpResponse* r, int max) { (void)r; (v #include #include "../../runtime/utils/aether_thread.h" #include +#include /* INT64_MAX, for the pool expiry watermark */ #ifdef _WIN32 #include @@ -412,6 +413,22 @@ static int http_pool_enabled = 1; static int http_pool_max_idle = 64; static int http_pool_max_per_key = 8; static int64_t http_pool_idle_ms = 15000; +/* When the oldest pooled connection becomes eligible for expiry, or INT64_MAX + * when the pool is empty (#1719). + * + * http_pool_take and http_pool_put both swept the whole idle list on every + * request, under the global pool mutex. With the default 15s idle window and a + * proxy reusing its upstreams continuously, that sweep frees nothing on the + * overwhelming majority of calls -- it is a list walk per request to discover + * there is nothing to do. Tracking the earliest deadline lets both callers skip + * the walk outright until that time actually arrives. + * + * Kept deliberately coarse: it is a lower bound, not an exact answer. Entries + * are only ever added with a fresh idle_since_ms, so the earliest deadline can + * only move later when the list shrinks, and recomputing it during a sweep we + * were doing anyway is free. A stale-early value costs one redundant sweep, + * never a missed expiry. */ +static int64_t http_pool_next_expiry_ms = INT64_MAX; static int64_t http_now_ms(void) { struct timespec ts; @@ -435,8 +452,15 @@ static void http_pool_key(char* out, size_t n, const char* host, int port, use_tls ? 1 : 0, insecure ? 1 : 0, cafile ? cafile : ""); } -/* Caller holds the lock. Drops every entry idle past the timeout. */ +/* Caller holds the lock. Drops every entry idle past the timeout. + * + * Returns immediately when the earliest deadline is still in the future, which + * is the common case on a busy proxy -- see http_pool_next_expiry_ms. When it + * does sweep, it recomputes that watermark from the survivors. */ static void http_pool_expire_locked(int64_t now) { + if (now < http_pool_next_expiry_ms) return; + + int64_t earliest = INT64_MAX; HttpIdleConn** link = &http_pool_head; while (*link) { HttpIdleConn* c = *link; @@ -447,8 +471,11 @@ static void http_pool_expire_locked(int64_t now) { free(c); continue; } + int64_t due = c->idle_since_ms + http_pool_idle_ms; + if (due < earliest) earliest = due; link = &c->next; } + http_pool_next_expiry_ms = earliest; } /* Take an idle connection for `key`, newest first (the most recently used is @@ -490,9 +517,15 @@ static void http_pool_put(const char* key, Transport* t) { int64_t now = http_now_ms(); pthread_mutex_lock(http_pool_lock()); http_pool_expire_locked(now); + /* Only whether the per-key cap is REACHED matters, not the exact count, so + * stop at the cap instead of walking to the end -- and skip the walk + * entirely when the global cap already rejects this connection. */ int per_key = 0; - for (HttpIdleConn* e = http_pool_head; e; e = e->next) { - if (strcmp(e->key, key) == 0) per_key++; + if (http_pool_count < http_pool_max_idle) { + for (HttpIdleConn* e = http_pool_head; e; e = e->next) { + if (strcmp(e->key, key) == 0 && ++per_key >= http_pool_max_per_key) + break; + } } if (http_pool_count >= http_pool_max_idle || per_key >= http_pool_max_per_key) { pthread_mutex_unlock(http_pool_lock()); @@ -506,6 +539,10 @@ static void http_pool_put(const char* key, Transport* t) { c->next = http_pool_head; http_pool_head = c; http_pool_count++; + /* On an empty pool the watermark is INT64_MAX, and this entry is now the + * only deadline there is. Lowering it here is what re-arms the sweep. */ + if (c->idle_since_ms + http_pool_idle_ms < http_pool_next_expiry_ms) + http_pool_next_expiry_ms = c->idle_since_ms + http_pool_idle_ms; pthread_mutex_unlock(http_pool_lock()); t->sockfd = -1; #ifdef AETHER_HAS_OPENSSL @@ -520,6 +557,7 @@ void http_client_pool_clear_raw(void) { HttpIdleConn* c = http_pool_head; http_pool_head = NULL; http_pool_count = 0; + http_pool_next_expiry_ms = INT64_MAX; /* nothing left to expire */ pthread_mutex_unlock(http_pool_lock()); while (c) { HttpIdleConn* next = c->next; @@ -542,6 +580,11 @@ const char* http_client_pool_configure_raw(int max_idle, int max_per_host, if (idle_ns >= 0) { int64_t ms = idle_ns / 1000000LL; http_pool_idle_ms = ms > 0 ? ms : 1; + /* The expiry watermark was derived from the PREVIOUS window, so + * shortening the window would leave a deadline further out than the new + * setting allows and delay every eviction. Force the next pool + * operation to sweep and recompute it. */ + http_pool_next_expiry_ms = INT64_MIN; } pthread_mutex_unlock(http_pool_lock()); if (!http_pool_enabled) http_client_pool_clear_raw(); From 184b8c42881e1ca731fe3c8b64c67d608091286c Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 23 Aug 2026 10:28:42 +0100 Subject: [PATCH 07/10] docs(changelog): record the pool sweep change and its null result (#1719) Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da688445b..6b9cc21b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,18 @@ version number before tagging the release. ### Changed +- **`std.http.client`'s idle connection pool no longer walks its whole list on + every request** (#1719). `http_pool_take` and `http_pool_put` each swept the + idle list under the global pool mutex per request; with the default 15s idle + window and a proxy reusing upstreams continuously, that sweep frees nothing + almost every time. The pool now tracks when its earliest connection becomes + eligible and skips the walk until then. Separately, the per-key cap check + stops counting once it reaches the cap instead of walking to the end. **No + measurable throughput change** (48,854 → 48,870 rps over three alternating + A/B rounds, baseline ahead in two of them) — kept because it is strictly less + work under a global mutex, which matters with more upstreams than the + two-backend benchmark has, not as a performance claim. + - **`std.http.server` and `std.http.client` no longer re-apply socket timeouts that are already set** (#1719). Under `strace` against the load-balancer benchmark, `setsockopt` was the third costliest syscall — 202,552 calls for From bb0530d3240c898e26ac16aaa7c0404bf409be91 Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 23 Aug 2026 10:55:31 +0100 Subject: [PATCH 08/10] perf(http): resolve the backend host only when about to dial getaddrinfo ran unconditionally at the top of the request path, above the pool lookup, so every request resolved the backend host and then discarded the answer on a pooled hit. Only the http_dial sites use the resolved address, and the pool key is built from dial_host/dial_port rather than the address, so nothing between the two needed it. It profiled at 0.44% and takes a lock. Resolution now happens through resolve_dial_addr, which resolves at most once per request behind a caller-owned once-flag. ALL FOUR dial sites go through it: the initial dial, and the three pooled-connection retry paths (send-failure on headers, send-failure on body, and zero-length response). Those three are precisely the paths where the first resolve was skipped, so each must resolve before reading serv_addr -- an earlier revision of this commit missed two of them and passed an uninitialised sockaddr_in to connect(). The client tests did not catch it; T10 of http_reverse_proxy did, as a 504 that came back 000. A failed resolve leaves the flag clear, so a later caller retries rather than reading a stale address. One deliberate behaviour change: a request hitting a live pooled connection now succeeds even if the host has since stopped resolving, where before it failed with "could not resolve host". An open connection does not need DNS, and holding an established socket hostage to a resolver blip is the less useful behaviour -- it is also what nginx does. A request that has to dial still fails exactly as before. No test depended on the old behaviour. Co-Authored-By: Claude Opus 5 --- std/net/aether_http.c | 112 ++++++++++++++++++++++++++++++------------ 1 file changed, 80 insertions(+), 32 deletions(-) diff --git a/std/net/aether_http.c b/std/net/aether_http.c index e7075a2d0..b475c969d 100644 --- a/std/net/aether_http.c +++ b/std/net/aether_http.c @@ -1308,6 +1308,45 @@ static int transport_is_live(Transport* t) { return ready == 0; } +/* Resolve `host`:`port` into `out`, at most once per request (#1719). + * + * `*resolved` is the caller's once-flag: 0 on entry means "not yet", and it is + * set on success so a second call is free. Returns 1 on success (including the + * cached case), 0 when the name does not resolve. + * + * Resolve via getaddrinfo, NOT gethostbyname: gethostbyname returns a pointer + * into a shared, process-static `struct hostent`, so two client calls resolving + * at once on different threads -- e.g. a request handler that dials out while + * serving (serve-and-dial), where the inner call runs on a server worker thread + * -- race on that static buffer and can corrupt each other's resolved address. + * getaddrinfo is thread-safe and returns caller-owned memory freed with + * freeaddrinfo. Pinned to AF_INET: the callers build a sockaddr_in and the + * timeout/connect path assumes IPv4, so widening to IPv6 is a separate change. + * + * A failed resolve leaves *resolved at 0, so a later caller retries rather than + * reading an uninitialised address. */ +static int resolve_dial_addr(const char* host, int port, + struct sockaddr_in* out, int* resolved) { + if (*resolved) return 1; + + char port_str[16]; + snprintf(port_str, sizeof(port_str), "%d", port); + struct addrinfo hints; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + struct addrinfo* res = NULL; + if (getaddrinfo(host, port_str, &hints, &res) != 0 || !res) return 0; + + memset(out, 0, sizeof(*out)); + memcpy(out, res->ai_addr, sizeof(struct sockaddr_in)); + freeaddrinfo(res); + out->sin_family = AF_INET; + out->sin_port = htons(port); + *resolved = 1; + return 1; +} + /* Set *err to a heap copy of `msg`, for the dial helper's error returns. */ static void ae_set_err(char** out_err, const char* msg) { if (!out_err) return; @@ -1688,36 +1727,28 @@ static HttpResponse* http_request_internal(HttpClientRequest* req) { const char* dial_host = connect_host; int dial_port = connect_port; - /* Resolve via getaddrinfo, NOT gethostbyname: gethostbyname returns a - * pointer into a shared, process-static `struct hostent`, so two client - * calls resolving at once on different threads, e.g. a request handler - * that dials out while serving (serve-and-dial), where the inner call runs - * on a server worker thread, race on that static buffer and can corrupt - * each other's resolved address. getaddrinfo is thread-safe and returns - * caller-owned memory freed with freeaddrinfo. Pinned to AF_INET: the rest - * of this function builds a sockaddr_in and the timeout/connect path - * assumes IPv4, so widening to IPv6 is a separate change. */ + /* The dial address, resolved lazily (#1719). + * + * Resolution used to run unconditionally, above the pool lookup, so every + * request resolved the backend host and threw the answer away on a pooled + * hit -- a lock-taking call per request for a result nobody used. Only the + * two http_dial sites consume it, and the pool key is built from + * dial_host/dial_port rather than the resolved address, so nothing before + * a dial needs it. + * + * Both dial sites go through resolve_dial_addr, which resolves at most + * once per request: the send-failure retry below re-dials a connection + * that came from the pool, and that is precisely the path where the first + * resolve was skipped. + * + * One deliberate behaviour change falls out of this: a request that hits a + * live pooled connection now succeeds even if the host has since stopped + * resolving, where before it failed with "could not resolve host". An open + * connection does not need DNS, and holding an established socket hostage + * to a resolver blip is the less useful behaviour -- it is also what nginx + * does. A request that has to dial still fails exactly as it did. */ struct sockaddr_in serv_addr; - memset(&serv_addr, 0, sizeof(serv_addr)); - { - char port_str[16]; - snprintf(port_str, sizeof(port_str), "%d", dial_port); - struct addrinfo hints; - memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_INET; - hints.ai_socktype = SOCK_STREAM; - struct addrinfo* res = NULL; - int gai = getaddrinfo(dial_host, port_str, &hints, &res); - if (gai != 0 || !res) { - response->error = string_new(via_proxy ? "could not resolve proxy host" - : "could not resolve host"); - return response; - } - memcpy(&serv_addr, res->ai_addr, sizeof(struct sockaddr_in)); - freeaddrinfo(res); - } - serv_addr.sin_family = AF_INET; - serv_addr.sin_port = htons(dial_port); + int serv_addr_resolved = 0; /* Zero-initialised so applied_timeout_ns starts at the "nothing applied" * sentinel rather than stack garbage, which the reuse guard would read as @@ -1743,6 +1774,13 @@ static HttpResponse* http_request_internal(HttpClientRequest* req) { } } if (!reused) { + if (!resolve_dial_addr(dial_host, dial_port, &serv_addr, + &serv_addr_resolved)) { + response->error = string_new(via_proxy ? "could not resolve proxy host" + : "could not resolve host"); + return response; + } + char* dial_err = NULL; if (http_dial(req, &serv_addr, host, port, use_tls, via_proxy, &t, &dial_err) != 0) { response->error = string_new(dial_err ? dial_err : "connection failed"); @@ -1875,7 +1913,11 @@ static HttpResponse* http_request_internal(HttpClientRequest* req) { reused = 0; transport_close(&t); char* rd_err = NULL; - if (http_dial(req, &serv_addr, host, port, use_tls, via_proxy, &t, &rd_err) == 0) { + /* This connection came from the pool, so the resolve above was + * skipped; do it now. A failure here just means no retry. */ + if (resolve_dial_addr(dial_host, dial_port, &serv_addr, + &serv_addr_resolved) + && http_dial(req, &serv_addr, host, port, use_tls, via_proxy, &t, &rd_err) == 0) { free(rd_err); goto send_request; } @@ -1895,7 +1937,10 @@ static HttpResponse* http_request_internal(HttpClientRequest* req) { reused = 0; transport_close(&t); char* rd_err = NULL; - if (http_dial(req, &serv_addr, host, port, use_tls, via_proxy, &t, &rd_err) == 0) { + /* Pooled connection, so the resolve was skipped; do it now. */ + if (resolve_dial_addr(dial_host, dial_port, &serv_addr, + &serv_addr_resolved) + && http_dial(req, &serv_addr, host, port, use_tls, via_proxy, &t, &rd_err) == 0) { free(rd_err); goto send_request; } @@ -2086,7 +2131,10 @@ static HttpResponse* http_request_internal(HttpClientRequest* req) { reused = 0; transport_close(&t); char* rd_err = NULL; - if (http_dial(req, &serv_addr, host, port, use_tls, via_proxy, &t, &rd_err) == 0) { + /* Pooled connection, so the resolve was skipped; do it now. */ + if (resolve_dial_addr(dial_host, dial_port, &serv_addr, + &serv_addr_resolved) + && http_dial(req, &serv_addr, host, port, use_tls, via_proxy, &t, &rd_err) == 0) { free(rd_err); aether_caps_free(full_response, cap); full_response = NULL; From 9b6c55806e49720e4fb2fd429d6c5e9bd8e992f5 Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 23 Aug 2026 11:02:37 +0100 Subject: [PATCH 09/10] docs(changelog): record the lazy backend resolve (#1719) Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b9cc21b3..0d0d1d952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,19 @@ version number before tagging the release. ### Changed +- **`std.http.client` resolves the backend host only when it is about to dial** + (#1719). `getaddrinfo` ran unconditionally above the pool lookup, so every + request resolved the host and then discarded the answer on a pooled hit — a + lock-taking call per request for a result nobody used. Resolution now happens + behind a once-flag at the four dial sites (the initial dial plus three + pooled-connection retry paths). Worth **+0.56%** on the LB benchmark (47,887 → + 48,156 rps); the gain is small here because the benchmark's backends are + numeric IPs, which `getaddrinfo` short-circuits — against named upstreams, + where resolution can touch `/etc/hosts` or the network, it removes real work. + One deliberate behaviour change: a request hitting a live pooled connection + now succeeds even if the host has since stopped resolving, rather than failing + with "could not resolve host". An open connection does not need DNS. + - **`std.http.client`'s idle connection pool no longer walks its whole list on every request** (#1719). `http_pool_take` and `http_pool_put` each swept the idle list under the global pool mutex per request; with the default 15s idle From 425df429f711086387ddac0c55376cf1d0ab6590 Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 23 Aug 2026 16:12:32 +0100 Subject: [PATCH 10/10] Merge origin/main into perf/1719-cache-conn-addresses Resolves the CHANGELOG by rebuilding it from main and re-inserting this branch's four #1719 entries under a fresh [current]. The plain merge had folded them INTO the released [0.576.0] section: main renamed its [current] to [0.576.0] at release time, and the merge combined that heading with this branch's still-unreleased [current], so four unmerged entries ended up inside a tagged release with no conflict marker to show it. Every released section now byte-matches main, and [current] holds only the four #1719 entries. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 78 +++++++++++++++++++++++++++------------------------- 1 file changed, 40 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 977427472..4f17b619c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,44 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `main`, the release pipeline automatically replaces `[current]` with the next version number before tagging the release. -## [0.576.0] - -### Added - -- **`std.bignum` reads and writes decimal** (#1723). `to_hex` was the only way - out and `from_int` (bounded by `long`) or `from_bytes` the only ways in, so a - value larger than 64 bits could be computed but neither entered nor printed - in the base every published test vector and task statement uses — 25! - computed correctly and could only be shown as `cd4a0619fb0907bc00000`. - `to_decimal` renders base 10 with a leading `-` for negatives and `"0"` for - zero; `from_decimal` parses it back as `(value, err)`, strictly: an optional - `-` then ASCII digits and nothing else, so malformed input is an error rather - than a silently truncated number. Conversion divides by 10^9 — nine digits a - pass — rather than one digit at a time, so a thousand-digit value costs about - a ninth of the big-integer divisions the naive form would. - -- **`std.sort` sorts strings, and takes comparators** (#1722). `sort.strings` / - `sort.string_search` order by `std.string.compare` — lexicographic byte order, - binary-safe — and `ints_by` / `longs_by` / `floats_by` / `strings_by` take a - comparator for any other order. Previously the module sorted only `int`, - `long` and `float` arrays with no way to express a different ordering, so - anything else meant hand-writing a sort at each call site. `string[]` carries - no length, so the string forms take an explicit count. Separate `_by` names - rather than an optional argument, so the default path keeps a direct - comparison instead of an indirect call per element. Sorts remain in place and - are still not stable. - -- **`std.map` and `std.set` key/item snapshots can now be read** (#1724). - `map.keys` and `set.items` returned a snapshot that could only be held and - freed: there was no size or element accessor, so a map's key set was - unreachable from Aether and callers kept a parallel array of keys purely to - have something iterable. `keys_size`/`keys_get` and `items_size`/`items_get` - expose what the C snapshot already held — a contiguous array with an exact - count. The returned strings are borrowed, valid until the snapshot is freed - and only while the container still holds them; iteration order stays - unspecified, so sort for deterministic output. Out-of-range and null return - `""` rather than trapping. Nothing in the tree had ever read a key from a - snapshot, which is how the gap went unnoticed. +## [current] ### Changed @@ -103,6 +66,45 @@ version number before tagging the release. and `getsockname` falls from 133,162 calls to 50. The request still owns its own copies, so `http_request_free`'s contract is unchanged. +## [0.576.0] + +### Added + +- **`std.bignum` reads and writes decimal** (#1723). `to_hex` was the only way + out and `from_int` (bounded by `long`) or `from_bytes` the only ways in, so a + value larger than 64 bits could be computed but neither entered nor printed + in the base every published test vector and task statement uses — 25! + computed correctly and could only be shown as `cd4a0619fb0907bc00000`. + `to_decimal` renders base 10 with a leading `-` for negatives and `"0"` for + zero; `from_decimal` parses it back as `(value, err)`, strictly: an optional + `-` then ASCII digits and nothing else, so malformed input is an error rather + than a silently truncated number. Conversion divides by 10^9 — nine digits a + pass — rather than one digit at a time, so a thousand-digit value costs about + a ninth of the big-integer divisions the naive form would. + +- **`std.sort` sorts strings, and takes comparators** (#1722). `sort.strings` / + `sort.string_search` order by `std.string.compare` — lexicographic byte order, + binary-safe — and `ints_by` / `longs_by` / `floats_by` / `strings_by` take a + comparator for any other order. Previously the module sorted only `int`, + `long` and `float` arrays with no way to express a different ordering, so + anything else meant hand-writing a sort at each call site. `string[]` carries + no length, so the string forms take an explicit count. Separate `_by` names + rather than an optional argument, so the default path keeps a direct + comparison instead of an indirect call per element. Sorts remain in place and + are still not stable. + +- **`std.map` and `std.set` key/item snapshots can now be read** (#1724). + `map.keys` and `set.items` returned a snapshot that could only be held and + freed: there was no size or element accessor, so a map's key set was + unreachable from Aether and callers kept a parallel array of keys purely to + have something iterable. `keys_size`/`keys_get` and `items_size`/`items_get` + expose what the C snapshot already held — a contiguous array with an exact + count. The returned strings are borrowed, valid until the snapshot is freed + and only while the container still holds them; iteration order stays + unspecified, so sort for deterministic output. Out-of-range and null return + `""` rather than trapping. Nothing in the tree had ever read a key from a + snapshot, which is how the gap went unnoticed. + ## [0.575.0] ### Added