Commit Graph

2039 Commits

Author SHA1 Message Date
yhirose
f00e476f1b Release v0.53.0 latest v0.53.0 2026-08-09 19:54:29 -04:00
yhirose
8e702d3837 Gracefully drain socket before close in Server::process_and_close_socket (#2534)
* Gracefully drain socket before close in Server::process_and_close_socket

Closing a connection while the receive queue still has unread data,
or while bytes are still in flight, can make the OS send an abortive
RST instead of a graceful FIN. On Windows this surfaces as
WSAECONNABORTED/WSAECONNRESET on the peer's read, which can make an
otherwise fully-written response look like a failed request -- a
likely contributor to the ServerTest.HTTP2Magic flakiness tracked in
#2533.

Add detail::close_socket_gracefully(), which half-closes the write
side, drains any queued/in-flight bytes (bounded to 100ms / 1MB),
then performs the final shutdown+close. Use it in
Server::process_and_close_socket.

Root cause and fix mechanism identified by @Hyukya in #2533.

* Rename close_socket_gracefully to drain_and_close_socket

'gracefully' already means something specific in this codebase: whether
to send a TLS close_notify before closing (shutdown_ssl's
shutdown_gracefully param, ClientImpl::disconnect(gracefully),
tls::shutdown(session, graceful)). Reusing the word for an unrelated
TCP-level drain-before-close made the new function read as part of that
TLS machinery when it isn't. Rename it to describe what it does instead,
matching the existing close_socket/shutdown_socket and
WebSocketClient::shutdown_and_close naming.
2026-08-09 19:43:19 -04:00
yhirose
19333f80d4 Fix Mbed TLS/wolfSSL hostname verification bugs in set_sni()
The stricter ws::Result error checks added in 6018c7f and 86d0210
exposed two backend-parity bugs in setup_client_tls_session(), shared
by SSLClient and WebSocketClient since their TLS setup was merged:

- enable_server_hostname_verification(false) had no effect on Mbed TLS
  or wolfSSL for DNS hosts: mbedtls_ssl_set_hostname() and
  wolfSSL_check_domain_name() bind SNI and handshake-time identity
  checking together, so the identity check ran regardless of the
  option, failing the handshake before the post-handshake
  server_hostname_verification check was ever reached.

- On a genuine wrong-hostname failure, Mbed TLS reported the generic
  Error::SSLServerVerification instead of
  Error::SSLServerHostnameVerification, because
  MBEDTLS_ERR_X509_CERT_VERIFY_FAILED was mapped without looking at
  which verify flag actually caused it.

Fixes:
- set_sni() now takes a verify_hostname flag. wolfSSL skips
  wolfSSL_check_domain_name() when it's false. Mbed TLS can't request
  SNI without also arming the CN/SAN check, so it installs a verify
  callback that masks the mismatch flag instead - a self-contained one
  when the session has no user verify callback of its own, so it never
  reads the process-wide set_verify_callback() slot another client may
  have populated (this was caught by ASAN as a stack-use-after-scope:
  VerifyCallbackTest.VerifyContextFields leaves a dangling lambda
  there because MbedTlsSession never had a reason to consult it
  before).
- map_mbedtls_error() now takes the handshake's verify flags and
  reports HostnameMismatch when CN/SAN mismatch is the only one set,
  matching the wolfSSL mapping and the post-handshake identity check.
- The duplicated verify-flags/error-mapping/backend_code logic in
  connect() and connect_nonblocking() is factored into
  fill_mbedtls_tls_error(); the duplicated flag-clearing in the two
  verify callbacks is factored into mbedtls_clear_cn_mismatch(); both
  use the existing hostname_mismatch_code() accessor instead of the
  raw Mbed TLS macro.

Also tightens SSLClientTest.ServerHostnameVerificationError_Online to
assert the specific error code now that all three backends agree,
rather than accepting Mbed TLS's old fallback value.

Verified full non-online suite green on OpenSSL (791), Mbed TLS (737),
and wolfSSL (735), plus the split build, plus the Online
hostname-mismatch test against badssl.com on all three backends.
2026-08-07 21:31:21 -04:00
yhirose
86d0210391 Add WebSocketClient::enable_server_hostname_verification
WebSocketClient's TLS setup already threaded
ClientTlsSessionOptions::server_hostname_verification through
setup_client_tls_session(), the same path SSLClient uses, but never
exposed a way to set it: create_stream() called setup_client_tls_session()
without an options argument, so the default (verification on) was the
only reachable value.

Add the public setter, mirroring ClientImpl/SSLClient/Client, and wire
it into create_stream()'s ClientTlsSessionOptions. Last open item from
issue #2531's WebSocketClient/SSLClient API alignment.
2026-08-07 18:48:59 -04:00
yhirose
6018c7feb3 Return ws::Result from WebSocketClient::connect() instead of bool
Issue #2531 asked for connect() to expose error detail the way
ClientImpl/SSLClient do via Result, instead of collapsing every failure
into a bare bool. The groundwork (detail::ClientTlsSessionError) was
already laid during the WebSocketClient/SSLClient dedup but left
unwired.

- Add httplib::ws::Result: explicit operator bool(), error(), and
  flattened upgrade-response accessors (status(), headers(),
  get_header_value(), has_header()); ssl_error()/ssl_backend_error() on
  SSL builds.
- Add Error::WebSocketHandshake for upgrade-validation failures
  (non-101 status, bad Sec-WebSocket-Accept, bad Upgrade/Connection
  headers).
- Extract detail::parse_status_line from ClientImpl::read_response_line
  and reuse it in read_websocket_upgrade_response, replacing the
  previous "HTTP/1.1 101" substring match with a proper parse. Non-101
  responses now surface their status and headers instead of being read
  and discarded.
- Wire WebSocketClient::create_stream() to capture ClientTlsSessionError
  so TLS failures (SSLServerVerification, SSLServerHostnameVerification,
  ...) reach the caller with backend error codes.
- Update tests and README-websocket.md accordingly.

This is a source-breaking change for callers that assign the result to
bool (e.g. bool ok = cli.connect();); if (cli.connect()) and gtest's
ASSERT_TRUE/EXPECT_FALSE(...) macros are unaffected since operator bool
still participates in contextual conversion.
2026-08-07 18:02:29 -04:00
yhirose
8d5085df1b Update README 2026-08-07 17:26:13 -04:00
yhirose
8f0ff32056 Add WebSocket TLS and timeout recipes to the Cookbook
T04 (mTLS) had grown a "WebSocketClient" subsection describing
wss:// client certificates, and c12/t02 were getting similar
WebSocketClient asides for timeouts and CA paths. The Cookbook's
own index already separates WebSocket into its own category
(W01-W04) from TLS/Security (T01-T05) and Client (C01-C19), so
burying WebSocketClient specifics inside those pages fought the
site's structure.

Move that content into two new recipes under the WebSocket
category instead:

- W05: wss:// TLS setup (set_ca_cert_path CA directory parity,
  PemMemory client certificate)
