mirror of
https://github.com/yhirose/cpp-httplib
synced 2026-06-08 18:30:49 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d412e98c62 | |||
| 806fcb8268 | |||
| c2678f0186 | |||
| 0cbeafe6a4 | |||
| 13e866bdb0 | |||
| db6c9ef27b | |||
| 887837c65b | |||
| 3d56762d5c | |||
| 109e331068 | |||
| 2ea632264d | |||
| 511cc02278 | |||
| f50bd311fb | |||
| b0866cff8f | |||
| 5ebbfeef0b | |||
| d14e4fc05f |
+106
-6
@@ -25,7 +25,9 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GTEST_FILTER: ${{ github.event.inputs.gtest_filter || '*' }}
|
||||
# Exclude *_Online tests by default — they hit external services and flake on
|
||||
# CI runners. Run with workflow_dispatch + a custom filter to include them.
|
||||
GTEST_FILTER: ${{ github.event.inputs.gtest_filter || '-*_Online' }}
|
||||
|
||||
jobs:
|
||||
style-check:
|
||||
@@ -75,6 +77,7 @@ jobs:
|
||||
github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name) ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.test_linux == 'true')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
tls_backend: [openssl, mbedtls, wolfssl]
|
||||
name: ubuntu (${{ matrix.tls_backend }})
|
||||
@@ -101,7 +104,10 @@ jobs:
|
||||
LSAN_OPTIONS: suppressions=lsan_suppressions.txt
|
||||
- name: build and run tests (Mbed TLS)
|
||||
if: matrix.tls_backend == 'mbedtls'
|
||||
run: cd test && make test_split_mbedtls && make test_mbedtls_parallel
|
||||
# Run mbedTLS shards with reduced parallelism — under ASAN+mbedTLS the
|
||||
# default 4 shards overload CI runners enough that timing-sensitive
|
||||
# ServerTest cases flake on first-request keep-alive reuse.
|
||||
run: cd test && make test_split_mbedtls && SHARDS=2 make test_mbedtls_parallel
|
||||
- name: build and run tests (wolfSSL)
|
||||
if: matrix.tls_backend == 'wolfssl'
|
||||
run: cd test && make test_split_wolfssl && make test_wolfssl_parallel
|
||||
@@ -114,6 +120,92 @@ jobs:
|
||||
- name: build and run ThreadPool test
|
||||
run: cd test && make test_thread_pool && ./test_thread_pool
|
||||
|
||||
# Reproducer for https://github.com/yhirose/cpp-httplib/issues/2431.
|
||||
# On Linux/glibc, getaddrinfo_with_timeout() schedules an asynchronous
|
||||
# DNS lookup with getaddrinfo_a(GAI_NOWAIT) using a stack-local gaicb.
|
||||
# When gai_suspend() hits the connection timeout, gai_cancel() is called
|
||||
# but does not block; the resolver worker can later write back into the
|
||||
# destroyed stack frame. To make the worker actually reach that write,
|
||||
# the test job runs a loopback UDP responder (test/dns_test_fixture.py)
|
||||
# that delays its reply past the test's 1s timeout, and uses an iptables
|
||||
# NAT rule so glibc's lookups land on that fixture instead of a real
|
||||
# nameserver. With ASAN's detect_stack_use_after_return enabled, the
|
||||
# late write-back is reported as a stack-use-after-return.
|
||||
issue-2431-repro:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
(github.event_name == 'push') ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name) ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.test_linux == 'true')
|
||||
name: issue-2431 repro (Linux + ASAN)
|
||||
# Bound the whole job in case anything in the test harness hangs
|
||||
# unexpectedly. With the fixture in place a normal run is well under
|
||||
# a minute either way (ASAN abort on broken HEAD, clean pass on fix).
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
DNS_FIXTURE_PORT: "15353"
|
||||
DNS_FIXTURE_DELAY: "3"
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: install libraries
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libssl-dev zlib1g-dev libbrotli-dev \
|
||||
libzstd-dev libcurl4-openssl-dev iptables util-linux iproute2
|
||||
- name: start loopback DNS test fixture
|
||||
run: |
|
||||
# Force glibc through its DNS code path: Ubuntu's default
|
||||
# nsswitch short-circuits to NOTFOUND through mdns4_minimal,
|
||||
# which would skip the buggy code entirely.
|
||||
sudo sed -i 's/^hosts:.*/hosts: dns/' /etc/nsswitch.conf
|
||||
# Run the loopback fixture (delayed UDP responder).
|
||||
python3 test/dns_test_fixture.py "$DNS_FIXTURE_PORT" "$DNS_FIXTURE_DELAY" \
|
||||
>/tmp/dns_fixture.log 2>&1 &
|
||||
echo $! | sudo tee /tmp/dns_fixture.pid >/dev/null
|
||||
# Wait for the fixture to start listening.
|
||||
for _ in $(seq 1 50); do
|
||||
if ss -lun "( sport = :$DNS_FIXTURE_PORT )" | grep -q ":$DNS_FIXTURE_PORT"; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
ss -lun "( sport = :$DNS_FIXTURE_PORT )" | grep -q ":$DNS_FIXTURE_PORT" \
|
||||
|| { echo "fixture failed to start"; cat /tmp/dns_fixture.log; exit 1; }
|
||||
# Send the test process's DNS lookups to the loopback fixture.
|
||||
# NAT only the local OUTPUT chain; conntrack handles the reply path.
|
||||
sudo iptables -t nat -I OUTPUT -p udp --dport 53 \
|
||||
-j REDIRECT --to-port "$DNS_FIXTURE_PORT"
|
||||
# Sanity check: a query must take at least the fixture delay
|
||||
# and resolve to NXDOMAIN (proving traffic reaches the fixture).
|
||||
start=$(date +%s)
|
||||
getent hosts unresolvable-host.invalid >/dev/null 2>&1 || true
|
||||
elapsed=$(( $(date +%s) - start ))
|
||||
if [ "$elapsed" -lt 2 ]; then
|
||||
echo "ERROR: lookup returned in ${elapsed}s; fixture not in path" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[ok] DNS lookups are routed to the test fixture (took ${elapsed}s)"
|
||||
- name: build test binary
|
||||
run: cd test && make test
|
||||
- name: run GetAddrInfoAsyncCancelTest
|
||||
run: |
|
||||
cd test
|
||||
ARCH=$(uname -m)
|
||||
CPPHTTPLIB_TEST_ISSUE_2431=1 \
|
||||
ASAN_OPTIONS=detect_stack_use_after_return=1 \
|
||||
LSAN_OPTIONS=suppressions=lsan_suppressions.txt \
|
||||
setarch "$ARCH" -R \
|
||||
./test --gtest_filter='GetAddrInfoAsyncCancelTest.*'
|
||||
- name: tear down test fixture
|
||||
if: always()
|
||||
run: |
|
||||
sudo iptables -t nat -F OUTPUT || true
|
||||
if [ -f /tmp/dns_fixture.pid ]; then
|
||||
sudo kill "$(cat /tmp/dns_fixture.pid)" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
macos:
|
||||
runs-on: macos-latest
|
||||
if: >
|
||||
@@ -122,6 +214,7 @@ jobs:
|
||||
github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name) ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.test_macos == 'true')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
tls_backend: [openssl, mbedtls, wolfssl]
|
||||
name: macos (${{ matrix.tls_backend }})
|
||||
@@ -141,7 +234,10 @@ jobs:
|
||||
LSAN_OPTIONS: suppressions=lsan_suppressions.txt
|
||||
- name: build and run tests (Mbed TLS)
|
||||
if: matrix.tls_backend == 'mbedtls'
|
||||
run: cd test && make test_split_mbedtls && make test_mbedtls_parallel
|
||||
# macOS runners under ASAN+mbedTLS still flake at SHARDS=2 (rapid
|
||||
# bind/connect on the fixture's fixed port races on the slower
|
||||
# macos-latest runner). Serialize fully here; ubuntu stays at 2.
|
||||
run: cd test && make test_split_mbedtls && SHARDS=1 make test_mbedtls_parallel
|
||||
- name: build and run tests (wolfSSL)
|
||||
if: matrix.tls_backend == 'wolfssl'
|
||||
run: cd test && make test_split_wolfssl && make test_wolfssl_parallel
|
||||
@@ -162,6 +258,7 @@ jobs:
|
||||
github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name) ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.test_windows == 'true')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
config:
|
||||
- with_ssl: false
|
||||
@@ -228,7 +325,7 @@ jobs:
|
||||
for ($i = 0; $i -lt $shards; $i++) {
|
||||
$log = "shard_${i}.log"
|
||||
$procs += Start-Process -FilePath ./Release/httplib-test.exe `
|
||||
-ArgumentList "--gtest_color=yes","--gtest_filter=${{ github.event.inputs.gtest_filter || '*' }}" `
|
||||
-ArgumentList "--gtest_color=yes","--gtest_filter=${{ github.event.inputs.gtest_filter || '-*_Online' }}" `
|
||||
-NoNewWindow -PassThru -RedirectStandardOutput $log -RedirectStandardError "${log}.err" `
|
||||
-Environment @{ GTEST_TOTAL_SHARDS="$shards"; GTEST_SHARD_INDEX="$i" }
|
||||
}
|
||||
@@ -236,11 +333,14 @@ jobs:
|
||||
$failed = $false
|
||||
for ($i = 0; $i -lt $shards; $i++) {
|
||||
$log = "shard_${i}.log"
|
||||
if (Select-String -Path $log -Pattern "\[ PASSED \]" -Quiet) {
|
||||
$proc = $procs[$i]
|
||||
$hasPassed = Select-String -Path $log -Pattern "\[ PASSED \]" -Quiet
|
||||
$hasFailed = Select-String -Path $log -Pattern "\[ FAILED \]" -Quiet
|
||||
if ($hasPassed -and -not $hasFailed -and $proc.ExitCode -eq 0) {
|
||||
$passed = (Select-String -Path $log -Pattern "\[ PASSED \]").Line
|
||||
Write-Host "Shard ${i}: $passed"
|
||||
} else {
|
||||
Write-Host "=== Shard $i FAILED ==="
|
||||
Write-Host "=== Shard $i FAILED (exit=$($proc.ExitCode)) ==="
|
||||
Get-Content $log
|
||||
if (Test-Path "${log}.err") { Get-Content "${log}.err" }
|
||||
$failed = $true
|
||||
|
||||
@@ -4,7 +4,7 @@ langs = ["en", "ja"]
|
||||
|
||||
[site]
|
||||
title = "cpp-httplib"
|
||||
version = "0.43.1"
|
||||
version = "0.43.2"
|
||||
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.1"
|
||||
#define CPPHTTPLIB_VERSION_NUM "0x002b01"
|
||||
#define CPPHTTPLIB_VERSION "0.43.2"
|
||||
#define CPPHTTPLIB_VERSION_NUM "0x002b02"
|
||||
|
||||
#ifdef _WIN32
|
||||
#if defined(_WIN32_WINNT) && _WIN32_WINNT < 0x0A00
|
||||
@@ -5319,8 +5319,9 @@ inline bool mmap::open(const char *path) {
|
||||
auto wpath = u8string_to_wstring(path);
|
||||
if (wpath.empty()) { return false; }
|
||||
|
||||
hFile_ = ::CreateFile2(wpath.c_str(), GENERIC_READ, FILE_SHARE_READ,
|
||||
OPEN_EXISTING, NULL);
|
||||
hFile_ =
|
||||
::CreateFile2(wpath.c_str(), GENERIC_READ,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE, OPEN_EXISTING, NULL);
|
||||
|
||||
if (hFile_ == INVALID_HANDLE_VALUE) { return false; }
|
||||
|
||||
@@ -5907,56 +5908,50 @@ inline int getaddrinfo_with_timeout(const char *node, const char *service,
|
||||
return 0;
|
||||
#elif defined(_GNU_SOURCE) && defined(__GLIBC__) && \
|
||||
(__GLIBC__ > 2 || (__GLIBC__ == 2 && __GLIBC_MINOR__ >= 2))
|
||||
// Linux implementation using getaddrinfo_a for asynchronous DNS resolution
|
||||
struct gaicb request;
|
||||
// #2431: gai_cancel() is non-blocking and may return EAI_NOTCANCELED while
|
||||
// the resolver worker still references the stack-local gaicb. The cancel
|
||||
// path therefore waits (gai_suspend with no timeout) for the worker to
|
||||
// actually finish before letting the stack frame go. The trade-off is that
|
||||
// a wedged DNS server can hold this thread for the system resolver timeout
|
||||
// (~30s by default) past the caller's connection timeout.
|
||||
struct gaicb request {};
|
||||
struct gaicb *requests[1] = {&request};
|
||||
struct sigevent sevp;
|
||||
struct timespec timeout;
|
||||
struct sigevent sevp {};
|
||||
struct timespec timeout {
|
||||
timeout_sec, 0
|
||||
};
|
||||
|
||||
// Initialize the request structure
|
||||
memset(&request, 0, sizeof(request));
|
||||
request.ar_name = node;
|
||||
request.ar_service = service;
|
||||
request.ar_request = hints;
|
||||
|
||||
// Set up timeout
|
||||
timeout.tv_sec = timeout_sec;
|
||||
timeout.tv_nsec = 0;
|
||||
|
||||
// Initialize sigevent structure (not used, but required)
|
||||
memset(&sevp, 0, sizeof(sevp));
|
||||
sevp.sigev_notify = SIGEV_NONE;
|
||||
|
||||
// Start asynchronous resolution
|
||||
int start_result = getaddrinfo_a(GAI_NOWAIT, requests, 1, &sevp);
|
||||
if (start_result != 0) { return start_result; }
|
||||
int rc = getaddrinfo_a(GAI_NOWAIT, requests, 1, &sevp);
|
||||
if (rc != 0) { return rc; }
|
||||
|
||||
// Wait for completion with timeout
|
||||
int wait_result =
|
||||
gai_suspend((const struct gaicb *const *)requests, 1, &timeout);
|
||||
auto cleanup = scope_exit([&] {
|
||||
if (request.ar_result) { freeaddrinfo(request.ar_result); }
|
||||
});
|
||||
|
||||
int wait_result = gai_suspend(requests, 1, &timeout);
|
||||
|
||||
if (wait_result == 0 || wait_result == EAI_ALLDONE) {
|
||||
// Completed successfully, get the result
|
||||
int gai_result = gai_error(&request);
|
||||
if (gai_result == 0) {
|
||||
*res = request.ar_result;
|
||||
request.ar_result = nullptr;
|
||||
return 0;
|
||||
} else {
|
||||
// Clean up on error
|
||||
if (request.ar_result) { freeaddrinfo(request.ar_result); }
|
||||
return gai_result;
|
||||
}
|
||||
} else if (wait_result == EAI_AGAIN) {
|
||||
// Timeout occurred, cancel the request
|
||||
gai_cancel(&request);
|
||||
return EAI_AGAIN;
|
||||
} else {
|
||||
// Other error occurred
|
||||
gai_cancel(&request);
|
||||
return wait_result;
|
||||
return gai_result;
|
||||
}
|
||||
|
||||
gai_cancel(&request);
|
||||
while (gai_error(&request) == EAI_INPROGRESS) {
|
||||
gai_suspend(requests, 1, nullptr);
|
||||
}
|
||||
return wait_result;
|
||||
#else
|
||||
// Fallback implementation using thread-based timeout for other Unix systems
|
||||
// Fallback implementation using thread-based timeout for other Unix systems.
|
||||
|
||||
struct GetAddrInfoState {
|
||||
~GetAddrInfoState() {
|
||||
@@ -17997,6 +17992,9 @@ inline ssize_t read(session_t session, void *buf, size_t len, TlsError &err) {
|
||||
err.code = impl::map_mbedtls_error(ret, err.sys_errno);
|
||||
err.backend_code = static_cast<uint64_t>(-ret);
|
||||
impl::mbedtls_last_error() = ret;
|
||||
// mbedTLS signals a clean close_notify via a negative error code rather
|
||||
// than 0; surface it as a clean EOF the way OpenSSL/wolfSSL do.
|
||||
if (err.code == ErrorCode::PeerClosed) { return 0; }
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
+16
-9
@@ -67,22 +67,29 @@ SHARDS ?= 4
|
||||
|
||||
define run_parallel
|
||||
@echo "Running $(1) with $(SHARDS) shards in parallel..."
|
||||
@fail=0; \
|
||||
@fail=0; pids=""; \
|
||||
for i in $$(seq 0 $$(($(SHARDS) - 1))); do \
|
||||
GTEST_TOTAL_SHARDS=$(SHARDS) GTEST_SHARD_INDEX=$$i \
|
||||
LSAN_OPTIONS=suppressions=lsan_suppressions.txt \
|
||||
$(SETARCH) ./$(1) --gtest_color=yes > $(1)_shard_$$i.log 2>&1 & \
|
||||
pids="$$pids $$!"; \
|
||||
done; \
|
||||
wait; \
|
||||
for i in $$(seq 0 $$(($(SHARDS) - 1))); do \
|
||||
if ! grep -q "\[ PASSED \]" $(1)_shard_$$i.log; then \
|
||||
echo "=== Shard $$i FAILED ==="; \
|
||||
cat $(1)_shard_$$i.log; \
|
||||
fail=1; \
|
||||
else \
|
||||
passed=$$(grep "\[ PASSED \]" $(1)_shard_$$i.log); \
|
||||
exits=""; \
|
||||
for pid in $$pids; do \
|
||||
wait $$pid; exits="$$exits $$?"; \
|
||||
done; \
|
||||
i=0; \
|
||||
for ec in $$exits; do \
|
||||
log=$(1)_shard_$$i.log; \
|
||||
if grep -q "\[ PASSED \]" $$log && ! grep -q "\[ FAILED \]" $$log && [ $$ec -eq 0 ]; then \
|
||||
passed=$$(grep "\[ PASSED \]" $$log); \
|
||||
echo "Shard $$i: $$passed"; \
|
||||
else \
|
||||
echo "=== Shard $$i FAILED (exit=$$ec) ==="; \
|
||||
cat $$log; \
|
||||
fail=1; \
|
||||
fi; \
|
||||
i=$$((i+1)); \
|
||||
done; \
|
||||
if [ $$fail -ne 0 ]; then exit 1; fi; \
|
||||
echo "All shards passed."
|
||||
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Delayed UDP responder used as a loopback test fixture.
|
||||
|
||||
This is a self-contained test fixture for the GetAddrInfoAsyncCancelTest
|
||||
cases (reproducer for cpp-httplib issue #2431). It is NOT a general-purpose
|
||||
nameserver and is only intended to run on 127.0.0.1 inside the test job's
|
||||
own runner / container.
|
||||
|
||||
What it does
|
||||
------------
|
||||
Binds a UDP socket on 127.0.0.1:<port>, accepts well-formed DNS queries
|
||||
from the test process, waits <delay_seconds>, then sends back a minimal
|
||||
NXDOMAIN reply. The deliberate delay is what makes the bug reproducible:
|
||||
|
||||
* The test calls getaddrinfo_with_timeout() with timeout_sec=1.
|
||||
* gai_suspend() returns EAI_AGAIN after 1s; the function returns and
|
||||
its stack frame is destroyed.
|
||||
* The fixture replies after <delay_seconds> (= 3s by default), so the
|
||||
glibc resolver worker thread receives the response *after* the
|
||||
caller's frame is gone and writes back into freed stack memory.
|
||||
* AddressSanitizer (with detect_stack_use_after_return=1) catches the
|
||||
write and aborts with a stack-use-after-return diagnostic.
|
||||
|
||||
Without this fixture the bug is hard to surface: dropping UDP/53 makes
|
||||
the resolver hang forever, so the worker never receives anything and
|
||||
never reaches the buggy write-back path.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python3 test/dns_test_fixture.py <port> [<delay_seconds>]
|
||||
|
||||
Only standard library; no third-party dependencies.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def serve(port: int, delay_sec: float) -> None:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(("127.0.0.1", port))
|
||||
print(
|
||||
f"[dns_test_fixture] listening on 127.0.0.1:{port}, "
|
||||
f"reply delay={delay_sec}s",
|
||||
flush=True,
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
data, addr = sock.recvfrom(2048)
|
||||
except OSError:
|
||||
return
|
||||
threading.Thread(
|
||||
target=_reply_after_delay,
|
||||
args=(sock, data, addr, delay_sec),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
|
||||
def _reply_after_delay(sock, query: bytes, addr, delay_sec: float) -> None:
|
||||
time.sleep(delay_sec)
|
||||
if len(query) < 12:
|
||||
return
|
||||
# Header: copy transaction id, set QR=1 RA=1 RCODE=3 (NXDOMAIN),
|
||||
# preserve the requester's RD bit, then echo the question section so
|
||||
# glibc's resolver accepts the reply as matching its outstanding query.
|
||||
txid = query[:2]
|
||||
rd_bit = query[2] & 0x01
|
||||
flags = struct.pack(">H", 0x8003 | (rd_bit << 8))
|
||||
counts = struct.pack(">HHHH", 1, 0, 0, 0)
|
||||
question = query[12:]
|
||||
reply = txid + flags + counts + question
|
||||
try:
|
||||
sock.sendto(reply, addr)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__, file=sys.stderr)
|
||||
sys.exit(2)
|
||||
port_arg = int(sys.argv[1])
|
||||
delay_arg = float(sys.argv[2]) if len(sys.argv) > 2 else 3.0
|
||||
serve(port_arg, delay_arg)
|
||||
@@ -13,7 +13,7 @@ ZLIB_SUPPORT = -DCPPHTTPLIB_ZLIB_SUPPORT -lz
|
||||
BROTLI_DIR = /usr/local/opt/brotli
|
||||
# BROTLI_SUPPORT = -DCPPHTTPLIB_BROTLI_SUPPORT -I$(BROTLI_DIR)/include -L$(BROTLI_DIR)/lib -lbrotlicommon -lbrotlienc -lbrotlidec
|
||||
|
||||
FUZZERS = server_fuzzer url_parser_fuzzer header_parser_fuzzer
|
||||
FUZZERS = server_fuzzer url_parser_fuzzer header_parser_fuzzer client_fuzzer
|
||||
|
||||
# Runs all the tests and also fuzz tests against seed corpus.
|
||||
all : $(FUZZERS)
|
||||
@@ -25,6 +25,10 @@ server_fuzzer : server_fuzzer.cc ../../httplib.h
|
||||
$(CXX) $(CXXFLAGS) -o $@ $< $(ZLIB_SUPPORT) $(LIB_FUZZING_ENGINE) -pthread -lanl
|
||||
zip -q -r server_fuzzer_seed_corpus.zip corpus
|
||||
|
||||
client_fuzzer : client_fuzzer.cc ../../httplib.h
|
||||
$(CXX) $(CXXFLAGS) -o $@ $< $(ZLIB_SUPPORT) $(LIB_FUZZING_ENGINE) -pthread -lanl
|
||||
zip -q -r client_fuzzer_seed_corpus.zip corpus
|
||||
|
||||
header_parser_fuzzer : header_parser_fuzzer.cc ../../httplib.h
|
||||
$(CXX) $(CXXFLAGS) -o $@ $< $(ZLIB_SUPPORT) $(LIB_FUZZING_ENGINE) -pthread -lanl
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <httplib.h>
|
||||
|
||||
class FuzzedStream : public httplib::Stream {
|
||||
public:
|
||||
FuzzedStream(const uint8_t *data, size_t size)
|
||||
: data_(data), size_(size), read_pos_(0) {}
|
||||
|
||||
ssize_t read(char *ptr, size_t size) override {
|
||||
if (size + read_pos_ > size_) { size = size_ - read_pos_; }
|
||||
memcpy(ptr, data_ + read_pos_, size);
|
||||
read_pos_ += size;
|
||||
return static_cast<ssize_t>(size);
|
||||
}
|
||||
|
||||
ssize_t write(const char *ptr, size_t size) override {
|
||||
request_.append(ptr, size);
|
||||
return static_cast<ssize_t>(size);
|
||||
}
|
||||
|
||||
ssize_t write(const char *ptr) { return write(ptr, strlen(ptr)); }
|
||||
|
||||
ssize_t write(const std::string &s) { return write(s.data(), s.size()); }
|
||||
|
||||
bool is_readable() const override { return true; }
|
||||
|
||||
bool wait_readable() const override { return true; }
|
||||
|
||||
bool wait_writable() const override { return true; }
|
||||
|
||||
void get_remote_ip_and_port(std::string &ip, int &port) const override {
|
||||
ip = "127.0.0.1";
|
||||
port = 8080;
|
||||
}
|
||||
|
||||
void get_local_ip_and_port(std::string &ip, int &port) const override {
|
||||
ip = "127.0.0.1";
|
||||
port = 8080;
|
||||
}
|
||||
|
||||
socket_t socket() const override { return 0; }
|
||||
|
||||
time_t duration() const override { return 0; };
|
||||
|
||||
private:
|
||||
const uint8_t *data_;
|
||||
size_t size_;
|
||||
size_t read_pos_;
|
||||
std::string request_;
|
||||
};
|
||||
|
||||
class FuzzableClient : public httplib::ClientImpl {
|
||||
public:
|
||||
FuzzableClient() : httplib::ClientImpl("localhost", 8080) {}
|
||||
|
||||
void ProcessFuzzedResponse(FuzzedStream &stream, const std::string &method) {
|
||||
httplib::Request req;
|
||||
req.method = method;
|
||||
req.path = "/";
|
||||
httplib::Response res;
|
||||
bool close_connection = false;
|
||||
httplib::Error error = httplib::Error::Success;
|
||||
|
||||
process_request(stream, req, res, close_connection, error);
|
||||
}
|
||||
};
|
||||
|
||||
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
|
||||
if (size < 1) return 0;
|
||||
|
||||
FuzzedStream stream{data + 1, size - 1};
|
||||
FuzzableClient client;
|
||||
|
||||
// Use the first byte to select method
|
||||
std::string method;
|
||||
switch (data[0] % 6) {
|
||||
case 0: method = "GET"; break;
|
||||
case 1: method = "POST"; break;
|
||||
case 2: method = "PUT"; break;
|
||||
case 3: method = "PATCH"; break;
|
||||
case 4: method = "DELETE"; break;
|
||||
case 5: method = "OPTIONS"; break;
|
||||
}
|
||||
|
||||
client.ProcessFuzzedResponse(stream, method);
|
||||
return 0;
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
# OpenSSL 3.x internal caches (provider, cipher, keymgmt) are allocated
|
||||
# lazily and intentionally kept until process exit. These are not real leaks.
|
||||
leak:libcrypto
|
||||
|
||||
# wolfSSL keeps ECC point/scratch buffers alive across handshakes; they are
|
||||
# released only at library shutdown which the test binaries do not invoke.
|
||||
leak:libwolfssl
|
||||
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reproducer runner for Issue #2431
|
||||
# (https://github.com/yhirose/cpp-httplib/issues/2431).
|
||||
#
|
||||
# Spins up an Ubuntu container, runs the loopback DNS test fixture
|
||||
# (test/dns_test_fixture.py), routes the container's DNS lookups to
|
||||
# that fixture via an iptables NAT rule, builds the test suite with
|
||||
# g++ + ASAN, and runs the GetAddrInfoAsyncCancelTest cases.
|
||||
#
|
||||
# Expected outcomes:
|
||||
# - HEAD prior to the fix: ASAN reports stack-use-after-return inside
|
||||
# getaddrinfo_with_timeout's getaddrinfo_a path during one of the
|
||||
# GetAddrInfoAsyncCancelTest cases.
|
||||
# - HEAD with the fix applied: all three cases PASS.
|
||||
#
|
||||
# Usage:
|
||||
# bash test/run_issue_2431_repro.sh
|
||||
#
|
||||
# Requirements: Docker (Linux container support). The container needs
|
||||
# --privileged because the test binary uses `setarch -R` to disable ASLR
|
||||
# for ASAN compatibility, and because the test job manages iptables
|
||||
# rules inside the container.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
docker run --rm --privileged \
|
||||
-v "$REPO_ROOT:/work" \
|
||||
-w /work/test \
|
||||
ubuntu:24.04 bash -c '
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends \
|
||||
ca-certificates g++ make pkg-config iptables iproute2 util-linux coreutils file \
|
||||
python3 \
|
||||
libssl-dev zlib1g-dev libbrotli-dev libzstd-dev libcurl4-openssl-dev \
|
||||
>/dev/null
|
||||
|
||||
# Force DNS-only resolution: Ubuntu defaults nsswitch.conf to
|
||||
# "hosts: files mdns4_minimal [NOTFOUND=return] dns ...", which
|
||||
# short-circuits to NOTFOUND before reaching glibc DNS code, so the
|
||||
# gai_cancel() branch never gets exercised.
|
||||
sed -i "s/^hosts:.*/hosts: dns/" /etc/nsswitch.conf
|
||||
|
||||
# Start the loopback DNS test fixture (delayed UDP responder).
|
||||
DNS_FIXTURE_PORT=15353
|
||||
DNS_FIXTURE_DELAY=3
|
||||
python3 /work/test/dns_test_fixture.py "$DNS_FIXTURE_PORT" "$DNS_FIXTURE_DELAY" \
|
||||
>/tmp/dns_fixture.log 2>&1 &
|
||||
FIXTURE_PID=$!
|
||||
|
||||
# Route the container DNS lookups to the fixture; conntrack handles the
|
||||
# reply path automatically. /etc/resolv.conf is left untouched.
|
||||
iptables -t nat -I OUTPUT -p udp --dport 53 \
|
||||
-j REDIRECT --to-port "$DNS_FIXTURE_PORT"
|
||||
|
||||
trap '"'"'iptables -t nat -F OUTPUT 2>/dev/null || true; kill "$FIXTURE_PID" 2>/dev/null || true'"'"' EXIT
|
||||
|
||||
# Wait for the fixture to start listening.
|
||||
for _ in $(seq 1 50); do
|
||||
if ss -lun "( sport = :$DNS_FIXTURE_PORT )" | grep -q ":$DNS_FIXTURE_PORT"; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
ss -lun "( sport = :$DNS_FIXTURE_PORT )" | grep -q ":$DNS_FIXTURE_PORT" || {
|
||||
echo "ERROR: dns_test_fixture failed to start" >&2
|
||||
cat /tmp/dns_fixture.log >&2 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Sanity check: a DNS lookup must take at least the fixture delay
|
||||
# (proving the NAT rule routes the query to the fixture).
|
||||
start=$(date +%s)
|
||||
getent hosts unresolvable-host.invalid >/dev/null 2>&1 || true
|
||||
elapsed=$(( $(date +%s) - start ))
|
||||
if [ "$elapsed" -lt 2 ]; then
|
||||
echo "ERROR: lookup returned in ${elapsed}s; fixture not in DNS path" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[ok] DNS lookups are routed to the test fixture (took ${elapsed}s)"
|
||||
|
||||
cd /work/test
|
||||
echo "=== building test binary (g++ + ASAN) ==="
|
||||
make CXX=g++ test 2>&1 | tail -5
|
||||
|
||||
ARCH=$(uname -m)
|
||||
echo "=== running GetAddrInfoAsyncCancelTest with CPPHTTPLIB_TEST_ISSUE_2431=1 ==="
|
||||
set +e
|
||||
CPPHTTPLIB_TEST_ISSUE_2431=1 \
|
||||
ASAN_OPTIONS=detect_stack_use_after_return=1 \
|
||||
setarch "$ARCH" -R \
|
||||
./test --gtest_filter="GetAddrInfoAsyncCancelTest.*" 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
echo "=== test exit: $rc ==="
|
||||
exit $rc
|
||||
'
|
||||
+161
-2
@@ -1549,6 +1549,140 @@ TEST(GetAddrInfoDanglingRefTest, LongTimeout) {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(8));
|
||||
}
|
||||
|
||||
#if defined(__linux__) && defined(__GLIBC__) && \
|
||||
defined(CPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO)
|
||||
|
||||
// Forward declaration: in split builds split.py strips `inline` and moves the
|
||||
// definition into httplib.cc, so detail::getaddrinfo_with_timeout is not
|
||||
// visible from the public httplib.h. Re-declaring it here lets the tests link
|
||||
// against the symbol in both header-only and split builds.
|
||||
namespace httplib {
|
||||
namespace detail {
|
||||
int getaddrinfo_with_timeout(const char *node, const char *service,
|
||||
const struct addrinfo *hints,
|
||||
struct addrinfo **res, time_t timeout_sec);
|
||||
} // namespace detail
|
||||
} // namespace httplib
|
||||
|
||||
// Reproducer for https://github.com/yhirose/cpp-httplib/issues/2431.
|
||||
//
|
||||
// On Linux/glibc, getaddrinfo_with_timeout() runs the lookup via
|
||||
// getaddrinfo_a(GAI_NOWAIT) using a stack-local `struct gaicb`. When the
|
||||
// gai_suspend() call hits the connection timeout the function calls
|
||||
// gai_cancel() and returns immediately. gai_cancel() is non-blocking and
|
||||
// can return EAI_NOTCANCELED, in which case the resolver worker thread is
|
||||
// still alive and still references the now-destroyed stack frame.
|
||||
//
|
||||
// Triggering the bug requires DNS to actually hang (UDP/53 dropped, etc.),
|
||||
// so these tests are gated on CPPHTTPLIB_TEST_ISSUE_2431=1 and are skipped
|
||||
// during normal runs. test/run_issue_2431_repro.sh sets up the environment
|
||||
// and runs them in a container.
|
||||
namespace {
|
||||
bool should_run_issue_2431_tests() {
|
||||
const char *v = getenv("CPPHTTPLIB_TEST_ISSUE_2431");
|
||||
return v && *v && std::string(v) != "0";
|
||||
}
|
||||
|
||||
std::string unique_unresolvable_host(int n) {
|
||||
// .invalid is reserved (RFC 6761) and is never served by real DNS, but
|
||||
// glibc still asks the configured nameserver — which is exactly the path
|
||||
// we want to exercise. A unique label per call avoids the resolver cache.
|
||||
auto t = std::chrono::steady_clock::now().time_since_epoch().count();
|
||||
return "h-" + std::to_string(::getpid()) + "-" + std::to_string(t) + "-" +
|
||||
std::to_string(n) + ".invalid";
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST(GetAddrInfoAsyncCancelTest, DirectCallSingleThread) {
|
||||
if (!should_run_issue_2431_tests()) {
|
||||
GTEST_SKIP()
|
||||
<< "Set CPPHTTPLIB_TEST_ISSUE_2431=1 (and sinkhole DNS) to run";
|
||||
}
|
||||
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
struct addrinfo hints;
|
||||
memset(&hints, 0, sizeof(hints));
|
||||
hints.ai_family = AF_UNSPEC;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
|
||||
auto host = unique_unresolvable_host(i);
|
||||
struct addrinfo *result = nullptr;
|
||||
int rc = detail::getaddrinfo_with_timeout(host.c_str(), "80", &hints,
|
||||
&result, /*timeout_sec=*/1);
|
||||
if (rc == 0 && result) { freeaddrinfo(result); }
|
||||
}
|
||||
|
||||
// Give orphaned getaddrinfo_a worker threads a chance to write into the
|
||||
// stack region they still believe holds their gaicb.
|
||||
std::this_thread::sleep_for(std::chrono::seconds(3));
|
||||
}
|
||||
|
||||
TEST(GetAddrInfoAsyncCancelTest, DirectCallMultiThread) {
|
||||
if (!should_run_issue_2431_tests()) {
|
||||
GTEST_SKIP()
|
||||
<< "Set CPPHTTPLIB_TEST_ISSUE_2431=1 (and sinkhole DNS) to run";
|
||||
}
|
||||
|
||||
std::atomic<bool> stop{false};
|
||||
std::vector<std::thread> threads;
|
||||
for (int t = 0; t < 8; ++t) {
|
||||
threads.emplace_back([t, &stop] {
|
||||
int i = 0;
|
||||
while (!stop.load(std::memory_order_relaxed)) {
|
||||
struct addrinfo hints;
|
||||
memset(&hints, 0, sizeof(hints));
|
||||
hints.ai_family = AF_UNSPEC;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
|
||||
auto host = unique_unresolvable_host(t * 100000 + i++);
|
||||
struct addrinfo *result = nullptr;
|
||||
int rc = detail::getaddrinfo_with_timeout(host.c_str(), "80", &hints,
|
||||
&result, /*timeout_sec=*/1);
|
||||
if (rc == 0 && result) { freeaddrinfo(result); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::seconds(8));
|
||||
stop.store(true, std::memory_order_relaxed);
|
||||
for (auto &th : threads) {
|
||||
th.join();
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::seconds(3));
|
||||
}
|
||||
|
||||
TEST(GetAddrInfoAsyncCancelTest, ClientGetMultiThread) {
|
||||
if (!should_run_issue_2431_tests()) {
|
||||
GTEST_SKIP()
|
||||
<< "Set CPPHTTPLIB_TEST_ISSUE_2431=1 (and sinkhole DNS) to run";
|
||||
}
|
||||
|
||||
std::atomic<bool> stop{false};
|
||||
std::vector<std::thread> threads;
|
||||
for (int t = 0; t < 8; ++t) {
|
||||
threads.emplace_back([t, &stop] {
|
||||
int i = 0;
|
||||
while (!stop.load(std::memory_order_relaxed)) {
|
||||
auto host = unique_unresolvable_host(t * 100000 + i++);
|
||||
Client cli(host, 80);
|
||||
cli.set_connection_timeout(1, 0);
|
||||
cli.set_read_timeout(1, 0);
|
||||
cli.set_write_timeout(1, 0);
|
||||
(void)cli.Get("/");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::seconds(8));
|
||||
stop.store(true, std::memory_order_relaxed);
|
||||
for (auto &th : threads) {
|
||||
th.join();
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::seconds(3));
|
||||
}
|
||||
|
||||
#endif // __linux__ && __GLIBC__ && CPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO
|
||||
|
||||
TEST(ConnectionErrorTest, InvalidHost) {
|
||||
auto host = "-abcde.com";
|
||||
|
||||
@@ -7805,6 +7939,31 @@ TEST(MountTest, MultibytesPathName) {
|
||||
EXPECT_EQ(U8("日本語コンテンツ"), res->body);
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
// Issue #2435: mmap::open() must succeed even when another handle holds
|
||||
// the file open for writing (e.g. an active log file).
|
||||
TEST(MmapTest, OpenWhileFileHeldForWriting) {
|
||||
const char *path = "mmap_concurrent_writer_test.txt";
|
||||
const char *content = "hello";
|
||||
|
||||
{
|
||||
std::ofstream f(path, std::ios::binary);
|
||||
f.write(content, static_cast<std::streamsize>(strlen(content)));
|
||||
}
|
||||
auto file_cleanup = detail::scope_exit([&] { std::remove(path); });
|
||||
|
||||
HANDLE writer = ::CreateFileA(path, GENERIC_WRITE, FILE_SHARE_READ, NULL,
|
||||
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
|
||||
ASSERT_NE(INVALID_HANDLE_VALUE, writer);
|
||||
auto handle_cleanup = detail::scope_exit([&] { ::CloseHandle(writer); });
|
||||
|
||||
detail::mmap m(path);
|
||||
ASSERT_TRUE(m.is_open());
|
||||
EXPECT_EQ(strlen(content), m.size());
|
||||
EXPECT_EQ(0, std::memcmp(content, m.data(), strlen(content)));
|
||||
}
|
||||
#endif
|
||||
|
||||
TEST(KeepAliveTest, ReadTimeout) {
|
||||
Server svr;
|
||||
|
||||
@@ -12142,7 +12301,7 @@ TEST(MultipartFormDataTest, ManyItemsEndToEnd) {
|
||||
TEST(MultipartFormDataTest, MakeFileProvider) {
|
||||
// Verify make_file_provider sends a file's contents correctly.
|
||||
const std::string file_content(4096, 'Z');
|
||||
const std::string tmp_path = "/tmp/httplib_test_make_file_provider.bin";
|
||||
const std::string tmp_path = "./httplib_test_make_file_provider.bin";
|
||||
{
|
||||
std::ofstream ofs(tmp_path, std::ios::binary);
|
||||
ofs.write(file_content.data(),
|
||||
@@ -12197,7 +12356,7 @@ TEST(MultipartFormDataTest, MakeFileProvider) {
|
||||
|
||||
TEST(MakeFileBodyTest, Basic) {
|
||||
const std::string file_content(4096, 'Z');
|
||||
const std::string tmp_path = "/tmp/httplib_test_make_file_body.bin";
|
||||
const std::string tmp_path = "./httplib_test_make_file_body.bin";
|
||||
{
|
||||
std::ofstream ofs(tmp_path, std::ios::binary);
|
||||
ofs.write(file_content.data(),
|
||||
|
||||
Reference in New Issue
Block a user