diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 93fbf758..ba7cc50f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -623,10 +623,6 @@ jobs: } if ($failed) { exit 1 } Write-Host "All shards passed." - - name: Run WebSocket heartbeat test ${{ matrix.config.name }} - if: ${{ matrix.config.run_tests }} - working-directory: build/test - run: ./Release/httplib-heartbeat-test.exe --gtest_color=yes env: VCPKG_ROOT: "C:/vcpkg" diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 1ae9cdb6..63b96ab3 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -31,14 +31,6 @@ target_compile_options(httplib-test PRIVATE "$<$:/utf-8;/b target_link_libraries(httplib-test PRIVATE httplib GTest::gtest_main CURL::libcurl) gtest_discover_tests(httplib-test) -# It overrides the heartbeat macros, which a compiled library has fixed -if(NOT HTTPLIB_COMPILE) - add_executable(httplib-heartbeat-test test_websocket_heartbeat.cc) - target_compile_options(httplib-heartbeat-test PRIVATE "$<$:/utf-8;/bigobj>") - target_link_libraries(httplib-heartbeat-test PRIVATE httplib GTest::gtest_main) - gtest_discover_tests(httplib-heartbeat-test) -endif() - file( COPY www www2 www3 ca-bundle.crt image.jpg DESTINATION ${CMAKE_CURRENT_BINARY_DIR} diff --git a/test/test.cc b/test/test.cc index 75c9c80f..d6e495e4 100644 --- a/test/test.cc +++ b/test/test.cc @@ -28414,6 +28414,68 @@ TEST(WebSocketTest, NegativePingIntervalSendsNoPings) { EXPECT_EQ(-1, ::recv(sock, &c, 1, 0)); } +// The client's heartbeat gives up on a peer that leaves its pings unanswered, +// and that has to end a read() already waiting on the peer, on every platform. +static void check_pong_timeout_ends_read(Server &svr, const char *scheme) { + // The peer sends no pings, and its handler never calls read(), which is + // where pongs are written. + std::mutex mutex; + std::condition_variable cv; + auto release = false; + svr.set_websocket_ping_interval(0); + svr.WebSocket("/ws", [&](const Request &, ws::WebSocket &) { + std::unique_lock lock(mutex); + cv.wait(lock, [&] { return release; }); + }); + + auto port = svr.bind_to_any_port(HOST); + std::thread t([&]() { svr.listen_after_bind(); }); + auto se = detail::scope_exit([&] { + { + std::lock_guard lock(mutex); + release = true; + } + cv.notify_all(); + svr.stop(); + t.join(); + }); + svr.wait_until_ready(); + + ws::WebSocketClient client(std::string(scheme) + + "://localhost:" + std::to_string(port) + "/ws"); +#ifdef CPPHTTPLIB_SSL_ENABLED + client.enable_server_certificate_verification(false); +#endif + // A ping a second, given up on after two go unanswered: about 3s + client.set_websocket_ping_interval(1); + client.set_websocket_max_missed_pongs(2); + // Well past that, so that a read the timeout ends instead stands out + client.set_read_timeout(10); + ASSERT_TRUE(client.connect()); + + auto start = std::chrono::steady_clock::now(); + std::string msg; + EXPECT_EQ(ws::Fail, client.read(msg)); + EXPECT_LT(std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(), + 6000); + EXPECT_FALSE(client.is_open()); +} + +TEST(WebSocketTest, PongTimeoutEndsABlockedRead) { + Server svr; + check_pong_timeout_ends_read(svr, "ws"); +} + +#ifdef CPPHTTPLIB_SSL_ENABLED +TEST(WebSocketTest, PongTimeoutEndsABlockedReadOverTLS) { + SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE); + ASSERT_TRUE(svr.is_valid()); + check_pong_timeout_ends_read(svr, "wss"); +} +#endif + TEST(WebSocketTest, AnswersAMalformedCloseWithProtocolError) { Server svr; svr.set_websocket_ping_interval(0); diff --git a/test/test_websocket_heartbeat.cc b/test/test_websocket_heartbeat.cc index 45646fb4..08dbf1a0 100644 --- a/test/test_websocket_heartbeat.cc +++ b/test/test_websocket_heartbeat.cc @@ -139,28 +139,22 @@ TEST_F(WebSocketServerPingIntervalTest, ServerRuntimeInterval) { client.close(); } -// Verify that the client detects a non-responsive peer via unacked-ping count. -// Setup: the server's heartbeat is disabled AND its handler never calls -// read(), so no automatic Pong reply is ever produced. The client sends -// pings but receives no pongs, and should close itself once the unacked -// ping count reaches max_missed_pongs. -class WebSocketPongTimeoutTest : public ::testing::Test { +// A peer that never sends anything: the server's heartbeat is disabled and its +// handler never calls read(). The pong timeout against such a peer is tested +// in test.cc (WebSocketTest.PongTimeoutEndsABlockedRead), where every CI job +// runs it. +class WebSocketSilentPeerTest : public ::testing::Test { protected: - virtual std::unique_ptr make_server() { - return std::unique_ptr(new Server()); - } - void SetUp() override { - svr_ = make_server(); - svr_->set_websocket_ping_interval(0); - svr_->WebSocket("/ws", [this](const Request &, ws::WebSocket &) { + svr_.set_websocket_ping_interval(0); + svr_.WebSocket("/ws", [this](const Request &, ws::WebSocket &) { std::unique_lock lock(handler_mutex_); handler_cv_.wait(lock, [this]() { return release_; }); }); - port_ = svr_->bind_to_any_port("localhost"); - thread_ = std::thread([this]() { svr_->listen_after_bind(); }); - svr_->wait_until_ready(); + port_ = svr_.bind_to_any_port("localhost"); + thread_ = std::thread([this]() { svr_.listen_after_bind(); }); + svr_.wait_until_ready(); } void TearDown() override { @@ -169,11 +163,11 @@ protected: release_ = true; } handler_cv_.notify_all(); - svr_->stop(); + svr_.stop(); thread_.join(); } - std::unique_ptr svr_; + Server svr_; int port_; std::thread thread_; std::mutex handler_mutex_; @@ -181,50 +175,10 @@ protected: bool release_ = false; }; -// Client pings every 1s (compile-time default in this test file). With -// max_missed_pongs = 2, the heartbeat thread should self-close within roughly -// 3s, and that has to end a read() already waiting on the peer. -static void expect_pong_timeout_ends_read(ws::WebSocketClient &client) { - client.set_websocket_max_missed_pongs(2); - // A read timeout asked for at runtime is reported as Timeout, so it cannot - // be mistaken for the Fail a pong timeout produces. - client.set_read_timeout(10); - ASSERT_TRUE(client.connect()); - ASSERT_TRUE(client.is_open()); - - auto start = std::chrono::steady_clock::now(); - std::string msg; - EXPECT_EQ(client.read(msg), ws::Fail); - EXPECT_TRUE(std::chrono::steady_clock::now() - start < - std::chrono::seconds(6)); - EXPECT_FALSE(client.is_open()); -} - -TEST_F(WebSocketPongTimeoutTest, ClientDetectsNonResponsivePeer) { - ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) + "/ws"); - expect_pong_timeout_ends_read(client); -} - -#ifdef CPPHTTPLIB_SSL_ENABLED -class WebSocketPongTimeoutSSLTest : public WebSocketPongTimeoutTest { -protected: - std::unique_ptr make_server() override { - return std::unique_ptr(new SSLServer("./cert.pem", "./key.pem")); - } -}; - -TEST_F(WebSocketPongTimeoutSSLTest, ClientDetectsNonResponsivePeer) { - ws::WebSocketClient client("wss://localhost:" + std::to_string(port_) + - "/ws"); - client.enable_server_certificate_verification(false); - expect_pong_timeout_ends_read(client); -} -#endif - // 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) { +TEST_F(WebSocketSilentPeerTest, CompileTimeClientReadTimeoutIsFail) { ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) + "/ws"); client.set_websocket_ping_interval(0); ASSERT_TRUE(client.connect()); @@ -293,8 +247,9 @@ TEST_F(WebSocketServerReadTimeoutTest, BackstopEndsHandlerLoop) { // 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 -// that incoming Pong frames are consumed and the unacked counter is reset. +// of WebSocketTest.PongTimeoutEndsABlockedRead in test.cc: the client must +// actively drive read() so that incoming Pong frames are consumed and the +// unacked counter is reset. TEST_F(WebSocketHeartbeatTest, ResponsivePeerNeverTimesOut) { ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) + "/ws"); client.set_websocket_max_missed_pongs(2);