- W06: WebSocketClient's three timeouts, including the recently
  added chrono overloads

T04, T02, C12, and W01 now carry a single reference link to the
new pages instead of duplicated explanations, matching the site's
existing cross-link convention.

While rewriting T04's client-side section, noticed it documented
SSLClient's file-path constructor but not its PemMemory one, even
though the server-side section covered both forms for SSLServer.
Added the missing PemMemory example so both sides are symmetric.
2026-08-07 17:17:07 -04:00
yhirose
2dd44d0f52 Document WebSocketClient/SSLClient TLS parity gaps in the READMEs
WebSocketClient::set_connection_timeout (both the time_t and
chrono overloads) was missing from README-websocket.md's API
reference and the timeout example, even though set_read_timeout
and set_write_timeout were both listed.

README.md never documented the PemMemory in-memory constructor
that SSLServer and SSLClient both have, so mTLS setup only showed
the file-path form. Add a "Mutual TLS (mTLS)" section covering
both forms for server and client, and note that
ws::WebSocketClient's wss:// constructor takes the same PemMemory
struct.

Also note, next to Client::set_interface, that WebSocketClient has
the same method, matching the existing cross-reference for
set_hostname_addr_map right below it.
2026-08-07 17:16:54 -04:00
yhirose
86abc9a0ea Give WebSocketClient the PemMemory client certificate constructor SSLClient has
Adds ws::WebSocketClient::PemMemory and a constructor overload that
installs an in-memory client certificate on the TLS context, enabling
mutual TLS for wss:// connections. The certificate is silently ignored
for ws:// URLs, consistent with the existing TLS-only setters such as
set_ca_cert_path().

Part of the interface alignment discussed in #2531.
2026-08-07 16:34:12 -04:00
yhirose
bd02a50cbb ci: auto-comment on issue #2533 when windows-without-SSL job fails
Temporary instrumentation to track the intermittent windows-without-SSL
failures reported in #2533. When the job fails on a push, it posts the
run URL, commit, and per-shard failed-test lines as a comment on the
issue, building up failure-pattern history automatically.

This should be removed once the root cause is found and fixed.
2026-08-07 16:07:40 -04:00
yhirose
1c2607cbb8 Merge the SSLClient and WebSocketClient TLS session setup
SSLClient::initialize_ssl kept its own copy of the session setup that
detail::setup_client_tls_session already implemented for WebSocketClient.
Extend the shared function with the pieces only SSLClient needed - a session
verifier, an independent hostname verification flag, the context mutex,
Windows Schannel verification and error details - and let initialize_ssl
build a ClientTlsSessionOptions and call it. All of them default, so
WebSocketClient's call site is unchanged.

