mirror of
https://github.com/yhirose/cpp-httplib.git
synced 2026-10-09 12:53:18 +00:00
When max_missed_pongs was exceeded, the heartbeat thread called close(), which returns without touching the socket while another thread is in read(). That read() then stayed blocked until its read timeout: forever by default on a client, 300 seconds on a server. A plain read loop never noticed the unresponsive peer the heartbeat had just detected. Shut down the read side of the socket after close() so the pending read() returns Fail. Only the read side: a TLS backend answers the EOF with an alert, and writing it to a socket closed for writing raises SIGPIPE in a process that has not ignored it. ClientDetectsNonResponsivePeer now waits in read() instead of polling is_open(), which covers both.
312 lines
10 KiB
C++
312 lines
10 KiB
C++
// Standalone test for WebSocket automatic heartbeat.
|
|
// Compiled with a 1-second ping interval so we can verify heartbeat behavior
|
|
// without waiting 30 seconds.
|
|
|
|
#define CPPHTTPLIB_WEBSOCKET_PING_INTERVAL_SECOND 1
|
|
#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 {
|
|
protected:
|
|
void SetUp() override {
|
|
svr_.WebSocket("/ws", [](const Request &, ws::WebSocket &ws) {
|
|
std::string msg;
|
|
while (ws.read(msg)) {
|
|
ws.send(msg);
|
|
}
|
|
});
|
|
|
|
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_;
|
|
};
|
|
|
|
// Verify that an idle connection stays alive beyond the read timeout
|
|
// thanks to automatic heartbeat pings.
|
|
TEST_F(WebSocketHeartbeatTest, IdleConnectionStaysAlive) {
|
|
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) + "/ws");
|
|
ASSERT_TRUE(client.connect());
|
|
|
|
// Sleep longer than read timeout (3s). Without heartbeat, the connection
|
|
// would time out. With heartbeat pings every 1s, it stays alive.
|
|
std::this_thread::sleep_for(std::chrono::seconds(5));
|
|
|
|
// Connection should still be open
|
|
ASSERT_TRUE(client.is_open());
|
|
|
|
// Verify we can still exchange messages
|
|
ASSERT_TRUE(client.send("hello after idle"));
|
|
std::string msg;
|
|
ASSERT_TRUE(client.read(msg));
|
|
EXPECT_EQ("hello after idle", msg);
|
|
|
|
client.close();
|
|
}
|
|
|
|
// Verify that set_websocket_ping_interval overrides the compile-time default
|
|
TEST_F(WebSocketHeartbeatTest, RuntimePingIntervalOverride) {
|
|
// The server is already using the compile-time default (1s).
|
|
// Create a client with a custom runtime interval.
|
|
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) + "/ws");
|
|
client.set_websocket_ping_interval(2);
|
|
ASSERT_TRUE(client.connect());
|
|
|
|
// Sleep longer than read timeout (3s). Client heartbeat at 2s keeps alive.
|
|
std::this_thread::sleep_for(std::chrono::seconds(5));
|
|
|
|
ASSERT_TRUE(client.is_open());
|
|
ASSERT_TRUE(client.send("runtime interval"));
|
|
std::string msg;
|
|
ASSERT_TRUE(client.read(msg));
|
|
EXPECT_EQ("runtime interval", msg);
|
|
|
|
client.close();
|
|
}
|
|
|
|
// Verify that ping_interval=0 disables heartbeat without breaking basic I/O.
|
|
TEST_F(WebSocketHeartbeatTest, ZeroDisablesHeartbeat) {
|
|
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) + "/ws");
|
|
client.set_websocket_ping_interval(0);
|
|
ASSERT_TRUE(client.connect());
|
|
|
|
// Basic send/receive still works with heartbeat disabled
|
|
ASSERT_TRUE(client.send("no client ping"));
|
|
std::string msg;
|
|
ASSERT_TRUE(client.read(msg));
|
|
EXPECT_EQ("no client ping", msg);
|
|
|
|
client.close();
|
|
}
|
|
|
|
// Verify that Server::set_websocket_ping_interval works at runtime
|
|
class WebSocketServerPingIntervalTest : public ::testing::Test {
|
|
protected:
|
|
void SetUp() override {
|
|
svr_.set_websocket_ping_interval(2);
|
|
svr_.WebSocket("/ws", [](const Request &, ws::WebSocket &ws) {
|
|
std::string msg;
|
|
while (ws.read(msg)) {
|
|
ws.send(msg);
|
|
}
|
|
});
|
|
|
|
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_;
|
|
};
|
|
|
|
TEST_F(WebSocketServerPingIntervalTest, ServerRuntimeInterval) {
|
|
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) + "/ws");
|
|
ASSERT_TRUE(client.connect());
|
|
|
|
// Server ping interval is 2s; client uses compile-time default (1s).
|
|
// Both keep the connection alive.
|
|
std::this_thread::sleep_for(std::chrono::seconds(5));
|
|
|
|
ASSERT_TRUE(client.is_open());
|
|
ASSERT_TRUE(client.send("server interval"));
|
|
std::string msg;
|
|
ASSERT_TRUE(client.read(msg));
|
|
EXPECT_EQ("server interval", msg);
|
|
|
|
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 {
|
|
protected:
|
|
void SetUp() override {
|
|
svr_.set_websocket_ping_interval(0);
|
|
svr_.WebSocket("/ws", [this](const Request &, ws::WebSocket &) {
|
|
std::unique_lock<std::mutex> 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();
|
|
}
|
|
|
|
void TearDown() override {
|
|
{
|
|
std::lock_guard<std::mutex> lock(handler_mutex_);
|
|
release_ = true;
|
|
}
|
|
handler_cv_.notify_all();
|
|
svr_.stop();
|
|
thread_.join();
|
|
}
|
|
|
|
Server svr_;
|
|
int port_;
|
|
std::thread thread_;
|
|
std::mutex handler_mutex_;
|
|
std::condition_variable handler_cv_;
|
|
bool release_ = false;
|
|
};
|
|
|
|
TEST_F(WebSocketPongTimeoutTest, ClientDetectsNonResponsivePeer) {
|
|
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) + "/ws");
|
|
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());
|
|
|
|
// 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.
|
|
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());
|
|
}
|
|
|
|
// 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
|
|
// 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);
|
|
ASSERT_TRUE(client.connect());
|
|
|
|
// Interactive loop over ~6s, longer than 2 ping intervals, so the
|
|
// pong-timeout mechanism would trigger if pongs weren't being consumed.
|
|
// Each iteration's read() also drains any pending Pong frame.
|
|
for (int i = 0; i < 6; i++) {
|
|
std::string text = "keepalive" + std::to_string(i);
|
|
ASSERT_TRUE(client.send(text));
|
|
std::string msg;
|
|
ASSERT_TRUE(client.read(msg));
|
|
EXPECT_EQ(text, msg);
|
|
std::this_thread::sleep_for(std::chrono::seconds(1));
|
|
}
|
|
|
|
EXPECT_TRUE(client.is_open());
|
|
client.close();
|
|
}
|
|
|
|
// Verify that multiple heartbeat cycles work
|
|
TEST_F(WebSocketHeartbeatTest, MultipleHeartbeatCycles) {
|
|
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) + "/ws");
|
|
ASSERT_TRUE(client.connect());
|
|
|
|
// Wait through several heartbeat cycles
|
|
for (int i = 0; i < 3; i++) {
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
|
ASSERT_TRUE(client.is_open());
|
|
std::string text = "msg" + std::to_string(i);
|
|
ASSERT_TRUE(client.send(text));
|
|
std::string msg;
|
|
ASSERT_TRUE(client.read(msg));
|
|
EXPECT_EQ(text, msg);
|
|
}
|
|
|
|
client.close();
|
|
}
|