Compare commits

..
30 Commits
Author SHA1 Message Date
yhirose 4f3f9ef19b Release v0.58.0 2026-09-21 20:01:43 -04:00
yhirose 6d59d1e2df Build open_stream's request head in memory before sending
open_stream wrote the request line and each header straight to the
socket, so a header rejected by check_and_write_headers left the request
line and the headers before it on the wire. Build them in a BufferStream
first and flush once, as write_request and the WebSocket handshake do.
2026-09-21 19:15:39 -04:00
yhirose 9386b25dd7 Don't wait for a response to a request rejected before sending
process_request reads the response even after write_request fails, so
that an early response (e.g. 413/414) sent while the body is still being
uploaded is not lost. A request line or header rejected while the
request is being built in memory never reaches the socket, though, so
no response will come and the client blocked until the read timeout (or
until the server closed the idle connection).

write_request now reports such a local rejection, and process_request
returns immediately in that case. Socket write failures still read the
response as before.
2026-09-21 19:15:30 -04:00
yhirose 91c55a4385 Release v0.57.1 2026-09-21 12:47:22 -04:00
yhirose ad88645a83 Reject non-token methods in write_request_line
write_request_line checked the request target for CR/LF but concatenated
the method verbatim. A method carrying CR/LF could smuggle a whole
request ahead of the real one, and the client would take the smuggled
request's response as its own. A method with a space or an empty method
put a malformed request line on the wire.

Require the method to be a token (RFC 9110 Section 9.1) before anything
is written. All three callers (the buffered client path, open_stream and
the WebSocket handshake) go through this function and fail with
Error::Write, as they already do for a rejected target.

