A socket whose body was not read to the end went back into the connection pool, and the next request read the leftovers as its status line. A write to a socket whose peer had gone away killed the host process. Both are fixed, along with the nine further instances of the first that verifying it turned up.
Reported by @yspbwx2010 in #15 and #16; the change is #17.
A library no longer kills its host with SIGPIPE. A write to a socket whose peer has gone away raises it, and a program that has not disarmed it — the default — is killed with exit status 141 rather than told. The descriptor is one this library created and the write is most often the close_notify its own pool clean-up sends, so this was the library's defect and not its caller's. mbedtls guards against it in net_prepare with a process-wide signal(SIGPIPE, SIG_IGN); replacing mbedtls's network layer with a custom BIO dropped that guard and put nothing in its place. MSG_NOSIGNAL, and SO_NOSIGPIPE on the platforms that spell it that way, is the better replacement anyway: a library has no business changing its host's signal disposition, and a program that wants SIGPIPE on its own stdout still gets it. Which of the two applies is decided by the target's own <sys/socket.h> and by nothing else — there is no configuration.
A connection is returned to the pool only when the body was read to the end its framing declared. Issue #15 named two of the eleven paths that could leak one; all eleven are fixed. Every one had the same shape — an early return that did nothing, where doing nothing left a dirty socket pooled — so dropping is now what doing nothing means. A guard drops on destruction and one call keeps the connection, on the single path where the body actually ended.
Three of the leaks were on ordinary paths with no timeout and no truncation involved. A redirect whose body is never read (the comment there promised a drain that was never written, and the recursive call picked the socket straight back up); a non-2xx whose error body is never read; and a download whose output file could not be opened. A fourth needed no timing at all: send() held Content-Length in an int, so 4294967296 became 0, the body was taken for empty, and the socket went back with four gigabytes owed on it. Two more were regressions 0.2.10 had introduced into the streaming reader — anyone on 0.2.10 using send_stream should upgrade.
The reason there were eleven and not one is that there were three readers. send, send_stream and download_to_file each carried a near-copy of the status-line parse, the header loop and the body loop, and every past hardening had landed on one or two of the three. #14 is the most recent example: it added a Content-Length branch to one copy and introduced two regressions doing it. There is now one status-line parser, one header reader and one body reader; the body reader returns where the body ended, which is the same question as whether the connection can be reused.
A status line is now required to be one. All three readers took the first run of digits they found and never checked the line began with HTTP/, which is where issue #15's statusCode = 999 came from — leftover body bytes read as BBBB 999 XHTTP/1.1 200 OK. parse_status_line is exported and unit-tested, as parse_content_length and parse_chunk_size_line are.
An interim 1xx response is read past rather than returned as the answer, per RFC 9112 §2.1. A 103 Early Hints — sent proactively by several CDNs — or a 100 Continue — sent for any request carrying Expect: 100-continue — was reported to the caller as the result, with the real response left to be read as its body.
An end of stream is no longer reported as a transport error. The BIO answered a peer's FIN with MBEDTLS_ERR_NET_CONN_RESET; mbedtls's own passes the zero through, and ssl_fetch_input tests for exactly that zero to produce SSL_CONN_EOF. Since most servers close without a TLS close_notify, a body whose framing is the close — legal, RFC 9112 §6.3 — read as truncated, and download_to_file reported failure for a file that had arrived complete and correct.
Also fixed: a header block cut short by a timeout is an error rather than the end of the headers; the CRLF after chunk data is verified in all three readers; 204 and 304 are no longer read for a body they do not have; TLS back-pressure is waited on rather than retried once and abandoned; and a header block or trailer section of unbounded length is refused, where before an endless supply of well-formed header lines could grow the client's memory without limit.
Three additions, none of which change what existing code means. HttpResponse::bodyComplete and bodyError say whether the body arrived in full and why not — a truncated 200 and a complete 200 were previously indistinguishable — and ok() deliberately does not consult them, so if (res.ok()) means exactly what it did. HttpClientConfig::maxResponseBodyBytes (64 MiB) bounds what send() will hold in memory, because the size it was about to allocate came from a header. HttpClientConfig::retryOnStaleConnection (default true) resends once when a pooled connection turns out to have been closed while idle, which is routine server behaviour the client cannot see until it writes; without it such a request returned statusCode = 0, "No response" for a request the server never saw. The window is one attempt, on a pooled connection, before a single response byte has arrived.
tests/ had no keep-alive coverage at all, which is the direct reason 0.2.10's regression was merged green. It now scripts a TLS server in-process — the library speaks only HTTPS, so a plain listener cannot reach the code under test — and every pool test asserts how many TCP connections the server saw as well as what came back. Issue #15's own report contains a case whose output is byte-for-byte correct and whose only symptom is the connection count. Fifty-three tests, and each fix was checked by mutation: reverting it in the source makes specific tests fail. Three tests did not survive that check and were rewritten.
The library is now built and run above openkal on every push. examples/openkal compiles these sources against musl ported onto the portable kernel ABI, links one static binary carrying both mbedtls and kal_net_connect, and makes a real HTTPS request through it. It is a separate CI job because whether MSG_NOSIGNAL or SO_NOSIGPIPE exists is decided by the C library rather than the operating system, and a #ifdef that is wrong about that compiles cleanly on the ordinary job and fails there.
mcpp new --template tinyhttps now works. Three starting points, one per entry point: fetch (the default), download and stream. tools/template_smoke.sh renders and builds them against the working tree, because mcpp new can only reach a template that is already published — checking them after the release would make the first user the one who finds out.
Past releases silently accepted malformed responses that this one reports as errors. Those are not new failures; they are failures that were previously invisible, and bodyError now says which.