This settles one difference between the two: WebSocketClient used to call
tls::set_hostname for named hosts, which on OpenSSL turns on verification
during the handshake, while SSLClient always set SNI only and verified
post-handshake. The shared function now does the latter for both, so
tls::set_hostname loses its last caller and goes away, as does the
write-only SSLClient::verify_result_.

Certificate verification with a host name rather than an IP literal was the
one combination the WebSocket tests never covered, and it is exactly the
path this normalizes. WebSocketSSLDnsHostTest fills that in; cert2 gains a
DNS:localhost SAN so a name can be verified against it.
2026-08-07 13:41:39 -04:00
yhirose
98be5fd3a6 Share the default Host and User-Agent header logic between the clients
ClientImpl::prepare_default_headers and WebSocketClient::prepare_default_headers
each built the Host value with the same AF_UNIX special case and appended the
same User-Agent. Move both into detail:: so there is one copy.

The Host helper returns only the value, because the two callers disagree on
where it goes: ClientImpl prepends it per RFC 9110 5.3, WebSocketClient appends.
The User-Agent helper takes the Request, since it has to consult and set a
header rather than compute a string, and it stays inside ClientImpl's
content_receiver branch so that path keeps sending no User-Agent.
2026-08-07 13:41:39 -04:00
yhirose
d2ef193b9c Let WebSocketClient take a CA directory the way ClientImpl does
WebSocketClient::set_ca_cert_path took a single path and create_stream()
hardcoded an empty directory when calling detail::load_client_ca_config, while
ClientImpl has always accepted (ca_cert_file_path, ca_cert_dir_path = ""). Give
WebSocketClient the same signature and store the directory, so both clients
configure CA loading identically. The one-argument form is unchanged for
callers.

Also note at both call sites why the "load the CA config once" guard differs:
SSLClient needs call_once because one client serves concurrent requests, and
WebSocketClient does not because connect() is not safe to call concurrently
anyway.
2026-08-07 13:41:38 -04:00
yhirose
a1aa2ad9cd Give WebSocketClient the chrono::duration timeout setters ClientImpl has
WebSocketClient only accepted timeouts as (time_t sec, time_t usec), while
ClientImpl has taken std::chrono::duration overloads for its read, write and
connection timeouts for a long time. Add the same three overloads, forwarding
through the existing detail::duration_to_sec_and_usec helper so the split
matches ClientImpl exactly.

