Tell a WebSocket read timeout apart from a closed connection

read() collapsed every failure into Fail and marked the connection closed with
it, so a read timeout could not be used to get control back and send on the
same connection -- it killed the connection instead. The information was
already there and thrown away: SocketStream::read records Error::Timeout, and
read_websocket_frame flattened it into a bool.

ReadResult gains Timeout, reported only when the timeout elapsed on a frame
boundary with nothing consumed, which is the only case where the stream can be
read again. Every multi-byte field now loops until it has its bytes, which also
fixes a frame header straddling the read buffer's boundary failing the frame:
Stream::read is allowed to return less than asked for, and only the payload
was reading in a loop.

ws::WebSocket::set_read_timeout() lets a server handler bound its own reads,
and WebSocketClient::set_read_timeout() now reaches an already-open connection
instead of only seeding the next connect().

The read timeout macro splits in two. A client waits forever by default -- a
read timeout is the caller's tool for taking back control, not a liveness
check, which is ping/pong's job -- while a server keeps the 300s that reclaims
a worker from a peer gone quiet. Defining the old name still sets both.

Also record a reason on the two WebSocketSSLStream::read failure paths that
returned -1 without one, so get_error() cannot report a previous call's
timeout, and make SocketStream's read timeout atomic now that it can be
changed while a read is in flight.
This commit is contained in:
yhirose
2026-09-03 14:32:13 -04:00
parent 9e2e33da56
commit 199d7ee248
12 changed files with 449 additions and 88 deletions
+175 -26
View File
@@ -22341,8 +22341,9 @@ TEST(WebSocketTest, RSVBitsMustBeZero) {
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_FALSE(ws::impl::read_websocket_frame(strm, opcode, payload, fin,
false, 1024));
EXPECT_EQ(ws::impl::read_websocket_frame(strm, opcode, payload, fin, false,
1024),
ws::impl::FrameRead::Fail);
}
// RSV2 set (0x20)
@@ -22352,8 +22353,9 @@ TEST(WebSocketTest, RSVBitsMustBeZero) {
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_FALSE(ws::impl::read_websocket_frame(strm, opcode, payload, fin,
false, 1024));
EXPECT_EQ(ws::impl::read_websocket_frame(strm, opcode, payload, fin, false,
1024),
ws::impl::FrameRead::Fail);
}
// RSV3 set (0x10)
@@ -22363,8 +22365,9 @@ TEST(WebSocketTest, RSVBitsMustBeZero) {
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_FALSE(ws::impl::read_websocket_frame(strm, opcode, payload, fin,
false, 1024));
EXPECT_EQ(ws::impl::read_websocket_frame(strm, opcode, payload, fin, false,
1024),
ws::impl::FrameRead::Fail);
}
// No RSV bits set - should succeed
@@ -22374,8 +22377,9 @@ TEST(WebSocketTest, RSVBitsMustBeZero) {
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_TRUE(ws::impl::read_websocket_frame(strm, opcode, payload, fin,
false, 1024));
EXPECT_EQ(ws::impl::read_websocket_frame(strm, opcode, payload, fin, false,
1024),
ws::impl::FrameRead::Ok);
EXPECT_EQ(ws::Opcode::Text, opcode);
EXPECT_EQ("Hello", payload);
EXPECT_TRUE(fin);
@@ -22396,8 +22400,9 @@ TEST(WebSocketTest, ControlFrameValidation) {
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_FALSE(ws::impl::read_websocket_frame(strm, opcode, payload, fin,
false, 1024));
EXPECT_EQ(ws::impl::read_websocket_frame(strm, opcode, payload, fin, false,
1024),
ws::impl::FrameRead::Fail);
}
// Close with FIN=0 - must be rejected
@@ -22410,8 +22415,9 @@ TEST(WebSocketTest, ControlFrameValidation) {
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_FALSE(ws::impl::read_websocket_frame(strm, opcode, payload, fin,
false, 1024));
EXPECT_EQ(ws::impl::read_websocket_frame(strm, opcode, payload, fin, false,
1024),
ws::impl::FrameRead::Fail);
}
// Ping with payload_len=126 (extended length) - must be rejected
@@ -22427,8 +22433,9 @@ TEST(WebSocketTest, ControlFrameValidation) {
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_FALSE(ws::impl::read_websocket_frame(strm, opcode, payload, fin,
false, 1024));
EXPECT_EQ(ws::impl::read_websocket_frame(strm, opcode, payload, fin, false,
1024),
ws::impl::FrameRead::Fail);
}
// Ping with FIN=1 and payload_len=125 - should succeed
@@ -22442,8 +22449,9 @@ TEST(WebSocketTest, ControlFrameValidation) {
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_TRUE(ws::impl::read_websocket_frame(strm, opcode, payload, fin,
false, 1024));
EXPECT_EQ(ws::impl::read_websocket_frame(strm, opcode, payload, fin, false,
1024),
ws::impl::FrameRead::Ok);
EXPECT_EQ(ws::Opcode::Ping, opcode);
EXPECT_EQ(125u, payload.size());
EXPECT_TRUE(fin);
@@ -22466,8 +22474,9 @@ TEST(WebSocketTest, PayloadLength64BitMSBMustBeZero) {
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_FALSE(ws::impl::read_websocket_frame(strm, opcode, payload, fin,
false, 1024));
EXPECT_EQ(ws::impl::read_websocket_frame(strm, opcode, payload, fin, false,
1024),
ws::impl::FrameRead::Fail);
}
// MSB clear - should pass length parsing (will be rejected by max_len,
@@ -22484,8 +22493,9 @@ TEST(WebSocketTest, PayloadLength64BitMSBMustBeZero) {
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_TRUE(ws::impl::read_websocket_frame(strm, opcode, payload, fin,
false, 1024));
EXPECT_EQ(ws::impl::read_websocket_frame(strm, opcode, payload, fin, false,
1024),
ws::impl::FrameRead::Ok);
EXPECT_EQ(ws::Opcode::Text, opcode);
EXPECT_EQ("abc", payload);
}
@@ -22970,10 +22980,46 @@ TEST_F(WebSocketIntegrationTest, ReadTimeout) {
client.set_read_timeout(1, 0); // 1 second
ASSERT_TRUE(client.connect());
// Don't send anything — server echo handler waits for a message,
// so read() should time out and return false.
// Don't send anything — server echo handler waits for a message, so read()
// should time out. The connection survives it: nothing was consumed.
std::string msg;
EXPECT_FALSE(client.read(msg));
EXPECT_EQ(client.read(msg), ws::Timeout);
EXPECT_TRUE(client.is_open());
// And it is still usable afterwards.
EXPECT_TRUE(client.send("after timeout"));
EXPECT_EQ(client.read(msg), ws::Text);
EXPECT_EQ(msg, "after timeout");
}
TEST_F(WebSocketIntegrationTest, ReadTimeoutLeavesMessageUntouched) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-echo");
client.set_read_timeout(1, 0);
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.send("first"));
std::string msg;
ASSERT_EQ(client.read(msg), ws::Text);
ASSERT_EQ(msg, "first");
// A timeout does not write to msg. This is why `while (ws.read(msg))` must
// not be used with a read timeout: it would reprocess the previous message.
EXPECT_EQ(client.read(msg), ws::Timeout);
EXPECT_EQ(msg, "first");
}
TEST_F(WebSocketIntegrationTest, ReadTimeoutSetAfterConnect) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-echo");
ASSERT_TRUE(client.connect());
// Setting it once the connection is open reaches the live stream, not just
// the seed for the next connect().
client.set_read_timeout(1, 0);
std::string msg;
EXPECT_EQ(client.read(msg), ws::Timeout);
EXPECT_TRUE(client.is_open());
}
TEST_F(WebSocketIntegrationTest, MaxPayloadExceeded) {
@@ -23162,12 +23208,13 @@ TEST_F(WebSocketIntegrationTest, ChronoTimeoutSetters) {
auto start = std::chrono::steady_clock::now();
std::string msg;
EXPECT_EQ(client.read(msg), ws::ReadResult::Fail);
EXPECT_EQ(client.read(msg), ws::ReadResult::Timeout);
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start)
.count();
// Above 1s so that dropping the microseconds half of the split fails here,
// and well under the 300s default so that ignoring the setter fails too.
// Above 1s so that dropping the microseconds half of the split fails here.
// Ignoring the setter altogether would not reach this line at all: a client
// waits forever by default.
EXPECT_GE(elapsed, 1400);
EXPECT_LT(elapsed, 30000);
}
@@ -23214,6 +23261,108 @@ TEST(WebSocketPreRoutingTest, RejectWithoutAuth) {
t.join();
}
TEST(WebSocketServerTimeoutTest, HandlerSendsWhileNothingArrives) {
Server svr;
svr.WebSocket("/ws", [](const Request &, ws::WebSocket &ws) {
// A read timeout is how a handler gets control back. Without it, a handler
// parked in read() can never write on the connection it is reading.
ws.set_read_timeout(std::chrono::milliseconds(100));
std::string msg;
while (ws.is_open()) {
auto r = ws.read(msg);
if (r == httplib::ws::Timeout) {
ws.send("tick");
continue;
}
if (r == httplib::ws::Fail) { break; }
ws.send(msg);
}
});
auto port = svr.bind_to_any_port("localhost");
std::thread t([&]() { svr.listen_after_bind(); });
svr.wait_until_ready();
ws::WebSocketClient client("ws://localhost:" + std::to_string(port) + "/ws");
client.set_read_timeout(10, 0); // fail rather than hang if no tick arrives
ASSERT_TRUE(client.connect());
// The client sends nothing, so anything it receives came out of the
// handler's timeout path.
std::string msg;
ASSERT_EQ(client.read(msg), ws::Text);
EXPECT_EQ("tick", msg);
client.close();
svr.stop();
t.join();
}
// Stream::read may return fewer bytes than asked for. This is that contract at
// its worst -- one byte per call -- which is what a frame header straddling a
// read buffer's boundary looks like to the frame parser.
class WsByteAtATimeStream : public Stream {
public:
explicit WsByteAtATimeStream(std::string data) : data_(std::move(data)) {}
bool is_readable() const override { return true; }
bool wait_readable() const override { return true; }
bool wait_writable() const override { return true; }
ssize_t read(char *ptr, size_t size) override {
if (size == 0 || pos_ >= data_.size()) { return 0; }
*ptr = data_[pos_++];
return 1;
}
ssize_t write(const char *, size_t size) override {
return static_cast<ssize_t>(size);
}
void get_remote_ip_and_port(std::string &ip, int &port) const override {
ip = "127.0.0.1";
port = 0;
}
void get_local_ip_and_port(std::string &ip, int &port) const override {
ip = "127.0.0.1";
port = 0;
}
socket_t socket() const override { return INVALID_SOCKET; }
time_t duration() const override { return 0; }
private:
std::string data_;
size_t pos_ = 0;
};
TEST(WebSocketFrameTest, MultiByteFieldsSplitAcrossReads) {
// A 200-byte payload uses the 16-bit extended length, so the header, the
// length and the mask key are all multi-byte fields here. Each used to be
// read with a single read() call, which fails as soon as one is split.
std::string body(200, 'x');
const uint8_t mask[4] = {0x0a, 0x0b, 0x0c, 0x0d};
std::string frame;
frame += static_cast<char>(0x81); // FIN + Text
frame += static_cast<char>(0x80 | 126); // masked, 16-bit length follows
frame += static_cast<char>(body.size() >> 8);
frame += static_cast<char>(body.size() & 0xff);
for (size_t i = 0; i < 4; i++) {
frame += static_cast<char>(mask[i]);
}
for (size_t i = 0; i < body.size(); i++) {
frame += static_cast<char>(body[i] ^ mask[i % 4]);
}
WsByteAtATimeStream strm(frame);
ws::Opcode opcode;
std::string payload;
bool fin = false;
ASSERT_EQ(ws::impl::read_websocket_frame(strm, opcode, payload, fin,
/*expect_masked=*/true, 1024),
ws::impl::FrameRead::Ok);
EXPECT_TRUE(fin);
EXPECT_EQ(opcode, ws::Opcode::Text);
EXPECT_EQ(payload, body);
}
TEST(WebSocketTest, QueryStringInHandshake) {
Server svr;
+2 -1
View File
@@ -3,7 +3,8 @@
// without waiting 30 seconds.
#define CPPHTTPLIB_WEBSOCKET_PING_INTERVAL_SECOND 1
#define CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND 3
#define CPPHTTPLIB_WEBSOCKET_CLIENT_READ_TIMEOUT_SECOND 3
#define CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND 3
#include <httplib.h>
#include "gtest/gtest.h"