From 8aa547e4a1ea7494b7df4dfa88d1f09cf6ab28d8 Mon Sep 17 00:00:00 2001 From: yhirose Date: Thu, 8 Oct 2026 00:38:51 -0400 Subject: [PATCH] Wake a blocked WebSocket read when the pong timeout fires 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. --- httplib.h | 8 ++++++++ test/test_websocket_heartbeat.cc | 14 ++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/httplib.h b/httplib.h index d12a3f6..c52ba15 100644 --- a/httplib.h +++ b/httplib.h @@ -22843,6 +22843,14 @@ inline void WebSocket::start_heartbeat() { if (max_missed_pongs_ > 0 && unacked_pings_ >= max_missed_pongs_) { lock.unlock(); close(CloseStatus::GoingAway, "pong timeout"); + // Wake a read() blocked on the unresponsive peer. Only the read side + // is shut down: a TLS backend answers the EOF with an alert, and + // writing it to a socket closed for writing raises SIGPIPE. +#ifdef _WIN32 + shutdown(strm_.socket(), SD_RECEIVE); +#else + shutdown(strm_.socket(), SHUT_RD); +#endif return; } lock.unlock(); diff --git a/test/test_websocket_heartbeat.cc b/test/test_websocket_heartbeat.cc index 334ff10..4242a49 100644 --- a/test/test_websocket_heartbeat.cc +++ b/test/test_websocket_heartbeat.cc @@ -179,18 +179,20 @@ protected: 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. Poll is_open() up to 6s. + // roughly 3s, and that has to end a read() already waiting on the peer. auto start = std::chrono::steady_clock::now(); - while (client.is_open() && - std::chrono::steady_clock::now() - start < std::chrono::seconds(6)) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - } - + 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()); }