mirror of
https://github.com/yhirose/cpp-httplib
synced 2026-06-08 18:30:49 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28f8264d13 | |||
| 91271c062d | |||
| d755c43d58 | |||
| 5c9285776e | |||
| 811dd0b6f2 | |||
| e8e652824b | |||
| fbb031ed85 | |||
| 7d5082cc0e |
@@ -4,7 +4,7 @@ langs = ["en", "ja"]
|
||||
|
||||
[site]
|
||||
title = "cpp-httplib"
|
||||
version = "0.43.4"
|
||||
version = "0.45.0"
|
||||
hostname = "https://yhirose.github.io"
|
||||
base_path = "/cpp-httplib"
|
||||
footer_message = "© 2026 Yuji Hirose. All rights reserved."
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
#ifndef CPPHTTPLIB_HTTPLIB_H
|
||||
#define CPPHTTPLIB_HTTPLIB_H
|
||||
|
||||
#define CPPHTTPLIB_VERSION "0.43.4"
|
||||
#define CPPHTTPLIB_VERSION_NUM "0x002b04"
|
||||
#define CPPHTTPLIB_VERSION "0.45.0"
|
||||
#define CPPHTTPLIB_VERSION_NUM "0x002d00"
|
||||
|
||||
#ifdef _WIN32
|
||||
#if defined(_WIN32_WINNT) && _WIN32_WINNT < 0x0A00
|
||||
@@ -5016,12 +5016,11 @@ inline bool parse_header(const char *beg, const char *end, T fn) {
|
||||
|
||||
if (!detail::fields::is_field_value(val)) { return false; }
|
||||
|
||||
if (case_ignore::equal(key, "Location") ||
|
||||
case_ignore::equal(key, "Referer")) {
|
||||
fn(key, val);
|
||||
} else {
|
||||
fn(key, decode_path_component(val));
|
||||
}
|
||||
// RFC 9110 §5.5: header field values are opaque octets and MUST NOT be
|
||||
// percent-decoded by the recipient. Applications that need to interpret a
|
||||
// value as a URI component should call httplib::decode_uri_component()
|
||||
// (or decode_path_component()) explicitly.
|
||||
fn(key, val);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -8579,17 +8578,24 @@ write_multipart_ranges_data(Stream &strm, const Request &req, Response &res,
|
||||
});
|
||||
}
|
||||
|
||||
inline bool has_framed_body(const Request &req) {
|
||||
return is_chunked_transfer_encoding(req.headers) ||
|
||||
req.get_header_value_u64("Content-Length") > 0;
|
||||
}
|
||||
|
||||
inline bool is_connection_persistent(const Request &req) {
|
||||
auto conn = req.get_header_value("Connection");
|
||||
if (conn == "close") { return false; }
|
||||
if (req.version == "HTTP/1.0" && conn != "Keep-Alive") { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
inline bool expect_content(const Request &req) {
|
||||
if (req.method == "POST" || req.method == "PUT" || req.method == "PATCH" ||
|
||||
req.method == "DELETE") {
|
||||
return true;
|
||||
}
|
||||
if (req.has_header("Content-Length") &&
|
||||
req.get_header_value_u64("Content-Length") > 0) {
|
||||
return true;
|
||||
}
|
||||
if (is_chunked_transfer_encoding(req.headers)) { return true; }
|
||||
return false;
|
||||
return has_framed_body(req);
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
@@ -10047,9 +10053,29 @@ inline ThreadPool::ThreadPool(size_t n, size_t max_n, size_t mqr)
|
||||
#endif
|
||||
max_thread_count_ = max_n == 0 ? n : max_n;
|
||||
threads_.reserve(base_thread_count_);
|
||||
for (size_t i = 0; i < base_thread_count_; i++) {
|
||||
threads_.emplace_back(std::thread([this]() { worker(false); }));
|
||||
#ifndef CPPHTTPLIB_NO_EXCEPTIONS
|
||||
try {
|
||||
#endif
|
||||
for (size_t i = 0; i < base_thread_count_; i++) {
|
||||
threads_.emplace_back(std::thread([this]() { worker(false); }));
|
||||
}
|
||||
#ifndef CPPHTTPLIB_NO_EXCEPTIONS
|
||||
} catch (...) {
|
||||
// If thread creation fails partway (e.g., pthread_create returns EAGAIN),
|
||||
// signal the workers we already spawned to exit and join them so the
|
||||
// vector destructor does not see joinable threads (which would call
|
||||
// std::terminate). Then rethrow so the caller learns of the failure.
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
shutdown_ = true;
|
||||
}
|
||||
cond_.notify_all();
|
||||
for (auto &t : threads_) {
|
||||
if (t.joinable()) { t.join(); }
|
||||
}
|
||||
throw;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
inline bool ThreadPool::enqueue(std::function<void()> fn) {
|
||||
@@ -11285,29 +11311,18 @@ inline bool Server::read_content_core(
|
||||
size_t /*len*/) { return receiver(buf, n); };
|
||||
}
|
||||
|
||||
// RFC 7230 Section 3.3.3: If this is a request message and none of the above
|
||||
// are true (no Transfer-Encoding and no Content-Length), then the message
|
||||
// body length is zero (no message body is present).
|
||||
//
|
||||
// For non-SSL builds, detect clients that send a body without a
|
||||
// Content-Length header (raw HTTP over TCP). Check both the stream's
|
||||
// internal read buffer (data already read from the socket during header
|
||||
// parsing) and the socket itself for pending data. If data is found and
|
||||
// exceeds the configured payload limit, reject with 413.
|
||||
// For SSL builds we cannot reliably peek the decrypted application bytes,
|
||||
// so keep the original behaviour.
|
||||
// RFC 9112 §6: no Transfer-Encoding and no Content-Length means no body.
|
||||
// For non-SSL builds we still scan non-persistent connections for stray
|
||||
// body bytes so the payload limit is enforced (413). On keep-alive,
|
||||
// pending bytes may be the next request (issue #2450), so skip.
|
||||
#if !defined(CPPHTTPLIB_SSL_ENABLED)
|
||||
if (!req.has_header("Content-Length") &&
|
||||
!detail::is_chunked_transfer_encoding(req.headers)) {
|
||||
// Only check if payload_max_length is set to a finite value
|
||||
if (payload_max_length_ > 0 &&
|
||||
if (!detail::is_connection_persistent(req) && payload_max_length_ > 0 &&
|
||||
payload_max_length_ < (std::numeric_limits<size_t>::max)()) {
|
||||
// Check if there is data already buffered in the stream (read during
|
||||
// header parsing) or pending on the socket. Use a non-blocking socket
|
||||
// check to avoid deadlock when the client sends no body.
|
||||
bool has_data = strm.is_readable();
|
||||
auto has_data = strm.is_readable();
|
||||
if (!has_data) {
|
||||
socket_t s = strm.socket();
|
||||
auto s = strm.socket();
|
||||
if (s != INVALID_SOCKET) {
|
||||
has_data = detail::select_read(s, 0, 0) > 0;
|
||||
}
|
||||
@@ -11869,6 +11884,11 @@ get_client_ip(const std::string &x_forwarded_for,
|
||||
ip_list.emplace_back(std::string(b + r.first, b + r.second));
|
||||
});
|
||||
|
||||
// A malformed X-Forwarded-For (empty, comma-only, whitespace-only) yields
|
||||
// no segments. Signal "no client IP derived" with an empty string so the
|
||||
// caller can fall back to the connection-level remote address.
|
||||
if (ip_list.empty()) { return std::string(); }
|
||||
|
||||
for (size_t i = 0; i < ip_list.size(); ++i) {
|
||||
auto ip = ip_list[i];
|
||||
|
||||
@@ -11959,7 +11979,8 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
|
||||
|
||||
if (!trusted_proxies_.empty() && req.has_header("X-Forwarded-For")) {
|
||||
auto x_forwarded_for = req.get_header_value("X-Forwarded-For");
|
||||
req.remote_addr = get_client_ip(x_forwarded_for, trusted_proxies_);
|
||||
auto derived = get_client_ip(x_forwarded_for, trusted_proxies_);
|
||||
req.remote_addr = derived.empty() ? remote_addr : derived;
|
||||
} else {
|
||||
req.remote_addr = remote_addr;
|
||||
}
|
||||
@@ -12161,15 +12182,14 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
|
||||
ret = write_response(strm, close_connection, req, res);
|
||||
}
|
||||
|
||||
// Drain any unconsumed request body to prevent request smuggling on
|
||||
// keep-alive connections.
|
||||
if (!req.body_consumed_ && detail::expect_content(req)) {
|
||||
int drain_status = 200; // required by read_content signature
|
||||
// Drain any unconsumed framed body to prevent request smuggling on
|
||||
// keep-alive. Without framing there is no body to drain — reading would
|
||||
// consume the next request (issue #2450).
|
||||
if (!req.body_consumed_ && detail::has_framed_body(req)) {
|
||||
int dummy_status;
|
||||
if (!detail::read_content(
|
||||
strm, req, payload_max_length_, drain_status, nullptr,
|
||||
strm, req, payload_max_length_, dummy_status, nullptr,
|
||||
[](const char *, size_t, size_t, size_t) { return true; }, false)) {
|
||||
// Body exceeds payload limit or read error — close the connection
|
||||
// to prevent leftover bytes from being misinterpreted.
|
||||
connection_closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
+33
-14
@@ -2,10 +2,12 @@
|
||||
#
|
||||
# Release a new version of cpp-httplib.
|
||||
#
|
||||
# Usage: ./release.sh [--run]
|
||||
# Usage: ./release.sh [--run] [--minor]
|
||||
#
|
||||
# By default, runs in dry-run mode (no changes made).
|
||||
# Pass --run to actually update files, commit, tag, and push.
|
||||
# Pass --minor to force a minor bump even when ABI is unchanged
|
||||
# (use this for behavioral breaking changes that don't break ABI).
|
||||
#
|
||||
# This script:
|
||||
# 1. Reads the current version from httplib.h
|
||||
@@ -14,21 +16,30 @@
|
||||
# 4. Determines the next version automatically:
|
||||
# - abidiff passed → patch bump (e.g., 0.38.0 → 0.38.1)
|
||||
# - abidiff failed → minor bump (e.g., 0.38.1 → 0.39.0)
|
||||
# - --minor passed → forces minor bump regardless of abidiff
|
||||
# 5. Updates httplib.h and docs-src/config.toml
|
||||
# 6. Commits, tags (vX.Y.Z), and pushes
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DRY_RUN=1
|
||||
if [ "${1:-}" = "--run" ]; then
|
||||
DRY_RUN=0
|
||||
shift
|
||||
fi
|
||||
|
||||
if [ $# -ne 0 ]; then
|
||||
echo "Usage: $0 [--run]"
|
||||
exit 1
|
||||
fi
|
||||
FORCE_MINOR=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--run)
|
||||
DRY_RUN=0
|
||||
shift
|
||||
;;
|
||||
--minor)
|
||||
FORCE_MINOR=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [--run] [--minor]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Step 1: Read current version from httplib.h ---
|
||||
CURRENT_VERSION=$(sed -n 's/^#define CPPHTTPLIB_VERSION "\([^"]*\)"/\1/p' httplib.h)
|
||||
@@ -51,8 +62,7 @@ HEAD_SHORT=$(git rev-parse --short HEAD)
|
||||
echo " Latest commit: $HEAD_SHORT"
|
||||
|
||||
# Fetch all workflow runs for the HEAD commit
|
||||
RUNS=$(gh run list --json name,conclusion,headSha \
|
||||
--jq "[.[] | select(.headSha == \"$HEAD_SHA\")]")
|
||||
RUNS=$(gh run list --commit "$HEAD_SHA" --json name,conclusion,headSha)
|
||||
|
||||
NUM_RUNS=$(echo "$RUNS" | jq 'length')
|
||||
|
||||
@@ -95,7 +105,12 @@ fi
|
||||
echo " All non-abidiff CI checks passed."
|
||||
|
||||
# --- Step 4: Determine new version ---
|
||||
if [ "$ABIDIFF_PASSED" -eq 1 ]; then
|
||||
if [ "$FORCE_MINOR" -eq 1 ] && [ "$ABIDIFF_PASSED" -eq 1 ]; then
|
||||
NEW_MINOR=$((V_MINOR + 1))
|
||||
NEW_VERSION="$V_MAJOR.$NEW_MINOR.0"
|
||||
echo ""
|
||||
echo "==> abidiff passed but --minor specified → forced minor bump"
|
||||
elif [ "$ABIDIFF_PASSED" -eq 1 ]; then
|
||||
NEW_PATCH=$((V_PATCH + 1))
|
||||
NEW_VERSION="$V_MAJOR.$V_MINOR.$NEW_PATCH"
|
||||
echo ""
|
||||
@@ -104,7 +119,11 @@ else
|
||||
NEW_MINOR=$((V_MINOR + 1))
|
||||
NEW_VERSION="$V_MAJOR.$NEW_MINOR.0"
|
||||
echo ""
|
||||
echo "==> abidiff failed → minor bump"
|
||||
if [ "$FORCE_MINOR" -eq 1 ]; then
|
||||
echo "==> abidiff failed → minor bump (--minor also specified)"
|
||||
else
|
||||
echo "==> abidiff failed → minor bump"
|
||||
fi
|
||||
fi
|
||||
|
||||
VERSION_HEX=$(printf "0x%02x%02x%02x" "${NEW_VERSION%%.*}" "$(echo "$NEW_VERSION" | cut -d. -f2)" "${NEW_VERSION##*.}")
|
||||
|
||||
+18
-1
@@ -202,8 +202,25 @@ test_split_no_tls : test.cc ../httplib.h httplib.cc Makefile
|
||||
$(CXX) -o $@ $(CXXFLAGS) test.cc httplib.cc $(TEST_ARGS_NO_TLS)
|
||||
|
||||
# ThreadPool unit tests (no TLS, no compression needed)
|
||||
#
|
||||
# The constructor-exception-safety reproducer test interposes pthread_create
|
||||
# at link time. The link flags below enable that interposition. ASAN is also
|
||||
# stripped from this target because libasan installs its own pthread_create
|
||||
# interceptor; layering our override on top corrupts ASAN's thread bookkeeping
|
||||
# and trips "Joining already joined thread" on Linux. ThreadPool memory
|
||||
# behavior is still covered by the ASAN-instrumented `test` binary.
|
||||
ifneq ($(OS), Windows_NT)
|
||||
ifeq ($(shell uname -s), Darwin)
|
||||
THREAD_POOL_INTERPOSE_LDFLAGS := -Wl,-flat_namespace
|
||||
else
|
||||
THREAD_POOL_INTERPOSE_LDFLAGS := -Wl,--export-dynamic
|
||||
endif
|
||||
endif
|
||||
|
||||
THREAD_POOL_CXXFLAGS := $(filter-out -fsanitize=address,$(CXXFLAGS))
|
||||
|
||||
test_thread_pool : test_thread_pool.cc ../httplib.h Makefile
|
||||
$(CXX) -o $@ -I.. $(CXXFLAGS) test_thread_pool.cc gtest/src/gtest-all.cc gtest/src/gtest_main.cc -Igtest -Igtest/include -lpthread
|
||||
$(CXX) -o $@ -I.. $(THREAD_POOL_CXXFLAGS) test_thread_pool.cc gtest/src/gtest-all.cc gtest/src/gtest_main.cc -Igtest -Igtest/include -lpthread $(THREAD_POOL_INTERPOSE_LDFLAGS)
|
||||
|
||||
check_abi:
|
||||
@./check-shared-library-abi-compatibility.sh
|
||||
|
||||
+223
@@ -7441,6 +7441,122 @@ TEST(ServerRequestParsingTest, EmptyFieldValue) {
|
||||
EXPECT_EQ("HTTP/1.1 200 OK", out.substr(0, 15));
|
||||
}
|
||||
|
||||
TEST(ServerRequestParsingTest, HeaderValueNotPercentDecoded) {
|
||||
Server svr;
|
||||
std::string x_custom;
|
||||
std::string cookie;
|
||||
std::string xff;
|
||||
std::string x_unicode;
|
||||
std::string x_iis;
|
||||
|
||||
svr.Get("/check", [&](const Request &req, Response &res) {
|
||||
x_custom = req.get_header_value("X-Custom");
|
||||
cookie = req.get_header_value("Cookie");
|
||||
xff = req.get_header_value("X-Forwarded-For");
|
||||
x_unicode = req.get_header_value("X-Unicode");
|
||||
x_iis = req.get_header_value("X-IIS");
|
||||
res.set_content("ok", "text/plain");
|
||||
});
|
||||
|
||||
thread t = thread([&] { svr.listen(HOST, PORT); });
|
||||
auto se = detail::scope_exit([&] {
|
||||
svr.stop();
|
||||
t.join();
|
||||
ASSERT_FALSE(svr.is_running());
|
||||
});
|
||||
|
||||
svr.wait_until_ready();
|
||||
|
||||
const std::string req = "GET /check HTTP/1.1\r\n"
|
||||
"Host: localhost\r\n"
|
||||
"X-Custom: a%0D%0AInjected: b\r\n"
|
||||
"Cookie: session%3Dvictim%3B%20admin%3Dyes\r\n"
|
||||
"X-Forwarded-For: 1.2.3.4%2C5.6.7.8\r\n"
|
||||
"X-Unicode: %E3%81%82\r\n"
|
||||
"X-IIS: %u00E9\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n";
|
||||
|
||||
std::string res;
|
||||
ASSERT_TRUE(send_request(5, req, &res));
|
||||
EXPECT_EQ("HTTP/1.1 200 OK", res.substr(0, 15));
|
||||
|
||||
// Every value must be returned verbatim (wire form), with no decoding.
|
||||
EXPECT_EQ("a%0D%0AInjected: b", x_custom);
|
||||
EXPECT_EQ("session%3Dvictim%3B%20admin%3Dyes", cookie);
|
||||
EXPECT_EQ("1.2.3.4%2C5.6.7.8", xff);
|
||||
EXPECT_EQ("%E3%81%82", x_unicode);
|
||||
EXPECT_EQ("%u00E9", x_iis);
|
||||
}
|
||||
|
||||
// Applications that previously relied on automatic percent-decoding can
|
||||
// reproduce the old behavior by explicitly calling decode_path_component()
|
||||
// or, for RFC 3986 conformance, decode_uri_component().
|
||||
TEST(ServerRequestParsingTest, HeaderValueExplicitDecodingByApplication) {
|
||||
Server svr;
|
||||
std::string decoded;
|
||||
|
||||
svr.Get("/check", [&](const Request &req, Response &res) {
|
||||
decoded = decode_uri_component(req.get_header_value("X-Custom"));
|
||||
res.set_content("ok", "text/plain");
|
||||
});
|
||||
|
||||
thread t = thread([&] { svr.listen(HOST, PORT); });
|
||||
auto se = detail::scope_exit([&] {
|
||||
svr.stop();
|
||||
t.join();
|
||||
ASSERT_FALSE(svr.is_running());
|
||||
});
|
||||
|
||||
svr.wait_until_ready();
|
||||
|
||||
const std::string req = "GET /check HTTP/1.1\r\n"
|
||||
"Host: localhost\r\n"
|
||||
"X-Custom: hello%20world\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n";
|
||||
|
||||
std::string res;
|
||||
ASSERT_TRUE(send_request(5, req, &res));
|
||||
EXPECT_EQ("HTTP/1.1 200 OK", res.substr(0, 15));
|
||||
EXPECT_EQ("hello world", decoded);
|
||||
}
|
||||
|
||||
// Regression test for #2033. Browsers send Referer values that include
|
||||
// percent-encoded characters such as %0A inside the URL. Decoding the
|
||||
// header value would either trip the post-decode CR/LF/NUL guard (the
|
||||
// original bug, returning 400) or, after that guard was relaxed, silently
|
||||
// store a literal LF — both unacceptable. The wire form must round-trip.
|
||||
TEST(ServerRequestParsingTest, RefererWithPercentEncodedNewline) {
|
||||
Server svr;
|
||||
std::string referer;
|
||||
|
||||
svr.Get("/check", [&](const Request &req, Response &res) {
|
||||
referer = req.get_header_value("Referer");
|
||||
res.set_content("ok", "text/plain");
|
||||
});
|
||||
|
||||
thread t = thread([&] { svr.listen(HOST, PORT); });
|
||||
auto se = detail::scope_exit([&] {
|
||||
svr.stop();
|
||||
t.join();
|
||||
ASSERT_FALSE(svr.is_running());
|
||||
});
|
||||
|
||||
svr.wait_until_ready();
|
||||
|
||||
const std::string req = "GET /check HTTP/1.1\r\n"
|
||||
"Host: localhost\r\n"
|
||||
"Referer: http://localhost:1111/?q=Hello%0A\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n";
|
||||
|
||||
std::string res;
|
||||
ASSERT_TRUE(send_request(5, req, &res));
|
||||
EXPECT_EQ("HTTP/1.1 200 OK", res.substr(0, 15));
|
||||
EXPECT_EQ("http://localhost:1111/?q=Hello%0A", referer);
|
||||
}
|
||||
|
||||
TEST(ServerStopTest, StopServerWithChunkedTransmission) {
|
||||
Server svr;
|
||||
|
||||
@@ -14135,6 +14251,53 @@ TEST(ForwardedHeadersTest, HandlesWhitespaceAroundIPs) {
|
||||
EXPECT_EQ(observed_remote_addr, "203.0.113.66");
|
||||
}
|
||||
|
||||
// An X-Forwarded-For header whose value parses to zero IP segments must not
|
||||
// crash the server (it used to call front() on an empty vector inside
|
||||
// get_client_ip). The connection-level remote address must be retained instead.
|
||||
static void run_malformed_xff_test(const std::string &xff_value) {
|
||||
Server svr;
|
||||
svr.set_trusted_proxies({"192.0.2.45"});
|
||||
|
||||
std::string observed_remote_addr;
|
||||
svr.Get("/ip", [&](const Request &req, Response &res) {
|
||||
observed_remote_addr = req.remote_addr;
|
||||
res.set_content("ok", "text/plain");
|
||||
});
|
||||
|
||||
int port = 0;
|
||||
thread t = thread([&]() {
|
||||
port = svr.bind_to_any_port(HOST);
|
||||
svr.listen_after_bind();
|
||||
});
|
||||
auto se = detail::scope_exit([&] {
|
||||
svr.stop();
|
||||
t.join();
|
||||
ASSERT_FALSE(svr.is_running());
|
||||
});
|
||||
|
||||
svr.wait_until_ready();
|
||||
|
||||
Client cli(HOST, port);
|
||||
auto res = cli.Get("/ip", {{"X-Forwarded-For", xff_value}});
|
||||
|
||||
ASSERT_TRUE(res);
|
||||
EXPECT_EQ(StatusCode::OK_200, res->status);
|
||||
EXPECT_TRUE(observed_remote_addr == "::1" ||
|
||||
observed_remote_addr == "127.0.0.1");
|
||||
}
|
||||
|
||||
TEST(ForwardedHeadersTest, EmptyXForwardedFor_DoesNotCrash) {
|
||||
run_malformed_xff_test("");
|
||||
}
|
||||
|
||||
TEST(ForwardedHeadersTest, CommaOnlyXForwardedFor_DoesNotCrash) {
|
||||
run_malformed_xff_test(",");
|
||||
}
|
||||
|
||||
TEST(ForwardedHeadersTest, MultipleCommasXForwardedFor_DoesNotCrash) {
|
||||
run_malformed_xff_test(", , ,");
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
TEST(ServerRequestParsingTest, RequestWithoutContentLengthOrTransferEncoding) {
|
||||
Server svr;
|
||||
@@ -18041,3 +18204,63 @@ TEST(RequestSmugglingTest, ContentLengthAndTransferEncodingRejected) {
|
||||
response.substr(0, response.find("\r\n")));
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for issue #2450: a DELETE without Content-Length on a
|
||||
// keep-alive connection must not let the post-response drain consume the
|
||||
// next request's bytes.
|
||||
TEST(KeepAliveTest, DeleteWithoutContentLengthDoesNotEatNextRequest) {
|
||||
Server svr;
|
||||
|
||||
std::atomic<int> delete_count(0);
|
||||
svr.Delete("/items/:id", [&](const Request &, Response &res) {
|
||||
delete_count++;
|
||||
res.status = StatusCode::NoContent_204;
|
||||
});
|
||||
|
||||
auto port = svr.bind_to_any_port(HOST);
|
||||
thread t = thread([&] { svr.listen_after_bind(); });
|
||||
auto se = detail::scope_exit([&] {
|
||||
svr.stop();
|
||||
t.join();
|
||||
});
|
||||
svr.wait_until_ready();
|
||||
|
||||
auto error = Error::Success;
|
||||
auto sock = detail::create_client_socket(
|
||||
HOST, "", port, AF_UNSPEC, false, false, nullptr,
|
||||
/*connection_timeout_sec=*/2, 0,
|
||||
/*read_timeout_sec=*/2, 0,
|
||||
/*write_timeout_sec=*/2, 0, std::string(), error);
|
||||
ASSERT_NE(INVALID_SOCKET, sock);
|
||||
auto sock_se = detail::scope_exit([&] { detail::close_socket(sock); });
|
||||
|
||||
auto send_request_and_read_response = [&](const std::string &req,
|
||||
std::string &out) -> bool {
|
||||
auto sent = send(sock, req.data(), req.size(), 0);
|
||||
if (sent != static_cast<ssize_t>(req.size())) { return false; }
|
||||
char buf[4096];
|
||||
for (;;) {
|
||||
auto n = recv(sock, buf, sizeof(buf), 0);
|
||||
if (n <= 0) { return !out.empty(); }
|
||||
out.append(buf, static_cast<size_t>(n));
|
||||
if (out.find("\r\n\r\n") != std::string::npos) { return true; }
|
||||
}
|
||||
};
|
||||
|
||||
std::string req1 = "DELETE /items/1 HTTP/1.1\r\n"
|
||||
"Host: localhost\r\n"
|
||||
"\r\n";
|
||||
std::string resp1;
|
||||
ASSERT_TRUE(send_request_and_read_response(req1, resp1));
|
||||
EXPECT_NE(std::string::npos, resp1.find("HTTP/1.1 204"));
|
||||
|
||||
std::string req2 = "DELETE /items/2 HTTP/1.1\r\n"
|
||||
"Host: localhost\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n";
|
||||
std::string resp2;
|
||||
ASSERT_TRUE(send_request_and_read_response(req2, resp2));
|
||||
EXPECT_NE(std::string::npos, resp2.find("HTTP/1.1 204"));
|
||||
|
||||
EXPECT_EQ(2, delete_count.load());
|
||||
}
|
||||
|
||||
@@ -198,6 +198,63 @@ TEST(ThreadPoolTest, InvalidMaxThreadsThrows) {
|
||||
}
|
||||
#endif
|
||||
|
||||
// Issue #2444: ThreadPool constructor must be exception-safe when std::thread
|
||||
// construction fails partway (e.g., pthread_create returns EAGAIN under thread
|
||||
// resource pressure). Without proper handling, the partially-built threads_
|
||||
// vector destroys joinable std::thread objects, calling std::terminate().
|
||||
//
|
||||
// We reproduce the failure portably by interposing pthread_create at link
|
||||
// time: while the counter is armed, the first N calls succeed, the rest
|
||||
// return EAGAIN. This is gated to POSIX + exceptions-enabled builds.
|
||||
#ifndef CPPHTTPLIB_NO_EXCEPTIONS
|
||||
#if defined(__unix__) || defined(__APPLE__)
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <pthread.h>
|
||||
|
||||
namespace {
|
||||
// -1 = pass-through (default). >= 0 = number of remaining successful calls
|
||||
// before EAGAIN is returned. Reset to -1 after each test that arms it.
|
||||
std::atomic<int> g_pthread_create_remaining{-1};
|
||||
} // namespace
|
||||
|
||||
extern "C" int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
|
||||
void *(*start_routine)(void *), void *arg) {
|
||||
using fn_t =
|
||||
int (*)(pthread_t *, const pthread_attr_t *, void *(*)(void *), void *);
|
||||
static fn_t real = reinterpret_cast<fn_t>(dlsym(RTLD_NEXT, "pthread_create"));
|
||||
|
||||
int n = g_pthread_create_remaining.load(std::memory_order_relaxed);
|
||||
if (n == 0) { return EAGAIN; }
|
||||
if (n > 0) {
|
||||
g_pthread_create_remaining.fetch_sub(1, std::memory_order_relaxed);
|
||||
}
|
||||
return real(thread, attr, start_routine, arg);
|
||||
}
|
||||
|
||||
TEST(ThreadPoolTest, ConstructorRecoversWhenThreadCreationFails) {
|
||||
// Allow only the first thread to spawn; subsequent pthread_create calls
|
||||
// return EAGAIN, causing std::thread() to throw std::system_error mid-loop.
|
||||
g_pthread_create_remaining.store(1);
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
ThreadPool pool(/*n=*/4);
|
||||
(void)pool;
|
||||
} catch (const std::system_error &) { caught = true; } catch (...) {
|
||||
caught = true;
|
||||
}
|
||||
|
||||
// Disarm before any further test runs.
|
||||
g_pthread_create_remaining.store(-1);
|
||||
|
||||
EXPECT_TRUE(caught);
|
||||
}
|
||||
|
||||
#endif // POSIX
|
||||
#endif // CPPHTTPLIB_NO_EXCEPTIONS
|
||||
|
||||
TEST(ThreadPoolTest, EnqueueAfterShutdownReturnsFalse) {
|
||||
ThreadPool pool(2);
|
||||
pool.shutdown();
|
||||
|
||||
Reference in New Issue
Block a user