The template bodies go above the first split.py BORDER, next to the class, so
that the .h/.cc split keeps them in the header where instantiation needs them.
2026-08-07 13:41:38 -04:00
shaozk
b2b1d56d6d fix: server example typos (#2532) 2026-08-06 19:34:24 -04:00
metsw24-max
2b8658fa99 match mount points on a segment boundary in handle_file_request (#2529) 2026-08-03 17:51:50 -04:00
yhirose
095a5c1caf Release v0.52.0 v0.52.0 2026-08-02 19:41:06 -04:00
yhirose
6e8a7dcd3f Bind the ordering tests to an ephemeral port (#2528)
test.cc says right above the PORT constant that it is only for the
legacy fixtures and that new standalone tests must use
bind_to_any_port() instead. The six tests added with the insertion-order
work all took the shared PORT anyway, which made them one more thing
contending for it.

Move them to bind_to_any_port() plus listen_after_bind(), the pattern
the note asks for and the rest of the standalone tests already use.
HeadersOrderTest.ReceivedFieldsKeepTheirOrder sends a raw request rather
than going through Client, so send_request() gains an optional port that
defaults to PORT and leaves its other 36 callers alone.

Checked by holding PORT open from another process while running the six:
they pass, where before the change listen(HOST, PORT) would have failed.
2026-08-02 19:33:07 -04:00
yhirose
148d61a6a3 Give the raw listener in wait_writable_INET the socket options its neighbours have (#2527)
SocketStream.wait_writable_INET binds PORT + 1 directly, and it was the
only raw listener in the file that did not set SO_REUSEPORT/SO_REUSEADDR
first. PORT + 1 is shared with three SSL redirect tests and with
VulnerabilityTest.CRLFInjectionInHeaders, so once any of those has run,
the TIME_WAIT entries they leave make bind() fail here.

That failure did not surface where it happened. The bind runs on a
worker thread, where a failed ASSERT_EQ only returns from the lambda, so
the test carried on and failed later at ASSERT_NE(disconnected_svr_sock,
-1) with nothing pointing at the port.

Within one run of the suite the test comes before everything that uses
PORT + 1, so a clean run passes; it failed when a previous process had
left TIME_WAIT behind, which made it look intermittent. Running any of
those four tests first and then this one reproduces it every time: 4 of
4 before this change, 4 of 4 passing after, with up to eight TIME_WAIT
entries on the port.

Call default_socket_options(), which is what the library does for its
own listeners and what the other raw listeners in this file already do.
2026-08-02 19:32:03 -04:00
yhirose
c48ed1ed9a Share the query pair splitting between its two callers (#2526)
parse_query_text() and normalize_query_string() both walk a query string
and both open with the same eight lines to cut one "key=value" span at
its first '='. Give that a name and call it from both.

divide() puts everything before the first delimiter in the left half and
the rest in the right, so a span with no '=' lands entirely in key and
leaves val empty. Both callers rely on that: parse_query_text() records
a bare "flag" with an empty value, and normalize_query_string() emits it
back without an '='. The helper's comment says so, since that is the
part of divide()'s behaviour a reader has to know to follow either
caller.
2026-08-02 19:31:46 -04:00
yhirose
8d428361fb Count entries with count() rather than equal_range plus distance (#2525)
Seven accessors each spelled the same two lines:

    auto r = x.equal_range(key);
    return static_cast<size_t>(std::distance(r.first, r.second));

The containers behind them all became detail::insertion_ordered_multimap
along the way, so count() is available and says what these functions
mean. The work is the same either way: equal_range() scans to the first
match and distance() then walks the restricted iterator to the end,
comparing keys at each step, which comes to one pass over the entries,
and count() is one pass too. This is a readability change, not a faster
one.

Covers get_header_value_count, Request and Response
get_trailer_value_count, Request::get_param_value_count,
MultipartFormData get_field_count and get_file_count, and
Result::get_request_header_value_count. The distance() call in
get_param_values() stays, since it sizes a reserve() and needs the range
anyway.
2026-08-02 19:31:22 -04:00
yhirose
23f67f25c2 Preserve the order of multipart form parts (#2524)
FormFields and FormFiles were std::multimaps, which sort by field name.
RFC 7578 5.2 says a form processor "SHOULD send back results in order"
and that "Intermediaries MUST NOT reorder the results", so a handler
walking req.form.fields saw the parts alphabetised rather than as they
were sent, and a body received for forwarding could not be reproduced.

Point both at the insertion-ordered container #2523 generalized, with
std::equal_to since field names are case-sensitive. Entries sharing a
name already kept their relative order under std::multimap; what is
recovered here is the order across different names.

Server::read_content() keeps a FormFields::iterator alive across the
content callbacks that fill the part it points at, which is the one
thing this container could have broken: it is vector-backed, so a later
emplace can reallocate and leave an older iterator dangling. The code is
safe because the iterator is reassigned by the same emplace that could
reallocate, and is only read while the flag set alongside it says so.
ContentSurvivesContainerGrowth pins that down with 64 parts, enough to
grow the vector through seven reallocations; reverting the reassignment
makes it abort under ASan rather than fail quietly.

Growth also never copies a part's payload: the parser inserts the entry
with an empty content and appends the body bytes afterwards, and both
mapped types are nothrow-move-constructible, so a reallocation steals
the string buffers rather than deep-copying them.
2026-08-02 18:37:20 -04:00
yhirose
7963c382d6 Preserve the order of query parameters (#2523)
Params was a std::multimap, which sorts by parameter name. Parsing a
query string therefore threw away the order it arrived in, and building
one back out of Params handed the caller an alphabetised query rather
than the one they wrote. ClientImpl::send() takes that path whenever a
request carries Params without a query already in its path, so a caller
signing its query string could not reproduce the order it asked for.
normalize_query_string() exists in part to work around exactly this.

Generalize the container #2520 introduced for Headers into
detail::insertion_ordered_multimap<Mapped, KeyEqual> and alias both
types to it. Params passes std::equal_to, so parameter names stay
case-sensitive, while Headers keeps matching field names
case-insensitively. The name says insertion_ordered because in STL
vocabulary std::multimap is the ordered one, which is the reading this
change exists to correct.

This is a correctness change, not a performance one. Measured on the
real paths against the previous implementation, parse_query_text() over
eight parameters goes 931.5ns -> 935.1ns and params_to_query_str()
374.7ns -> 377.2ns; both are inside the run-to-run noise. The container
is a small share of that work, most of which is the string building in
decode_query_component().

Params picks up the same API changes Headers took in #2520: iterators
follow std::vector rules, value_type is std::pair<std::string,
std::string>, and insert(hint, value) is gone. Headers itself becomes an
alias rather than a class, so the names it appears in mangle differently
again; #2520 has not shipped in a release yet, so this costs nothing on
top of the break already there.
2026-08-02 12:40:00 -04:00
yhirose
486c81b275 Determine the final transfer coding across Transfer-Encoding lines (#2522)
RFC 9110 5.3 lets a coding list be split across several Transfer-Encoding
lines, which combine, in the order the lines were received, into one
comma-separated list. RFC 9112 6.1 then frames the message as chunked
only when chunked is the final coding of that combined list.

is_chunked_transfer_encoding() could not apply that rule while Headers
was an unordered_multimap, since the order of the lines was not
recoverable, so it fell back to reporting any message naming chunked on
any line as chunked. Headers now preserves the order the lines were
received in, so read the final coding directly: the last token of the
last line.

The fallback erred toward reporting chunked because mis-reading a
chunked message as unframed leaves its body in the socket, where a
keep-alive connection parses it as a smuggled request. That direction is
no longer needed. A request whose combined list ends in something other
than chunked is now reported as not chunked, and process_request()
answers 400 and closes rather than letting it reach the "no body" path,
which is what it already did for the single-line "chunked, gzip" form.
So `Transfer-Encoding: chunked` followed by `Transfer-Encoding: gzip` is
rejected instead of being read as chunked, and `gzip` followed by
`chunked` is still accepted.

A trailing line carrying no coding at all now leaves the combined list
ending in nothing rather than inheriting the coding from the line
before it.
2026-08-02 01:05:26 -04:00
yhirose
be28cf9435 Run CIFuzz only for pull requests that touch the fuzzed code (#2521)
The fuzzers build httplib.h and the targets under test/fuzzing, so a pull
request that touches neither has nothing for CIFuzz to exercise. Fuzzing
is by far the longest job in CI: 600 seconds of fuzzing on top of
building the OSS-Fuzz image, which came to 12m20s on a recent run while
every other job finished within 5m7s.

Filter the trigger by path rather than shortening fuzz-seconds. OSS-Fuzz
recommends 600 seconds as a minimum, and the budget is divided among all
of the project's fuzz targets, so with five targets a shorter run would
leave each one well under two minutes. Skipping the job for
documentation-only changes cuts the wait without giving up any fuzzing
on the pull requests that do reach the parsers.
2026-08-02 00:33:11 -04:00
yhirose
d860c842ea Preserve the order of header fields with the same name (#2520)
RFC 9110 5.3 makes the order of header fields sharing a field name
significant, but Headers was a std::unordered_multimap, which gives no
ordering guarantee for equivalent keys. libstdc++ hands duplicates back
in reverse insertion order while libc++ uses insertion order, so
get_header_value() returned a different field depending on the platform,
and code that picks a value out of an accidentally or maliciously
duplicated field name had no way to say which one it wanted.

Replace Headers with a small container that keeps the fields in the
order they were received or set. Storage is a flat vector and lookup is
a linear scan, which beats hashing for the at most
CPPHTTPLIB_HEADER_MAX_COUNT fields a message carries. begin()/end() walk
every field, while find() and equal_range() hand back the same iterator
type restricted to one field name; equality compares only the position,
so a restricted iterator still compares equal to end(). Erasing an
equal_range() therefore removes only the fields with that name and
leaves interleaved fields alone.

std::multimap was the smaller change but sorts by field name, which
would stop control data such as Host from leading the message. Instead
Host is now prepended via emplace_front() so it keeps its place at the
front of a request.

Two side effects worth noting: incrementing past the last field of a
name now saturates at end(), so an out-of-range id passed to
get_header_value() returns the default instead of running off the
container as it did before; and iterators follow std::vector rules, so
they are invalidated by insertion.

Fixes #2509
2026-08-02 00:22:41 -04:00
yhirose
8e08c22783 Cut a syscall and the byte-at-a-time line reader out of the request path (#2513)
* Skip the redundant readability poll on the first read of a request

process_server_socket_core() calls keep_alive(), which polls the socket
and only invokes the callback once it reports readable. The stream is
then constructed and its first read polls the very same socket again
before calling recv, asking the kernel a question that was answered
microseconds earlier.

Profiling a request shows how little else is going on: of the samples
taken while the worker was off the keep-alive wait, 85% sat in syscalls
(recv, send, and the two polls) and only 15% in header parsing, routing
and response serialization. Removing one of five syscalls per request is
worth more than anything reachable inside that 15%.

Let the caller hand the stream what it already knows. The hint is
consumed by the first read, so body reads and every later request keep
polling as before, and set_read_timeout() during a WebSocket upgrade is
unaffected. Nothing is skipped when a read finds the buffer empty on its
own. The accepted socket also carries SO_RCVTIMEO from listen_internal(),
so even a wrong hint could not block forever.

Measured with wrk -t2 -c8 over three interleaved runs, server CPU per
request drops from 33.4/32.5/29.7us to 23.6/22.9/23.9us, and throughput
rises 10-15%. The TLS path gets the same treatment - it shares
process_server_socket_core(), and SSLSocketStream::read() polls after
tls::pending() comes up empty - for a smaller 44.9->40.2us, crypto being
the larger cost there.

Removing the second poll, in write(), looks worth another 15% but is left
alone: it would rely on SO_SNDTIMEO being set on every socket a
SocketStream is built over, and the TLS write path retries on WantWrite
in a way that turns a send timeout into a much longer stall.

* Scan buffered bytes for line ends instead of reading one at a time

stream_line_reader::getline() pulls the request line and every header
through strm_.read(&byte, 1). A 300-byte header block therefore costs
300 virtual calls, each with its own bounds check and one-byte copy.
None of them are syscalls - SocketStream has already pulled up to 4KB
off the socket - so this is pure CPU spent one character at a time.

On a Linux CI runner read_headers() takes 2.44us for seven headers, of
which 1.53us is this loop. That is 63% of header parsing, and header
parsing is a bigger share of the total on Linux than the profiling on
macOS suggested: 3.8us of HTTP processing against 16us of server CPU
per request, versus roughly 3us against 30us on macOS. Cheap syscalls
leave parsing a larger slice of what remains.

Let a stream offer what it has already buffered, and scan that for the
terminator in one pass. Streams that do no buffering of their own report
none and keep the existing byte loop, so Stream subclasses outside the
library are unaffected, as is the TLS path - SSLSocketStream has no
buffer of its own to expose.

A bare LF still does not end a line in the default configuration, so the
scan looks for CRLF and carries the CR across a chunk boundary. Both
append overloads now share one path: the per-character one used to
decide where to write from fixed_buffer_used_size_ alone, which after a
bulk append had spilled into the growable buffer would send the byte to
a fixed buffer that ptr() and size() no longer read. That dropped a byte
from over-long request lines - caught by ServerTest.TooLongRequest and
AlmostTooLongRequest.
2026-08-01 22:46:10 -04:00
yhirose
f51df1473b Merge branch 'metsw24-max-mmap-map-failed-guard' 2026-08-01 22:21:46 -04:00
yhirose
15a23abd7e Clarify the MAP_FAILED guard comment and assert the cleared size
Explain why `addr_` is reset before `close()`: `munmap()` must not be
called with the sentinel. The test also checks `size()`, since the hazard
is a stale size paired with a sentinel `data()`.
2026-08-01 22:21:26 -04:00
yhirose
2e37c51921 Merge branch 'mmap-map-failed-guard' of github.com:metsw24-max/cpp-httplib into metsw24-max-mmap-map-failed-guard 2026-08-01 22:16:28 -04:00
yhirose
571717adb8 Merge branch 'metsw24-max-range-206-only' 2026-08-01 21:54:25 -04:00
yhirose
5539a66c63 Cover the non-206 Range paths and drop the duplicated test route
The added test only exercised a 403 with a single range, leaving three
paths that were equally broken untested: a suffix range, whose first_pos
is still -1 when the bounds asserts are compiled out under NDEBUG; two
ranges, where apply_ranges() makes no boundary for a non-206 response and
the multipart branch wrote an empty one; and a 2xx that is not a 206,
which was validated but still announced the full content length.

Fold the copied route back into /streamed-with-range with a status query
parameter, following the ?error idiom already used there.
2026-08-01 21:47:35 -04:00
yhirose
6b723fbf9d Merge branch 'range-206-only' of github.com:metsw24-max/cpp-httplib into metsw24-max-range-206-only 2026-08-01 21:38:55 -04:00
yhirose
1562f0ec4f Pass unrecognized Content-Encoding values through instead of failing (#2518)
Since 8bba34e, prepare_content_receiver() rejected every non-empty
Content-Encoding that create_decompressor() could not handle, conflating an
unrecognized content coding with a known one whose support was not compiled
in. A response labeled `Content-Encoding: UTF-8` -- a header some servers
misuse to advertise a character set -- therefore failed with 415, which the
client surfaced as the generic Error::Read. Before that commit the raw body
came back untouched.

Restore that behavior: reject only a coding cpp-httplib recognizes but was
not built with, and leave an unrecognized one (including "identity") alone.
Match codings case-insensitively while here, as RFC 9110 8.4.1 requires;
otherwise `Content-Encoding: GZIP` would look unrecognized and hand back a
still-compressed body.

open_stream() had the mirror-image problem. It silently passed the payload
through whenever create_decompressor() returned null, so a gzip response on
a build without zlib reached the caller still compressed, and it never
checked is_valid() -- gzip_decompressor::decompress() only asserts, so a
failed inflateInit2() was undefined behavior in release builds. It now
applies the same policy as the buffered path.

Add Error::UnsupportedContentEncoding so callers can tell this apart from a
read failure, and report an unusable decompressor as Error::Compression. The
status read_content() writes was previously discarded into an uninitialized
dummy_status.
2026-08-01 21:25:22 -04:00
yhirose
a691e531c3 Close the listening socket in stop() even when not serving (#2517)
* Close the listening socket in stop() even when not serving

Server::stop() released svr_sock_ only under `if (is_running_)`, and
is_running_ is set inside listen_internal(). A server that bound with
bind_to_port() / bind_to_any_port() and never reached listen_after_bind()
therefore kept its listening descriptor for the life of the process:
~Server() is defaulted and svr_sock_ is a bare atomic<socket_t>, so nothing
else closes it. The port stayed held too.

Close whenever the socket is open instead. The exchange already made this
safe against a concurrent accept loop, so dropping the is_running_ gate also
removes a TOCTOU between the check and the exchange.

* Fail listen_after_bind() when stop() already closed the socket

With stop() now releasing a bound-but-not-serving socket, a stop() that
lands between bind and listen used to slip through listen_internal():
the accept loop saw INVALID_SOCKET, never iterated, and returned success
without ever serving, pulsing is_running_ just long enough that a
wait_until_ready() caller could miss it and spin forever. Return false
instead and mark the server decommissioned the way any failed listen
does, so waiters wake up.

Also drop the assert() in stop(). It read is_running_ and svr_sock_
separately, which is exactly the race the exchange removes: a second
stop() while the accept loop is still unwinding sees is_running_ true
and svr_sock_ already INVALID_SOCKET, aborting debug builds.

The regression test checks the released port with a raw connect()
instead of a Client request: against the old code the connection is
accepted into the backlog and never answered, which would hang the test
rather than fail it.
2026-08-01 19:57:37 -04:00
yhirose
ae8356d86e Clean up addr_map hostname support
Follow-up to 49b921b.

Move the duplicated addr_map lookup into detail::apply_addr_map, shared by
ClientImpl::create_client_socket and WebSocketClient::connect.

Add a WebSocketClient test for a hostname mapped value, so that path has
the same coverage as the Client one. Guard its teardown with scope_exit:
a failing ASSERT_TRUE returns from the test body, and destroying a still
joinable std::thread calls std::terminate, taking the whole binary down.

Document set_hostname_addr_map in README. It had no entry at all.
2026-08-01 14:51:35 -04:00
Kim, Hyuk
49b921b52d Allow addr_map_ values to be hostnames, not just IP literals (#2515)
addr_map_ only accepted IP literals as mapped values; a non-IP value was
passed as the `ip` argument and rejected by getaddrinfo's AI_NUMERICHOST
path. The lookup in ClientImpl::create_client_socket and
WebSocketClient::connect now checks the mapped value with
detail::is_ip_address: IP literals keep the existing AI_NUMERICHOST path,
while hostnames are passed as the connect host so they get resolved.

host_ is never touched, so it keeps supplying the Host header and SNI in
both cases.

is_ip_address() moved into the non-SSL detail block so that both client
code paths can use it without CPPHTTPLIB_SSL_ENABLED.

This also fixes the documented Unix domain socket client example, where
the mapped value is a socket path: it previously took the AI_NUMERICHOST
path and never reached the AF_UNIX branch in create_socket.
2026-08-01 14:29:20 -04:00
yhirose
447b9c4a29 Buffer the WebSocket handshake before writing it to the socket
Follow-up to #2514. The rebuilt handshake wrote the request line straight
to the socket, so a header rejected by check_and_write_headers left a
truncated "GET /ws HTTP/1.1" sitting in the peer's buffer before the
connection was torn down, and every header cost its own small write.

Build the request into a BufferStream and flush it in one go, matching
ClientImpl::write_request. The new test drives a raw listener and asserts
the peer sees a clean EOF with zero bytes; without this change it observes
18.

Also fold WebSocketTest.HostHeaderInHandshake into
WebSocketTest.DefaultHeadersInHandshake, which covers the same Host
assertion through the capture helper #2514 introduced.
2026-08-01 14:09:53 -04:00
yhirose
f406808497 Merge pull request #2514 from Hyukya/master
websocket: rebuild handshake on Request (support Host override, align with HTTP client behavior)
2026-08-01 08:20:23 -04:00
hyuk.kim
2c28d2fa3e websocket: rebuild handshake on Request/header pipeline
Replace the hand-built upgrade request string in
perform_websocket_handshake with the same Request,
write_request_line, and check_and_write_headers path used by
ClientImpl::open_stream. Add WebSocketClient::prepare_default_headers
to inject Host and User-Agent defaults; protocol-mandatory headers
(Upgrade, Connection, Sec-WebSocket-Key/Version) are always
overwritten.
2026-08-01 17:15:58 +09:00
yhirose
f24e79aab9 Fix benchmark-run reporting success without measuring anything
The first run of this workflow exposed three problems.

Crow's amalgamated header includes <asio.hpp>, which no runner provides,
so the build died immediately. It compiled locally only because CPATH
happened to point at Homebrew's include directory. Install asio
explicitly, and on macOS pass its include path through CROW_CXXFLAGS.

The macos runner image has no Go, so bombardier could not be installed.
Add actions/setup-go, which also pins a known toolchain on Linux.

Worst of all, the ubuntu job reported success. `make ... | tee` returns
tee's status, so the failed build was invisible. Enable pipefail. That
alone is not enough: every recipe in benchmark/Makefile ends in `kill`,
so make still exits 0 when bombardier itself fails to run. Assert that
the expected number of "Reqs/sec" lines came out.
2026-07-30 20:59:12 -04:00
yhirose
60f285a301 Add a workflow to run the committed benchmark on CI
benchmark/Makefile has always been local-only, so the numbers it produces
were never recorded anywhere. Wire it up to a manual workflow so a run can
be kicked off and its output kept in the job summary.

This does not gate anything: it reports absolute throughput for the
current ref, with Crow v1.3.1 alongside for reference. Absolute req/s is
only comparable against other runs on the same runner type, which is why
the ref, runner and load parameters are recorded next to the numbers.

Use benchmark-ab instead when the question is whether a specific change
made things faster; comparing absolute numbers across runs cannot answer
that.

Linux and macOS only. Windows needs benchmark/Makefile rewritten first,
since it relies on nc, & and kill.
2026-07-30 20:52:50 -04:00
yhirose
29ceccecd6 Add A/B throughput benchmark for comparing two refs
The existing test_benchmark asserts a single request completes within
5ms, which catches gross connection-setup regressions but cannot see
throughput changes: the effects we care about are tens of microseconds
per request, a hundredth of that resolution. There was no way to answer
"does this patch make the server faster" other than measuring by hand.

Absolute req/s is not usable for that. Running the same binary five
times on an idle 8-core machine gave 53.9k to 74.6k req/s, and shared CI
runners are noisier still, so a number printed per push says nothing.

Build both refs and measure them alternately in one session, flipping
the order each round to cancel ordering bias, then report only the ratio
of the medians. Whether that ratio means anything is decided by an exact
permutation test rather than by comparing the change against the min/max
spread - a single slow round is enough to make a spread check give up,
while the rank test rides it out.

Validated against a patch that removes a redundant poll() per request:
individual measurements ranged 41.7k-93.9k req/s, yet nine rounds
resolved a 1.244x speedup at p = 0.019. The same data truncated to five
rounds was inconclusive, so the workflow defaults to nine.

Manual dispatch only, Linux and non-SSL for now, and it never fails the
build - this is a measurement, not a test.
2026-07-30 19:56:03 -04:00
yhirose
85ec3963bc Increase default listen backlog from 5 to 128
The accept queue was limited to 5 pending connections, which overflows
easily under connection churn or a burst of simultaneous connects (LB
health checks, thundering herd on restart). On overflow the kernel
silently drops the ACK rather than failing fast, so clients stall on
SYN/ACK retransmission backoff.

Benchmark (bombardier -c 10 -d 10s, 3 trials, interleaved with the old
binary) shows max latency dropping from 48-89ms to 5.8-11.2ms, while
p99 is unchanged - the fix affects only the extreme tail, as expected.
2026-07-30 17:19:36 -04:00
Sayed Kaif
f4fce42e77 fail mmap::open when ::mmap returns MAP_FAILED 2026-07-30 00:46:08 +05:30
Sayed Kaif
23fef15e07 apply Range only to a 206 response in write_content_with_provider 2026-07-28 13:19:35 +05:30
yhirose
34b7c846d4 Merge pull request #2496 from superm1/superm1/SWSPLAT-23622
Sanitize uploaded filenames in upload example to prevent path traversal
2026-07-27 17:11:45 -04:00
Mario Limonciello
76b54e7de4 Sanitize uploaded filenames in upload example to prevent path traversal
The upload example wrote each uploaded file using the filename supplied
verbatim in the multipart Content-Disposition header. A client could set
that filename to an absolute path or one containing "../" components and
cause the server to create or overwrite files outside the working
directory.

Reduce each client-supplied filename to its base name and reject the
request with 400 Bad Request if the result is empty, ".", "..", or
still contains a path separator (including colon for Windows drive
letters).
2026-07-27 10:56:49 -05:00
yhirose
5b9d1495ff Apply path encoding to open_stream()
ClientImpl::open_stream() passed the caller-supplied path straight to the
request line, so it ignored path_encode_ entirely and always behaved as if
set_path_encode(false) had been called. The same path therefore produced
different bytes on the wire depending on which API was used:

  Get()          "/a b"  ->  GET /a%20b HTTP/1.1
  open_stream()  "/a b"  ->  GET /a b HTTP/1.1

A space is the request-target delimiter, so the streaming form is not merely
inconsistent: an RFC 9112 conformant server reads the target as "/a" and the
version as "b". Non-ASCII bytes and '+' diverged the same way, the latter
changing the value a server that decodes '+' as space sees.

Extract the path/query splitting and encoding out of ClientImpl::write_request
into detail::encode_request_target() and call it from both paths, so the
encoding rule lives in one place. open_stream() appends Params before
encoding, matching ClientImpl::Get(path, params), which builds its target the
same way.

Note a behavior change: with path encoding enabled, CR/LF in the target is now
percent-encoded and sent rather than rejected with Error::Write, matching
Get(). This is not a weakening of the CR/LF guard in write_request_line() --
that check is independent of path_encode_ and still backstops
set_path_encode(false), where encode_path() does nothing.
2026-07-25 13:05:48 -04:00
yhirose
d66d9a9599 Release v0.51.0 v0.51.0 2026-07-23 22:52:55 -04:00