Claude-Session: https://claude.ai/code/session_01NTDesJQTQPEuu4o4XCu69g
2026-09-21 12:25:12 -04:00
yhirose 82722fcb13 Release v0.57.0 2026-09-20 20:38:01 -04:00
metsw24-maxandyhirose 8b872605e0 reject control characters in chunk extensions in read_payload (#2585)
* reject control characters in chunk extensions in read_payload

* Bound every chunk-size line scan by the line terminator

read_payload() ended its scans of one line buffer two different ways: the
hex-size parse and the space skip that follows stopped on the NUL that
stream_line_reader::append() writes, while the new chunk-ext check walked
to an explicit end pointer. Compute that end pointer first and bound all
of them by it, so no scan depends on the buffer's NUL and the terminator
can never be read as line content.

The bare-LF branch is reachable only under
CPPHTTPLIB_ALLOW_LF_AS_LINE_TERMINATOR, where getline() ends the line on
an LF that is the terminator rather than extension text. Say so: the
comment below it explains why a bare LF inside the line is rejected, and
without that note the two read as contradictory. Its guard no longer
depends on the scan cursor either, since all it ever needed was a check
that there is a byte to look at.

* Reuse the chunked-body helper in the chunk-ext acceptance test

AcceptsChunkExtension repeated expect_chunked_body_rejected()'s body
verbatim apart from the expected status, so parameterise the helper on
the status and keep the rejection wrapper for the existing callers. The
decoded body is already checked by the /chunked handler, so asserting
the status is all the new test needs.

Also record why the control-character literal stays split: a hex escape
consumes every hex digit that follows it, so "\x01b" would be the single
byte \x1b rather than \x01 followed by 'b', and joining the halves would
quietly change what the test sends.

---------

Co-authored-by: yhirose <yuji.hirose.bug@gmail.com>
2026-09-20 20:22:52 -04:00
yhirose 52f214bf2e Don't wait for the peer's close_notify on OpenSSL shutdown
tls::shutdown() on OpenSSL called SSL_shutdown() a second time to wait
for the peer's close_notify. An idle keep-alive client never sends one,
so closing its connection held the worker thread until the read timeout,
and Server::stop() waited for it. Send close_notify and return, as the
Mbed TLS and wolfSSL backends already do.
2026-09-19 17:28:09 -04:00
yhirose 09c02f1335 Send 100 Continue only when the request body is read
The server wrote 100 Continue as soon as it saw the expectation, before
pre_routing_handler, pre_request_handler, or routing ran. A request
those handlers rejected, or one that matched no route, still invited
the client to send a body the server would never read.

Defer the interim response until the body is about to be read. If the
request is answered without reading the body, 100 Continue is never
sent and the connection is closed, since whether and when the client
sends the body is unknown.

Also treat a 417 returned by expect_100_continue_handler as the final
response. It used to be written as a bare status line, after which the
request was processed and a second response was written.
2026-09-19 17:28:09 -04:00
yhirose 2e5480ad65 Document that pre_request_handler runs for WebSocket routes 2026-09-19 17:28:09 -04:00
yhirose 4cb363e3f2 Run pre_request_handler for WebSocket routes
The WebSocket upgrade path matched the route and switched protocols
without setting req.matched_route or calling pre_request_handler, so a
check placed there (e.g. authentication) never ran for WebSocket
routes. Set matched_route and run the handler before the upgrade; if it
handles the request, reply with a regular HTTP response instead of 101.

Also write rejected upgrade responses (from pre_routing_handler too)
with write_response_with_content, so they carry Content-Length. Without
it, a client reading the body waited until the keep-alive timeout.
2026-09-19 17:28:09 -04:00
yhirose deb520e26b Update version files with sed -i.bak, which GNU sed accepts too
`sed -i ''` is BSD-only: GNU sed takes the '' as the script and the expression as a file name, so `just release --run` failed on Linux before touching anything.
2026-09-19 13:56:31 -04:00
yhirose f37a5b1407 Fix #2583 2026-09-14 17:31:07 -04:00
yhirose f15992c7ed Update documentation 2026-09-14 12:27:52 -04:00
yhirose 278c2979e8 Release v0.56.0 2026-09-11 17:35:54 -04:00
yhirose 6b8c3f5387 Report only a caller-set WebSocket read timeout as Timeout
199d7ee made read() return the new ReadResult::Timeout for every read
timeout and leave the connection open. The compile-time server default
(CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND, 300s) is always in
effect, so a handler written as `while (ws.read(msg))`, the form the
README's Quick Start uses, no longer ended when a peer went quiet:
Timeout is non-zero, so the loop ran its body again with the previous
message still in `msg`, and the worker the backstop is meant to reclaim
was never released. Nothing caught it because every test of the new
result set a timeout explicitly and checked the result by value, and the
heartbeat tests keep the connection alive with pings.

The two timeouts mean different things. One the caller sets through
set_read_timeout() is a request for control back, and is reported as
Timeout on a still-open connection. The compile-time default is a
backstop against a peer that has gone quiet, and elapsing it is now a
failure again: read() returns Fail and closes the connection, as it did
before 199d7ee. WebSocket tracks whether set_read_timeout() was called,
and WebSocketClient carries the same flag over to the WebSocket it
creates on connect().

Tests use the heartbeat binary, which compiles both defaults down to 3s:
a `while (ws.read(msg))` server handler runs its body once and exits
when the client falls silent, and a client that never set a timeout gets
Fail with the connection closed. The README and cookbook now say which
timeout produces Timeout.

Claude-Session: https://claude.ai/code/session_01EF5uZ1X2kaHhqJ8VgfjVaQ
2026-09-11 17:29:20 -04:00
yhirose e640f8376b Release v0.55.0 2026-09-11 16:54:08 -04:00
yhirose 73a4092f8a Point the remaining httpbingo.org proxy tests at the self-hosted httpbin
ProxyTest, RedirectTest.HTTPBin*, KeepAliveTest and ProxyTest.SSLOpenStream
still sent their requests through the squid proxies to the external
httpbingo.org, so an upstream hiccup there failed CI with no code change
involved (KeepAliveTest.SSLWithDigest got a 502 on its first /get).

Switch them to the "httpbin" container (nginx + go-httpbin) that
BaseAuthTest/DigestAuthTest already use. go-httpbin serves /get,
/redirect/n and /digest-auth the same way, so the test logic is
unchanged; the SSL variants disable certificate verification for the
self-signed test cert, as BaseAuthTest.SSL does.

RedirectTest.YouTube* is left pointing at youtube.com since it exercises
a real cross-host, http -> https redirect chain.

Claude-Session: https://claude.ai/code/session_0148ZAzsuYRYXkwcA7UFeh95
2026-09-11 16:35:11 -04:00
yhirose 3517f92e2f ci: remove the temporary windows-without-SSL flaky failure reporter
The intermittent failures it was tracking stopped after the graceful
drain before close in 8e702d3: no windows-without-SSL test failure on
master since 2026-08-09. Drop the reporting step, the issues: write
permission it needed, and the run_tests step id that only it used.

Claude-Session: https://claude.ai/code/session_0148ZAzsuYRYXkwcA7UFeh95
2026-09-11 16:16:25 -04:00
metsw24-maxandyhirose 0480ff77b8 reject ambiguously framed responses in client read paths (#2581)
* reject ambiguously framed responses in client read paths

* Accept non-chunked Transfer-Encoding responses in the client framing guard

RFC 9112 §6.3 treats requests and responses differently when the final
transfer coding is not chunked: a request's body length cannot be
determined and the server must answer 400, but a response's body simply
runs until the server closes the connection. read_content() and the
open_stream() body reader already do that, so such a response is not
ambiguous and rejecting it broke valid responses such as
"Transfer-Encoding: gzip" followed by a close.

Keep rejecting a Transfer-Encoding paired with a non-zero Content-Length,
which is the actual ambiguity, and drop the non-chunked clause from both
client read paths.

Tests: check that rejection surfaces as Error::Read, that a non-chunked
Transfer-Encoding response is read until close on both paths, and that
HEAD, 204 and 304 responses with both framing headers are not rejected.

Claude-Session: https://claude.ai/code/session_01JYPWKpbp4a881EdpEf2xSi

* Share the framing check and reuse existing test helpers

Factor "Transfer-Encoding with a non-zero Content-Length" into
detail::has_conflicting_content_length() next to
is_chunked_transfer_encoding(), and call it from the server request
guard and both client read paths so the rule and its RFC 9112 §6.3
rationale live in one place.

In the tests, drop the POSIX-only raw socket helper in favour of the
existing serve_single_response() and read_all(), which also lets the
tests run on Windows. Fold the stream-only test into the buffered one so
each case checks both Get() and open_stream(), and cover the HEAD/204/304
exclusion on the open_stream() path too.

Claude-Session: https://claude.ai/code/session_01JYPWKpbp4a881EdpEf2xSi

---------

Co-authored-by: yhirose <yuji.hirose.bug@gmail.com>
2026-09-11 15:55:28 -04:00
KBSandyhirose 8d25b6a3ac Reject a Range first-byte-pos that overflows ssize_t (#2580)
* Reject a Range first-byte-pos that overflows ssize_t

parse_range_header initializes first to the -1 sentinel that means "no
first-byte-pos" and only overwrites it when detail::from_chars succeeds.
On std::errc::result_out_of_range the assignment is skipped and -1
survives, so "bytes=9223372036854775808-100" is parsed as the suffix
range "bytes=-100" and range_error serves the last 100 bytes instead of
returning 416.

Before the parser was rewritten onto detail::from_chars, std::stoll threw
std::out_of_range on the same input, the catch arm added in 8f8761e for
issue #705 returned false, and the request was answered with 416. The
catch arm is still there but from_chars reports through an error code, so
nothing reaches it any more.

get_header_value_u64 and parse_port already reject an out-of-range value
at their from_chars call sites; this was the remaining one that dropped
the error.

The last-byte-pos side is deliberately unchanged: -1 there is the
documented RFC 9110 14.1.2 "remainder of the representation" value, so an
oversized last-byte-pos stays accepted.

* Simplify the Range first-byte-pos overflow check

Parse the first-byte-pos straight into first, since a failed parse now
returns before first is read, and fold the overflow test into the
existing batch of rejected ranges. Also note on the last-byte-pos side
why an overflow there deliberately keeps -1.

Claude-Session: https://claude.ai/code/session_01JYPWKpbp4a881EdpEf2xSi

---------

Co-authored-by: yhirose <yuji.hirose.bug@gmail.com>
2026-09-11 13:48:04 -04:00
metsw24-maxandyhirose 515b8f84af send each credential only to its own hop in write_request (#2579)
* send each credential only to its own hop in write_request

An SSLClient behind a proxy sent Proxy-Authorization inside the TLS tunnel, where the origin reads it, and sent the origin's Authorization on the CONNECT request the proxy reads. Attach each only on the message its hop actually reads.

* Keep default headers off the CONNECT request

set_default_headers() is typically used for origin credentials such as
Authorization, Cookie or API keys, but they were also attached to the
CONNECT request an SSLClient sends to its proxy, in plaintext before the
TLS tunnel exists. Default headers now go only on requests the origin
reads, the same split the previous commit makes for set_basic_auth and
set_bearer_token_auth.

Claude-Session: https://claude.ai/code/session_01JYPWKpbp4a881EdpEf2xSi

* Simplify per-hop credential handling and its tests

Flatten the Authorization insertion in write_request into one guard with
an else-if (Basic already took precedence over Bearer), and shorten the
comments around it. Fold DefaultHeadersStayOffConnect into the
CredentialsStayWithTheirHop helper, which now takes the list of headers
that must reach only the origin.

Claude-Session: https://claude.ai/code/session_01JYPWKpbp4a881EdpEf2xSi

---------

Co-authored-by: yhirose <yuji.hirose.bug@gmail.com>
2026-09-11 13:25:48 -04:00
yhirose 88956ccad8 Self-host the httpbin auth-testing backend for BaseAuthTest/DigestAuthTest
These tests exercise the squid proxies by hitting /basic-auth and
/digest-auth on an external httpbin-style site. That site's identity has
already moved twice (httpbin.org -> httpcan.org, per #2300) chasing
uptime, and httpcan.org itself is now down (Cloudflare 502 from its
origin), failing CI with no code change involved.

Adds two containers to the existing squid docker-compose stack instead:
go-httpbin (mccutchen/go-httpbin) as the backend, and an nginx sidecar in
front of it under the single "httpbin" hostname so both the NoSSL tests
(port 80) and the SSL tests, which CONNECT-tunnel through the proxy to
port 443, resolve the same name -- go-httpbin only listens on one port at
a time, so it can't serve both protocols itself. nginx uses the repo's
existing self-signed test cert; the SSL client tests already disable
verification for it like other self-signed-cert tests in this suite.

go-httpbin was picked over the more feature-complete kennethreitz/httpbin
after finding the latter accepts a wrong digest-auth username as long as
the password matches -- confirmed with a direct curl against the
container, unrelated to anything in this repo. go-httpbin correctly
rejects both. The trade-off is losing SHA-512 digest-auth coverage here,
since go-httpbin only implements MD5 and SHA-256; nothing else in the
suite exercises SHA-512 digest auth against a live server. Response body
assertions are adjusted to go-httpbin's actual JSON shape (an added
"authorized" field, no "algorithm" field), and the domain changes from
httpcan.org to the self-hosted "httpbin".

This only affects 'make proxy'/'make proxy_mbedtls'/'make proxy_wolfssl'
and the Proxy Test CI workflow -- the default 'make' target is untouched.
2026-09-07 21:49:00 -04:00
yhirose f83d06538b Track Homebrew's clang-format version instead of a fixed pin
The previous commit pinned CI and the pre-commit hook to a fixed
clang-format 23.1.0, but the maintainer develops on macOS against
whatever version `brew` currently installs, which changes over time
as Homebrew updates the formula.

style-check now runs on macos-latest and installs clang-format via
`brew install`, so it tracks the same moving target the maintainer's
Mac does. The pre-commit hook switches from pre-commit's own pinned
mirror to a local hook that shells out to the system clang-format,
so a local commit and CI both go through the same Homebrew-installed
binary rather than two independently versioned copies.

Trade-off: this reintroduces the non-determinism a fixed pin avoids
-- a commit's style-check result can now change over time as Homebrew
updates the formula -- but that mirrors how the maintainer already
develops, which is the point.

Also install coreutils in CI: the style_check Makefile target needs
grealpath's --relative-to, which the macOS-native realpath lacks.
2026-09-07 20:15:31 -04:00
yhirose dc41dbd954 Pin style-check and pre-commit to clang-format 23.1.0
CI relied on ubuntu-latest's default apt clang-format (18.1.3), while the
pre-commit hook was pinned to a different 18.x build. Neither tracked a
specific, deliberately-chosen version, and the two could drift from each
other and from whatever a contributor has installed locally.

Both now pin the same clang-format 23.1.0 (latest stable), installed via
pipx in CI. Also scope the pre-commit hook's file matcher to the same set
of files test/Makefile's style_check target checks, since the previous
\.(cpp|cc|h)$ pattern reached into vendored code (test/gtest,
benchmark/crow) that must stay untouched.

Reformat httplib.h's brace-init spacing to match 23.1.0's output.
2026-09-07 17:18:45 -04:00
yhirose 6303a99ce1 Run clang-format on httplib.h and test.cc
Fixes formatting introduced in 199d7ee and d5f8858 that clang-format
18.1.3 (the version used by CI) disagrees with.
2026-09-07 16:29:08 -04:00
yhirose d5f8858731 Move ws::WebSocket's chrono set_read_timeout into the header half
Defined next to its non-template overload, it landed in the part of the header
that test_split compiles into httplib.cc, leaving a user TU's instantiation
with nothing to link against. The WebSocketClient overload of the same name has
always sat up with the class definitions; this one belongs there too.

Only test_split sees it -- a header-only build instantiates the template
wherever it is written, which is why the regular test target stayed green.
2026-09-03 15:00:02 -04:00
yhirose 199d7ee248 Tell a WebSocket read timeout apart from a closed connection
read() collapsed every failure into Fail and marked the connection closed with
it, so a read timeout could not be used to get control back and send on the
same connection -- it killed the connection instead. The information was
already there and thrown away: SocketStream::read records Error::Timeout, and
read_websocket_frame flattened it into a bool.

ReadResult gains Timeout, reported only when the timeout elapsed on a frame
boundary with nothing consumed, which is the only case where the stream can be
read again. Every multi-byte field now loops until it has its bytes, which also
fixes a frame header straddling the read buffer's boundary failing the frame:
Stream::read is allowed to return less than asked for, and only the payload
was reading in a loop.

ws::WebSocket::set_read_timeout() lets a server handler bound its own reads,
and WebSocketClient::set_read_timeout() now reaches an already-open connection
instead of only seeding the next connect().

The read timeout macro splits in two. A client waits forever by default -- a
read timeout is the caller's tool for taking back control, not a liveness
check, which is ping/pong's job -- while a server keeps the 300s that reclaims
a worker from a peer gone quiet. Defining the old name still sets both.

Also record a reason on the two WebSocketSSLStream::read failure paths that
returned -1 without one, so get_error() cannot report a previous call's
timeout, and make SocketStream's read timeout atomic now that it can be
changed while a read is in flight.
2026-09-03 14:32:13 -04:00
Avionic Harshit 9e2e33da56 let EXTRA_CXXFLAGS override -fsanitize=address (#2577) 2026-09-03 10:22:00 -04:00
Jhen-Jie Hongandyhirose 7d53a31d23 Don't compress a response whose handler already set Content-Encoding (#2575)
* Don't compress a response whose handler already set Content-Encoding

* Don't compress a pre-encoded response served from a file

The guard that stands down when a response already names a content coding
covered the responses that settle their coding in `apply_ranges()`, but a
file-backed one settles it in `static_file_encoding()`, which asked the
content-type overload and so never saw the field. With static file
compression enabled, a mount point naming the coding for a tree of
build-time compressed assets, and a handler setting the field on a
`set_file_content()` response, both had their stored bytes compressed a
second time and a second `Content-Encoding` field line appended.

A file-backed response has not been given a content type by the time its
coding is decided, which is the only reason it could not go through
`encoding_type()`. It takes the type as an argument now, so both paths share
the one guard instead of carrying a copy each.

`Response::content_encoding_` becomes `content_coding_`, after what it
holds. It names the coding chosen for the body, which is what its own
comment already called it, while the old name read as the value of the
`Content-Encoding` field whose presence is exactly what forces the coding to
`None`.

README gains the behaviour, including the part that stays with the handler:
`Vary` is added only to a coding the server chose, so a handler that picks a
representation from `Accept-Encoding` has to add the field itself.

---------

Co-authored-by: yhirose <yuji.hirose.bug@gmail.com>
2026-08-31 20:44:12 -04:00
27 changed files with 1955 additions and 252 deletions
+8 -31
View File
@@ -31,12 +31,19 @@ env:
jobs:
style-check:
runs-on: ubuntu-latest
# Uses the macOS runner's pre-installed Homebrew so clang-format tracks
# whatever version `brew install clang-format` currently resolves to on
# the maintainer's own Mac, instead of a version pinned in this file.
runs-on: macos-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
continue-on-error: true
steps:
- name: checkout
uses: actions/checkout@v4
- name: install clang-format
run: |
brew update
brew install clang-format coreutils
- name: run style check
run: |
clang-format --version
@@ -475,7 +482,6 @@ jobs:
runs-on: windows-latest
permissions:
contents: read
issues: write
if: >
(github.event_name == 'push') ||
(github.event_name == 'pull_request' &&
@@ -585,7 +591,6 @@ jobs:
- name: Build ${{ matrix.config.name }}
run: cmake --build build --config Release -- /v:m /clp:ShowCommandLine
- name: Run tests ${{ matrix.config.name }}
id: run_tests
if: ${{ matrix.config.run_tests }}
shell: pwsh
working-directory: build/test
@@ -618,34 +623,6 @@ jobs:
}
if ($failed) { exit 1 }
Write-Host "All shards passed."
- name: Report flaky failure on issue #2533
if: >
failure() && steps.run_tests.conclusion == 'failure'
&& matrix.config.name == 'without SSL'
&& github.event_name == 'push'
continue-on-error: true
shell: pwsh
working-directory: build/test
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$summary = ""
for ($i = 0; $i -lt 4; $i++) {
$log = "shard_${i}.log"
if (Test-Path $log) {
$failedLines = Select-String -Path $log -Pattern "\[ FAILED \]"
if ($failedLines) {
$summary += "**Shard ${i}:**`n" + (($failedLines | ForEach-Object { $_.Line }) -join "`n") + "`n`n"
}
}
}
if (-not $summary) {
Write-Host "No [ FAILED ] line in any shard log; not a test failure. Skipping the report."
exit 0
}
$runUrl = "$($env:GITHUB_SERVER_URL)/$($env:GITHUB_REPOSITORY)/actions/runs/$($env:GITHUB_RUN_ID)"
$body = "Reoccurred on push: $runUrl`n`nCommit: $($env:GITHUB_SHA)`n`n$summary"
gh issue comment 2533 --repo $env:GITHUB_REPOSITORY --body $body
env:
VCPKG_ROOT: "C:/vcpkg"
+9 -4
View File
@@ -1,7 +1,12 @@
repos:
- repo: https://github.com/pre-commit/mirrors-clang-format
rev: v18.1.8 # 最新バージョンを使用
- repo: local
hooks:
- id: clang-format
files: \.(cpp|cc|h)$
args: [-i] # インプレースで修正
name: clang-format
# Uses whatever `clang-format` resolves to on PATH (the Homebrew
# install on macOS) instead of a version pinned here, so it tracks
# the same version CI installs via `brew install clang-format`.
entry: clang-format
language: system
files: ^(httplib\.h|(example|fuzzing|test)/[^/]+\.(cpp|cc|h))$
args: [-i]
+34 -5
View File
@@ -57,14 +57,30 @@ if (ws.connect()) {
```cpp
enum ReadResult : int {
Fail = 0, // Connection closed or error
Text = 1, // UTF-8 text message
Binary = 2, // Binary message
Fail = 0, // Connection closed or error
Text = 1, // UTF-8 text message
Binary = 2, // Binary message
Timeout = 3, // Read timeout elapsed; connection still open
};
```
Returned by `read()`. Since `Fail` is `0`, the result works naturally in boolean contexts — `while (ws.read(msg))` continues until the connection closes. When you need to distinguish text from binary, check the return value directly.
`Timeout` is only returned for a read timeout you set yourself with `set_read_timeout()`. It means the timeout elapsed on a message boundary: nothing was consumed and the connection is still open, so you can send on it and read again. The compile-time defaults (`CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND`, 300 seconds on the server; a client waits forever) are a backstop against a peer that has gone quiet, not a request for control: when one of them elapses, `read()` returns `Fail` and closes the connection, so code that never calls `set_read_timeout()` can keep using `while (ws.read(msg))`.
**`msg` is left untouched on `Timeout`.** Because `Timeout` is non-zero, `while (ws.read(msg))` keeps looping — with the *previous* message still in `msg`. Once you set a read timeout, test the result instead:
```cpp
ws.set_read_timeout(std::chrono::milliseconds(100));
std::string msg;
while (ws.is_open()) {
auto r = ws.read(msg);
if (r == httplib::ws::Timeout) { continue; } // nothing yet; send if you like
if (r == httplib::ws::Fail) { break; }
handle(msg);
}
```
### CloseStatus
```cpp
@@ -327,6 +343,18 @@ svr.WebSocket("/ws", [](const httplib::Request &req, httplib::ws::WebSocket &ws)
});
```
The check above runs after the handshake, so the client sees a successful upgrade followed by a close frame. To refuse the upgrade itself with an HTTP status, use a pre-routing or pre-request handler. Both run before the `101 Switching Protocols` response, and `req.matched_route` is available in the pre-request handler:
```cpp
svr.set_pre_request_handler([](const httplib::Request &req, httplib::Response &res) {
if (req.matched_route == "/ws" && req.get_header_value("Authorization").empty()) {
res.status = httplib::StatusCode::Unauthorized_401;
return httplib::Server::HandlerResponse::Handled; // not upgraded
}
return httplib::Server::HandlerResponse::Unhandled;
});
```
### Custom Headers and Timeouts
```cpp
@@ -409,7 +437,8 @@ if (ws.connect()) {
| Macro | Default | Description |
|---------------------------------------------|-------------------|----------------------------------------------------------|
| `CPPHTTPLIB_WEBSOCKET_MAX_PAYLOAD_LENGTH` | `16777216` (16MB) | Maximum payload size per message |
| `CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND` | `300` | Read timeout for WebSocket connections (seconds) |
| `CPPHTTPLIB_WEBSOCKET_CLIENT_READ_TIMEOUT_SECOND` | `0` | Client read timeout (seconds); `0` waits forever |
| `CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND` | `300` | Server read timeout (seconds) |
| `CPPHTTPLIB_WEBSOCKET_CLOSE_TIMEOUT_SECOND` | `5` | Timeout for waiting peer's Close response (seconds) |
| `CPPHTTPLIB_WEBSOCKET_PING_INTERVAL_SECOND` | `30` | Automatic Ping interval for heartbeat (seconds) |
| `CPPHTTPLIB_WEBSOCKET_MAX_MISSED_PONGS` | `0` (disabled) | Close the connection after N consecutive unacked pings |
@@ -446,7 +475,7 @@ The server side has the same `set_websocket_max_missed_pongs()`.
With the default ping interval of 30 seconds, `max_missed_pongs = 2` detects a dead peer within ~60 seconds. The counter is reset every time a Pong frame is received, so the mechanism only works when your code is actively calling `read()` — exactly the pattern a normal WebSocket client already uses.
**The default is `0`**, which means "never close the connection because of missing pongs." Pings are still sent on the heartbeat interval, but their responses are not checked. Even so, a dead connection does not linger forever: while your code is inside `read()`, `CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND` (default **300 seconds = 5 minutes**) acts as a backstop and `read()` fails if no frame arrives in time. `max_missed_pongs` is the knob for detecting an unresponsive peer faster than that 5-minute fallback.
**The default is `0`**, which means "never close the connection because of missing pongs." Pings are still sent on the heartbeat interval, but their responses are not checked. On the server side a dead connection still does not linger: while a handler is inside `read()`, `CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND` (default **300 seconds = 5 minutes**) acts as a backstop. A client has no such backstop — it waits forever unless you set a read timeout — so there `max_missed_pongs` is what notices an unresponsive peer at all. On either side it is also the knob for noticing one *faster* than the 5-minute fallback.
## Threading Model
+42 -3
View File
@@ -347,6 +347,25 @@ int port = svr.bind_to_any_port("0.0.0.0");
svr.listen_after_bind();
```
### Port sharing and exclusive binding
By default, the server socket enables address/port reuse: `SO_REUSEPORT` where it is available (Linux, macOS), and `SO_REUSEADDR` otherwise (Windows). A restarted server can bind again immediately, but binding to a port that another server is already listening on also succeeds, and connections are distributed between them.
If you want `listen()` to fail when the port is already in use, replace the default socket options with `set_socket_options`:
```cpp
svr.set_socket_options([](socket_t sock) {
#ifdef _WIN32
httplib::set_socket_opt(sock, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, 1);
#else
httplib::set_socket_opt(sock, SOL_SOCKET, SO_REUSEADDR, 1);
#endif
});
```
> [!NOTE]
> Setting only `SO_REUSEADDR` is not enough on Windows. There, `SO_REUSEADDR` allows two sockets that both set it to bind to the same port, so use `SO_EXCLUSIVEADDRUSE` instead.
### Static File Server
```cpp
@@ -545,14 +564,15 @@ svr.set_pre_request_handler([](const auto& req, auto& res) {
```
Request received
│
├─ expect_100_continue_handler (when the request has "Expect: 100-continue")
│ └─ returns a status other than 100 → stop here
│
├─ pre_routing_handler route not matched yet, body not read
│ └─ returns Handled → stop here
│
├─ file_request_handler (GET/HEAD, static file serving)
│
├─ expect_100_continue_handler (when the request has "Expect: 100-continue")
│
├─ route matching → req.matched_route is set
│
├─ pre_request_handler route matched, body NOT read yet
@@ -568,6 +588,10 @@ Request received
Use `pre_routing_handler` to reject a request as early as possible, before the route is known. Use `pre_request_handler` for route-specific checks, since `req.matched_route` is available and the body has not been read yet.
For a request with `Expect: 100-continue`, the `100 Continue` response is not sent until the body is about to be read. A request rejected before that point (by `pre_routing_handler`, `pre_request_handler`, or because no route matched) gets its final response without `100 Continue`, so the client never sends the body.
A WebSocket upgrade request that matches a route registered with `svr.WebSocket()` takes a shorter path: `pre_routing_handler`, then route matching (`req.matched_route` is set), then `pre_request_handler`, then the WebSocket handler. If either hook returns `Handled`, its response is sent as a regular HTTP response and the connection is not upgraded. Once the connection is upgraded, `post_routing_handler` does not run.
### Response user data
`res.user_data` is a type-safe key-value store that lets pre-routing or pre-request handlers pass arbitrary data to route handlers.
@@ -827,7 +851,9 @@ svr.Get("/content", [&](const Request &req, Response &res) {
### 'Expect: 100-continue' handler
By default, the server sends a `100 Continue` response for an `Expect: 100-continue` header.
By default, the server accepts an `Expect: 100-continue` header and sends a `100 Continue` response when it starts reading the request body. If the request is answered without reading the body, `100 Continue` is not sent and the connection is closed after the response.
The handler runs before `pre_routing_handler`. Returning `100` lets the request proceed; returning any other status sends that status as the final response and closes the connection.
```cpp
// Send a '417 Expectation Failed' response.
@@ -1474,6 +1500,19 @@ The server can apply compression to the following MIME type contents:
- application/protobuf
- application/xhtml+xml
A response that already carries `Content-Encoding` is sent as it is. A handler serving content it encoded itself, an asset compressed at build time for instance, keeps its own coding and its own bytes:
```c++
svr.Get("/app.js", [](const Request & /*req*/, Response &res) {
res.set_header("Content-Encoding", "gzip");
res.set_content(gzipped_asset, "application/javascript");
});
```
This holds for every kind of response, including the file-backed ones below.
`Vary: Accept-Encoding` is added only to responses the server encoded itself. A handler that chooses between an encoded and an identity representation by reading `Accept-Encoding` should set the field itself, so that shared caches keep the two apart.
### Static file compression
Responses served from a file, whether through `set_mount_point()` or `Response::set_file_content()`, are sent as is by default. Turn compression on for them with:
+1 -1
View File
@@ -4,7 +4,7 @@ langs = ["en", "ja"]
[site]
title = "cpp-httplib"
version = "0.54.1"
version = "0.58.0"
hostname = "https://yhirose.github.io"
base_path = "/cpp-httplib"
footer_message = "© 2026 Yuji Hirose. All rights reserved."
@@ -37,6 +37,8 @@ svr.set_pre_request_handler(
`matched_route` is the pattern **before** path parameters are expanded (e.g. `/admin/users/:id`). You compare against the route definition, not the actual request path, so IDs or names don't throw you off.
The pre-request handler also runs for routes registered with `svr.WebSocket()`. It is called before the `101 Switching Protocols` response, so returning `Handled` sends your HTTP response (such as a 403) and the connection is never upgraded.
## Return values
Same as pre-routing — return `HandlerResponse`.
@@ -43,15 +43,40 @@ svr.listen_after_bind();
## Check the return values
`bind_to_port()` returns `false` on failure — typically when the port is already taken. Always check it.
`bind_to_port()` returns `false` on failure, for example when you don't have permission to bind to the port. Always check it.
```cpp
if (!svr.bind_to_port("0.0.0.0", 8080)) {
std::cerr << "port already in use" << std::endl;
std::cerr << "bind failed" << std::endl;
return 1;
}
```
`listen_after_bind()` blocks until the server stops and returns `true` on a clean shutdown.
## Detect a port that's already in use
With the default settings, you can actually bind to a port another server is already using. That's because cpp-httplib sets `SO_REUSEPORT` (Linux, macOS) or `SO_REUSEADDR` (Windows) on the server socket. A restarted server can bind again right away. The flip side is that a second server on the same port starts without an error, and connections get split between the two.
To make `bind_to_port()` fail on a port in use, replace the socket options with `set_socket_options()`.
```cpp
svr.set_socket_options([](socket_t sock) {
#ifdef _WIN32
httplib::set_socket_opt(sock, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, 1);
#else
httplib::set_socket_opt(sock, SOL_SOCKET, SO_REUSEADDR, 1);
#endif
});
if (!svr.bind_to_port("0.0.0.0", 8080)) {
std::cerr << "port already in use" << std::endl;
return 1;
}
```
`set_socket_options()` replaces the defaults entirely. Setting `SO_REUSEADDR` on Linux and macOS keeps the "restarted server can bind again right away" behavior.
> **Note:** `SO_REUSEADDR` alone isn't enough on Windows. Two sockets that both set it can bind to the same port, so use `SO_EXCLUSIVEADDRUSE` instead.
> **Note:** To auto-pick a free port, see [S17. Bind to any available port](../s17-bind-any-port). Under the hood, that's just `bind_to_any_port()` + `listen_after_bind()`.
@@ -36,6 +36,7 @@ The `read()` return value is a `ReadResult` enum:
- `ReadResult::Text`: received a text message
- `ReadResult::Binary`: received a binary message
- `ReadResult::Fail`: error, or connection closed
- `ReadResult::Timeout`: a read timeout you set with `set_read_timeout()` elapsed with nothing received; the connection is still open. The compile-time default timeout closes the connection and is reported as `Fail` instead — see [W06. Set Timeouts](../w06-websocket-timeouts)
## Client: talk to the echo server
@@ -75,6 +75,6 @@ The counter is reset whenever `read()` consumes an incoming Pong frame, so this
`max_missed_pongs` defaults to `0`, which means "never close the connection because of missing pongs." Pings are still sent on the heartbeat interval, but their responses aren't checked. If you want unresponsive-peer detection, set it explicitly to `1` or higher.
Even with `0`, a dead connection won't linger forever: while your code is inside `read()`, `CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND` (default **300 seconds = 5 minutes**) acts as a backstop and `read()` fails if no frame arrives in time. Think of `max_missed_pongs` as the knob for detecting an unresponsive peer **faster** than that.
On the server side, even with `0`, a dead connection won't linger forever: while a handler is inside `read()`, `CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND` (default **300 seconds = 5 minutes**) acts as a backstop. A client has no backstop of its own — it waits forever unless you set a read timeout — so there `max_missed_pongs` is what notices an unresponsive peer at all. On either side, it is also how you notice one **faster** than that 5-minute fallback.
> For handling a closed connection, see [W03. Handle connection close](../w03-websocket-close).
@@ -42,6 +42,9 @@ switch (result) {
case httplib::ws::ReadResult::Fail:
// error or closed
break;
case httplib::ws::ReadResult::Timeout:
// read timeout elapsed; the connection is still open
break;
}
```
@@ -9,7 +9,7 @@ status: "draft"
| Kind | API | Default |
| --- | --- | --- |
| Connection | `set_connection_timeout` | 300s |
| Read | `set_read_timeout` | 300s (`CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND`) |
| Read | `set_read_timeout` | none — waits forever (`CPPHTTPLIB_WEBSOCKET_CLIENT_READ_TIMEOUT_SECOND`) |
| Write | `set_write_timeout` | 5s |
## Basic usage
@@ -26,7 +26,7 @@ if (ws.connect()) {
}
```
Set these before calling `connect()`.
Set the connection and write timeouts before calling `connect()`. The read timeout can be changed at any time — setting it on an open connection takes effect on the next `read()`.
## Use `std::chrono`
@@ -40,9 +40,44 @@ ws.set_read_timeout(30s);
ws.set_write_timeout(10s);
```
## Watch out for what the read timeout means
## What the read timeout means
`set_read_timeout()` applies to a single `read()` call. If no message arrives within that time, `read()` returns `ReadResult::Fail`. For connections where long idle periods are normal — waiting on notifications, for example — set a longer timeout, or reconnect from your application code when the read fails.
`set_read_timeout()` applies to a single `read()` call. If no message arrives within that time, `read()` returns `ReadResult::Timeout`: **the connection is still open** and nothing was consumed, so you can send on it and read again. That is what separates it from `ReadResult::Fail`, which means the connection is gone.
This is what lets one thread own a connection in both directions:
```cpp
using namespace std::chrono_literals;
ws.set_read_timeout(100ms);
std::string msg;
while (ws.is_open()) {
auto r = ws.read(msg);
if (r == httplib::ws::Timeout) {
flush_outgoing(ws); // nothing arrived — send whatever is queued
continue;
}
if (r == httplib::ws::Fail) { break; }
handle(msg);
}
```
Without a read timeout, `read()` blocks until a message arrives, so the thread holding the connection never gets to its writes.
Two things to know about `Timeout`:
- It leaves `msg` untouched, and it is non-zero. So `while (ws.read(msg))` is not usable once a read timeout is set — the loop would keep running with the *previous* message still in `msg`.
- It is only reported on a message boundary. If the timeout elapses partway through a fragmented message, that message cannot be resumed and `read()` returns `Fail`.
For connections where long idle periods are normal — waiting on notifications, for example — either leave the read timeout unset, or treat `Timeout` as the no-op it is and keep looping.
## On the server side
A handler's `ws::WebSocket` has `set_read_timeout()` too, and the pattern above is how a handler relays between connections instead of parking in `read()`.
The server default is 300s (`CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND`) rather than "forever": it is a backstop that reclaims a worker from a peer that has gone silent, since a WebSocket handler holds its worker for the life of the connection.
Because it is a backstop rather than something the handler asked for, it does not surface as `Timeout`. When it elapses, `read()` returns `Fail` and closes the connection, so a handler written as `while (ws.read(msg))` ends the way it always has. Only a timeout the handler set itself with `set_read_timeout()` comes back as `Timeout`.
> Unresponsive-peer detection via Ping/Pong is a separate mechanism. See [W02. Set a WebSocket Heartbeat](../w02-websocket-ping) for details.
+2
View File
@@ -107,6 +107,8 @@ svr.WebSocket("/ws", [](const httplib::Request &req, httplib::ws::WebSocket &ws)
});
```
A check inside the handler runs after the handshake has completed. To refuse the connection with an HTTP status such as 401 before it is upgraded, use `set_pre_request_handler()` instead. It also runs for WebSocket routes. See [S11. Authenticate per route with a pre-request handler](../../cookbook/s11-pre-request).
## Using WSS
WebSocket over HTTPS (WSS) is also supported. On the server side, just register a WebSocket handler on `httplib::SSLServer`.
@@ -37,6 +37,8 @@ svr.set_pre_request_handler(
`matched_route`はパスパラメーターを展開する**前**のパターン文字列(例: `/admin/users/:id`)です。特定の値ではなく、ルート定義のパターンで判定できるので、IDや名前に左右されません。
`svr.WebSocket()`で登録したルートでも、Pre-requestハンドラは呼ばれます。呼ばれるのは`101 Switching Protocols`を返す前なので、`Handled`を返すとそのHTTPレスポンス(403など)がそのまま返り、WebSocketへのアップグレードは行われません。
## 戻り値の意味
Pre-routingハンドラと同じく、`HandlerResponse`を返します。
@@ -43,15 +43,40 @@ svr.listen_after_bind();
## 戻り値のチェック
`bind_to_port()`は失敗すると`false`を返します。ポートが既に使われている場合などです。必ずチェックしてください。
`bind_to_port()`は失敗すると`false`を返します。ポートにbindする権限が無い場合などです。必ずチェックしてください。
```cpp
if (!svr.bind_to_port("0.0.0.0", 8080)) {
std::cerr << "port already in use" << std::endl;
std::cerr << "bind failed" << std::endl;
return 1;
}
```
`listen_after_bind()`はサーバーが停止するまでブロックし、正常終了なら`true`を返します。
## 使用中のポートを検出する
実は、デフォルトの設定では、ほかのサーバーが使っているポートにもbindできてしまいます。cpp-httplibがサーバーソケットに`SO_REUSEPORT`(Linux、macOS)か`SO_REUSEADDR`(Windows)を設定しているからです。再起動したサーバーはすぐにbindし直せます。その代わり、同じポートで2つ目のサーバーを起動してもエラーにならず、接続が両方に振り分けられます。
使用中のポートで`bind_to_port()`を失敗させたいときは、`set_socket_options()`でソケットオプションを差し替えてください。
```cpp
svr.set_socket_options([](socket_t sock) {
#ifdef _WIN32
httplib::set_socket_opt(sock, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, 1);
#else
httplib::set_socket_opt(sock, SOL_SOCKET, SO_REUSEADDR, 1);
#endif
});
if (!svr.bind_to_port("0.0.0.0", 8080)) {
std::cerr << "port already in use" << std::endl;
return 1;
}
```
`set_socket_options()`はデフォルトの設定を丸ごと置き換えます。Linux、macOSで`SO_REUSEADDR`を設定しているのは、再起動したサーバーがすぐにbindし直せるようにするためです。
> **Note:** Windowsでは`SO_REUSEADDR`だけでは足りません。お互いに`SO_REUSEADDR`を設定したソケット同士は、同じポートにbindできてしまいます。`SO_EXCLUSIVEADDRUSE`を使ってください。
> **Note:** 空いているポートを自動で選びたいときは[S17. ポートを動的に割り当てる](../s17-bind-any-port)を参照してください。こちらも内部では`bind_to_any_port()` + `listen_after_bind()`の組み合わせです。
@@ -31,11 +31,12 @@ int main() {
`svr.WebSocket()`でWebSocket用のハンドラを登録します。ハンドラが呼ばれた時点で、すでにWebSocketのハンドシェイクは完了しています。ループの中で`ws.read()`して`ws.send()`するだけで、エコー動作が完成します。
`read()`の返り値は`ReadResult`列挙値で、次の3種類です。
`read()`の返り値は`ReadResult`列挙値で、次の4種類です。
- `ReadResult::Text`: テキストメッセージを受信
- `ReadResult::Binary`: バイナリメッセージを受信
- `ReadResult::Fail`: エラー、または接続が閉じた
- `ReadResult::Timeout`: `set_read_timeout()`で自分が設定した読み取りタイムアウトが、何も受信しないまま経過した。接続は開いたまま。コンパイル時のデフォルトのタイムアウトは接続を閉じ、`Fail`として返る([W06. タイムアウトを設定する](../w06-websocket-timeouts)を参照)
## クライアント: エコーを叩く
@@ -75,6 +75,6 @@ cli.set_websocket_max_missed_pongs(2); // 2回連続でPongが返ってこなけ
`max_missed_pongs`のデフォルトは`0`で、これは「Pongが何回返ってこなくてもこの仕組みでは切断しない」という意味です。Ping自体は送られ続けますが、応答の有無はチェックされません。無応答ピアを検出したい場合は明示的に`1`以上を設定してください。
ただし`0`のままでも最終的に接続が残り続けることはありません。`read()`を呼んでいる間は`CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND`(デフォルト**300秒 = 5分**)が保険として働き、フレームが一定時間来なければ`read()`が失敗します。つまり`max_missed_pongs`は「**もっと速く**無応答を検出したい」ときに使うオプションだと考えてください。
サーバ側は`0`のままでも接続が残り続けることはありません。ハンドラが`read()`を呼んでいる間は`CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND`(デフォルト**300秒 = 5分**)が保険として働きます。一方クライアント側にはこの保険がなく、読み取りタイムアウトを設定しない限り無期限に待つので、無応答ピアを検出する手段は`max_missed_pongs`だけです。どちらの側でも「**もっと速く**検出したい」ときに使うオプションでもあります。
> 接続が閉じたときの処理は[W03. 接続クローズをハンドリングする](../w03-websocket-close)を参照してください。
@@ -42,6 +42,9 @@ switch (result) {
case httplib::ws::ReadResult::Fail:
// エラーまたは切断
break;
case httplib::ws::ReadResult::Timeout:
// 読み取りタイムアウト。接続は開いたまま
break;
}
```
@@ -9,7 +9,7 @@ status: "draft"
| 種類 | API | デフォルト |
| --- | --- | --- |
| 接続タイムアウト | `set_connection_timeout` | 300秒 |
| 読み取りタイムアウト | `set_read_timeout` | 300秒(`CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND`) |
| 読み取りタイムアウト | `set_read_timeout` | なし。無期限に待つ(`CPPHTTPLIB_WEBSOCKET_CLIENT_READ_TIMEOUT_SECOND`) |
| 書き込みタイムアウト | `set_write_timeout` | 5秒 |
## 基本の使い方
@@ -26,7 +26,7 @@ if (ws.connect()) {
}
```
`connect()`を呼ぶ前に設定してください。
接続タイムアウトと書き込みタイムアウトは`connect()`を呼ぶ前に設定してください。読み取りタイムアウトはいつでも変更でき、接続済みの状態で設定した場合は次の`read()`から効きます。
## `std::chrono`で指定する
@@ -40,9 +40,44 @@ ws.set_read_timeout(30s);
ws.set_write_timeout(10s);
```
## 読み取りタイムアウトの意味に注意
## 読み取りタイムアウトの意味
`set_read_timeout()`は「1回の`read()`呼び出し」に対するタイムアウトです。メッセージが届かないまま指定時間が経過すると`read()`が`ReadResult::Fail`を返します。通知の待受のように長時間メッセージが来ないことが正常な接続では、意図せず切断されないよう長めに設定するか、切断されたらアプリケーション側で再接続してください。
`set_read_timeout()`は「1回の`read()`呼び出し」に対するタイムアウトです。メッセージが届かないまま指定時間が経過すると`read()`は`ReadResult::Timeout`を返します。このとき**接続は開いたまま**で、1バイトも読み進めていないので、そのまま送信して読み直せます。接続が失われたことを意味する`ReadResult::Fail`とはここが違います。
1本の接続を1つのスレッドで双方向に扱えるのはこのためです。
```cpp
using namespace std::chrono_literals;
ws.set_read_timeout(100ms);
std::string msg;
while (ws.is_open()) {
auto r = ws.read(msg);
if (r == httplib::ws::Timeout) {
flush_outgoing(ws); // 何も届いていない。溜まっている分を送る
continue;
}
if (r == httplib::ws::Fail) { break; }
handle(msg);
}
```
読み取りタイムアウトを設定しないと`read()`はメッセージが届くまで戻らないので、接続を持っているスレッドは送信に手が回りません。
`Timeout`について2点あります。
- `msg`は書き換えられません。値も0以外なので、読み取りタイムアウトを設定した状態で`while (ws.read(msg))`と書くと、**前回のメッセージ**が`msg`に残ったままループが回り続けます。
- 報告されるのはメッセージの境界だけです。分割されたメッセージの途中でタイムアウトした場合、そのメッセージは再開できないので`read()`は`Fail`を返します。
通知の待受のように長時間メッセージが来ないことが正常な接続では、読み取りタイムアウトを設定しないままにするか、`Timeout`を「まだ何も来ていない」印として扱ってループを続けてください。
## サーバ側
ハンドラが受け取る`ws::WebSocket`にも`set_read_timeout()`があります。ハンドラが`read()`で止まったままにならないので、上と同じ書き方で複数の接続の間をメッセージが中継できます。
サーバ側のデフォルトは「無期限」ではなく300秒(`CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND`)です。WebSocketのハンドラは接続が続く限りワーカーを1つ占有するので、無言になったピアからワーカーを回収する保険として働きます。
この保険はハンドラが求めたタイムアウトではないので、`Timeout`としては返りません。経過すると`read()`は`Fail`を返して接続を閉じるため、`while (ws.read(msg))`と書いたハンドラは従来どおりそこで終わります。`Timeout`が返るのは、ハンドラ自身が`set_read_timeout()`で設定したタイムアウトだけです。
> Ping/Pongによる無応答ピア検出は別の仕組みです。詳しくは[W02. ハートビートを設定する](../w02-websocket-ping)を参照してください。
+2
View File
@@ -107,6 +107,8 @@ svr.WebSocket("/ws", [](const httplib::Request &req, httplib::ws::WebSocket &ws)
});
```
ハンドラー内のチェックは、ハンドシェイクが完了した後に行われます。アップグレードする前に401などのHTTPステータスで接続を拒否したい場合は、`set_pre_request_handler()`を使ってください。WebSocketのルートでも呼ばれます。詳しくは[S11. Pre-request handlerでルート単位の認証を行う](../../cookbook/s11-pre-request)を参照してください。
## WSSで使う
HTTPS上のWebSocket(WSS)にも対応しています。サーバー側は `httplib::SSLServer` にWebSocketハンドラーを登録するだけです。
+420 -133
View File
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -164,14 +164,18 @@ if [ "$DRY_RUN" -eq 1 ]; then
echo "==> Dry run complete. No changes were made."
else
echo "==> Updating httplib.h..."
sed -i '' "s/#define CPPHTTPLIB_VERSION \"[^\"]*\"/#define CPPHTTPLIB_VERSION \"$NEW_VERSION\"/" httplib.h
sed -i '' "s/#define CPPHTTPLIB_VERSION_NUM \"0x[0-9a-fA-F]*\"/#define CPPHTTPLIB_VERSION_NUM \"$VERSION_HEX\"/" httplib.h
# `-i.bak` is the in-place form GNU and BSD sed both accept (`-i ''` is
# BSD-only: GNU sed reads the '' as the script).
sed -i.bak "s/#define CPPHTTPLIB_VERSION \"[^\"]*\"/#define CPPHTTPLIB_VERSION \"$NEW_VERSION\"/" httplib.h
sed -i.bak "s/#define CPPHTTPLIB_VERSION_NUM \"0x[0-9a-fA-F]*\"/#define CPPHTTPLIB_VERSION_NUM \"$VERSION_HEX\"/" httplib.h
rm -f httplib.h.bak
echo " CPPHTTPLIB_VERSION = \"$NEW_VERSION\""
echo " CPPHTTPLIB_VERSION_NUM = \"$VERSION_HEX\""
echo ""
echo "==> Updating docs-src/config.toml..."
sed -i '' "s/^version = \"[^\"]*\"/version = \"$NEW_VERSION\"/" docs-src/config.toml
sed -i.bak "s/^version = \"[^\"]*\"/version = \"$NEW_VERSION\"/" docs-src/config.toml
rm -f docs-src/config.toml.bak
echo " version = \"$NEW_VERSION\""
# --- Step 6: Commit, tag, and push ---
+1 -1
View File
@@ -1,5 +1,5 @@
CXX = clang++
CXXFLAGS = -g -std=c++11 -I. -Wall -Wextra -Wtype-limits -Wconversion -Wshadow $(EXTRA_CXXFLAGS) -DCPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO -fsanitize=address # -fno-exceptions -DCPPHTTPLIB_NO_EXCEPTIONS
CXXFLAGS = -g -std=c++11 -I. -Wall -Wextra -Wtype-limits -Wconversion -Wshadow -DCPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO -fsanitize=address $(EXTRA_CXXFLAGS) # -fno-exceptions -DCPPHTTPLIB_NO_EXCEPTIONS
ifneq ($(OS), Windows_NT)
UNAME_S := $(shell uname -s)
+21
View File
@@ -18,3 +18,24 @@ services:
context: ./
args:
auth: digest
# Self-hosted stand-in for the httpbin.org-style auth-testing endpoints
# (/basic-auth, /digest-auth) that BaseAuthTest/DigestAuthTest exercise
# through the proxies above, so those tests don't depend on an external
# site's uptime.
httpbin_backend:
image: mccutchen/go-httpbin:latest
restart: always
# TLS termination in front of httpbin_backend (which only speaks plain
# HTTP) so the SSL variants of those tests can CONNECT-tunnel through the
# proxies to "httpbin" on port 443, same as the NoSSL variants do on 80.
httpbin:
image: nginx:alpine
restart: always
depends_on:
- httpbin_backend
volumes:
- ./httpbin_nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ../cert.pem:/etc/nginx/certs/cert.pem:ro
- ../key.pem:/etc/nginx/certs/key.pem:ro
+22
View File
@@ -0,0 +1,22 @@
server {
listen 80;
server_name httpbin;
location / {
proxy_pass http://httpbin_backend:8080;
proxy_set_header Host $host;
}
}
server {
listen 443 ssl;
server_name httpbin;
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
location / {
proxy_pass http://httpbin_backend:8080;
proxy_set_header Host $host;
}
}
+1129 -30
View File
File diff suppressed because it is too large Load Diff
+36 -25
View File
@@ -22,23 +22,25 @@ template <typename T> void ProxyTest(T &cli, bool basic) {
}
TEST(ProxyTest, NoSSLBasic) {
Client cli("httpbingo.org");
Client cli("httpbin");
ProxyTest(cli, true);
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(ProxyTest, SSLBasic) {
SSLClient cli("httpbingo.org");
SSLClient cli("httpbin");
cli.enable_server_certificate_verification(false);
ProxyTest(cli, true);
}
TEST(ProxyTest, NoSSLDigest) {
Client cli("httpbingo.org");
Client cli("httpbin");
ProxyTest(cli, false);
}
TEST(ProxyTest, SSLDigest) {
SSLClient cli("httpbingo.org");
SSLClient cli("httpbin");
cli.enable_server_certificate_verification(false);
ProxyTest(cli, false);
}
#endif
@@ -63,23 +65,25 @@ void RedirectProxyText(T &cli, const char *path, bool basic) {
}
TEST(RedirectTest, HTTPBinNoSSLBasic) {
Client cli("httpbingo.org");
Client cli("httpbin");
RedirectProxyText(cli, "/redirect/2", true);
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(RedirectTest, HTTPBinNoSSLDigest) {
Client cli("httpbingo.org");
Client cli("httpbin");
RedirectProxyText(cli, "/redirect/2", false);
}
TEST(RedirectTest, HTTPBinSSLBasic) {
SSLClient cli("httpbingo.org");
SSLClient cli("httpbin");
cli.enable_server_certificate_verification(false);
RedirectProxyText(cli, "/redirect/2", true);
}
TEST(RedirectTest, HTTPBinSSLDigest) {
SSLClient cli("httpbingo.org");
SSLClient cli("httpbin");
cli.enable_server_certificate_verification(false);
RedirectProxyText(cli, "/redirect/2", false);
}
#endif
@@ -173,7 +177,8 @@ template <typename T> void BaseAuthTestFromHTTPWatch(T &cli) {
cli.Get("/basic-auth/hello/world",
Headers{make_basic_authentication_header("hello", "world")});
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(normalizeJson("{\"authenticated\":true,\"user\":\"hello\"}\n"),
EXPECT_EQ(normalizeJson("{\"authenticated\":true,\"user\":\"hello\","
"\"authorized\":true}\n"),
normalizeJson(res->body));
EXPECT_EQ(StatusCode::OK_200, res->status);
}
@@ -182,7 +187,8 @@ template <typename T> void BaseAuthTestFromHTTPWatch(T &cli) {
cli.set_basic_auth("hello", "world");
auto res = cli.Get("/basic-auth/hello/world");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(normalizeJson("{\"authenticated\":true,\"user\":\"hello\"}\n"),
EXPECT_EQ(normalizeJson("{\"authenticated\":true,\"user\":\"hello\","
"\"authorized\":true}\n"),
normalizeJson(res->body));
EXPECT_EQ(StatusCode::OK_200, res->status);
}
@@ -203,13 +209,14 @@ template <typename T> void BaseAuthTestFromHTTPWatch(T &cli) {
}
TEST(BaseAuthTest, NoSSL) {
Client cli("httpcan.org");
Client cli("httpbin");
BaseAuthTestFromHTTPWatch(cli);
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(BaseAuthTest, SSL) {
SSLClient cli("httpcan.org");
SSLClient cli("httpbin");
cli.enable_server_certificate_verification(false);
BaseAuthTestFromHTTPWatch(cli);
}
#endif
@@ -228,21 +235,21 @@ template <typename T> void DigestAuthTestFromHTTPWatch(T &cli) {
}
{
// go-httpbin (the "httpbin" test double) only implements MD5 and
// SHA-256 for digest auth, so SHA-256 is as far as this can exercise
// the client's digest-auth algorithm selection end-to-end.
std::vector<std::string> paths = {
"/digest-auth/auth/hello/world/MD5",
"/digest-auth/auth/hello/world/SHA-256",
"/digest-auth/auth/hello/world/SHA-512",
};
cli.set_digest_auth("hello", "world");
for (auto path : paths) {
auto res = cli.Get(path.c_str());
ASSERT_TRUE(res != nullptr);
std::string algo(path.substr(path.rfind('/') + 1));
EXPECT_EQ(
normalizeJson("{\"algorithm\":\"" + algo +
"\",\"authenticated\":true,\"user\":\"hello\"}\n"),
normalizeJson(res->body));
EXPECT_EQ(normalizeJson("{\"authenticated\":true,\"user\":\"hello\","
"\"authorized\":true}\n"),
normalizeJson(res->body));
EXPECT_EQ(StatusCode::OK_200, res->status);
}
@@ -263,12 +270,13 @@ template <typename T> void DigestAuthTestFromHTTPWatch(T &cli) {
}
TEST(DigestAuthTest, SSL) {
SSLClient cli("httpcan.org");
SSLClient cli("httpbin");
cli.enable_server_certificate_verification(false);
DigestAuthTestFromHTTPWatch(cli);
}
TEST(DigestAuthTest, NoSSL) {
Client cli("httpcan.org");
Client cli("httpbin");
DigestAuthTestFromHTTPWatch(cli);
}
#endif
@@ -329,22 +337,24 @@ template <typename T> void KeepAliveTest(T &cli, bool basic) {
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(KeepAliveTest, NoSSLWithBasic) {
Client cli("httpbingo.org");
Client cli("httpbin");
KeepAliveTest(cli, true);
}
TEST(KeepAliveTest, SSLWithBasic) {
SSLClient cli("httpbingo.org");
SSLClient cli("httpbin");
cli.enable_server_certificate_verification(false);
KeepAliveTest(cli, true);
}
TEST(KeepAliveTest, NoSSLWithDigest) {
Client cli("httpbingo.org");
Client cli("httpbin");
KeepAliveTest(cli, false);
}
TEST(KeepAliveTest, SSLWithDigest) {
SSLClient cli("httpbingo.org");
SSLClient cli("httpbin");
cli.enable_server_certificate_verification(false);
KeepAliveTest(cli, false);
}
#endif
@@ -353,7 +363,8 @@ TEST(KeepAliveTest, SSLWithDigest) {
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(ProxyTest, SSLOpenStream) {
SSLClient cli("httpbingo.org");
SSLClient cli("httpbin");
cli.enable_server_certificate_verification(false);
cli.set_proxy("localhost", 3128);
cli.set_proxy_basic_auth("hello", "world");
+74 -1
View File
@@ -3,11 +3,14 @@
// without waiting 30 seconds.
#define CPPHTTPLIB_WEBSOCKET_PING_INTERVAL_SECOND 1
#define CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND 3
#define CPPHTTPLIB_WEBSOCKET_CLIENT_READ_TIMEOUT_SECOND 3
#define CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND 3
#include <httplib.h>
#include "gtest/gtest.h"
#include <future>
using namespace httplib;
class WebSocketHeartbeatTest : public ::testing::Test {
@@ -191,6 +194,76 @@ TEST_F(WebSocketPongTimeoutTest, ClientDetectsNonResponsivePeer) {
EXPECT_FALSE(client.is_open());
}
// The compile-time client read timeout (3s here) was never asked for through
// set_read_timeout(), so when it elapses read() reports Fail and closes the
// connection rather than handing back a Timeout on a still-open one.
TEST_F(WebSocketPongTimeoutTest, CompileTimeClientReadTimeoutIsFail) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) + "/ws");
client.set_websocket_ping_interval(0);
ASSERT_TRUE(client.connect());
// Server pings are off and its handler never sends, so nothing arrives.
std::string msg;
EXPECT_EQ(client.read(msg), ws::Fail);
EXPECT_FALSE(client.is_open());
}
// The compile-time server read timeout (3s here) is a backstop that reclaims
// the worker from a peer gone quiet, not a timeout the handler asked for. When
// it elapses read() must return Fail, so a handler written as
// `while (ws.read(msg))` ends instead of re-running its body with the previous
// message still in `msg`.
class WebSocketServerReadTimeoutTest : public ::testing::Test {
protected:
void SetUp() override {
svr_.set_websocket_ping_interval(0);
svr_.WebSocket("/ws", [this](const Request &, ws::WebSocket &ws) {
std::string msg;
while (ws.read(msg)) {
iterations_++;
ws.send(msg);
}
handler_done_.set_value();
});
port_ = svr_.bind_to_any_port("localhost");
thread_ = std::thread([this]() { svr_.listen_after_bind(); });
svr_.wait_until_ready();
}
void TearDown() override {
svr_.stop();
thread_.join();
}
Server svr_;
int port_;
std::thread thread_;
std::atomic<int> iterations_{0};
std::promise<void> handler_done_;
};
TEST_F(WebSocketServerReadTimeoutTest, BackstopEndsHandlerLoop) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) + "/ws");
client.set_websocket_ping_interval(0); // nothing reaches the server's read()
client.set_read_timeout(10, 0); // fail rather than hang
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.send("hello"));
std::string msg;
ASSERT_EQ(client.read(msg), ws::Text);
EXPECT_EQ("hello", msg);
// The client now stays silent. The server's backstop elapses and the
// handler returns, having run its loop body exactly once.
auto done = handler_done_.get_future();
ASSERT_EQ(done.wait_for(std::chrono::seconds(6)), std::future_status::ready);
EXPECT_EQ(1, iterations_.load());
EXPECT_EQ(client.read(msg), ws::Fail);
EXPECT_FALSE(client.is_open());
}
// Verify that a responsive peer does NOT trigger the pong-timeout mechanism,
// even with a small max_missed_pongs budget. This is the positive counterpart
// of ClientDetectsNonResponsivePeer: the client must actively drive read() so