Skip to content

Commit 00d1f54

Browse files
committed
Fix WebSocket::close() racing a concurrent read() on the same stream
close() drained the peer's Close reply with its own frame read. If an application reader thread was inside read() at that moment, two threads parsed frames off one stream: read_websocket_frame()'s payload loop keeps reading until it has the declared length, so bytes stolen by the drain were silently replaced with bytes from further along the stream. The in-flight message kept its correct length but got the wrong content. Add a read_mutex_ that marks which thread owns the stream's read side. read() holds it for the whole call. close() sends the Close frame, then drains the peer's reply (RFC 6455 7.1.1) only if it can try_lock the mutex; otherwise it returns immediately, leaving the stream entirely to the thread already reading it. This also fixes close() blocking for the full close timeout when a reader thread was parked waiting on a peer that never replies. Add WebSocketTest.CloseDoesNotStealBytesFromConcurrentRead, which drives a raw TCP peer that stalls mid-payload to force the race; it fails reliably against the old code and passes against the fix. Update README-websocket.md: close() during a concurrent read() is now supported.
1 parent f82d2d9 commit 00d1f54

3 files changed

Lines changed: 182 additions & 4 deletions

File tree

README-websocket.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -470,9 +470,9 @@ Choose sizes that account for both your expected HTTP load and the maximum numbe
470470

471471
A single `WebSocket` (server-side) or `WebSocketClient` handle is shared by three potential callers: the thread running your handler (or holding the client), the heartbeat thread, and, if your code does its own thing, a separate thread calling `send()`/`close()` while another thread is blocked in `read()`.
472472

473-
**Supported**: calling `read()` from one thread while calling `send()`/`close()` from another. This is the common pattern for a client that reads incoming messages in a loop on one thread and sends from elsewhere (e.g. a UI thread). The heartbeat thread's automatic pings use the same `send()` path internally, so they are safe to run concurrently with your `read()` loop too — for `wss://` this requires every TLS call on a connection to be serialized internally, which cpp-httplib does for you.
473+
**Supported**: calling `read()` from one thread while calling `send()`/`close()` from another. This is the common pattern for a client that reads incoming messages in a loop on one thread and sends from elsewhere (e.g. a UI thread). A message that is in flight when `close()` is called still arrives intact; `close()` sends the Close frame and returns, leaving the connection's read side to the thread that owns it, so it does not block waiting for the peer's Close reply in that case. The heartbeat thread's automatic pings use the same `send()` path internally, so they are safe to run concurrently with your `read()` loop too — for `wss://` this requires every TLS call on a connection to be serialized internally, which cpp-httplib does for you.
474474

475-
**Not supported**: calling `read()` from two threads at the same time on the same handle, or calling `close()` from one thread while another thread is already inside `read()` and a Close frame from the peer is currently being parsed. `close()` waits for the peer's Close response using its own frame read, so it can race with your `read()` loop's own frame parsing. This does not corrupt the TLS session or crash the process, but a message that is in flight at that exact moment is not guaranteed to arrive intact — treat any message received while `close()` is in progress as advisory only, and don't rely on it.
475+
**Not supported**: calling `read()` from two threads at the same time on the same handle. The calls are serialized rather than left to corrupt each other, but which thread receives which message is unspecified, so there is nothing useful to build on it.
476476

477477
## Protocol
478478

httplib.h

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4331,6 +4331,11 @@ class WebSocket {
43314331
int unacked_pings_ = 0;
43324332
std::atomic<bool> closed_{false};
43334333
std::mutex write_mutex_;
4334+
// Owned by whichever thread is parsing frames off strm_. Only one thread
4335+
// may do so: read_websocket_frame() reads a payload until it has the whole
4336+
// declared length, so a second parser stealing bytes silently corrupts the
4337+
// message the first one is assembling.
4338+
std::mutex read_mutex_;
43344339
std::thread ping_thread_;
43354340
std::mutex ping_mutex_;
43364341
std::condition_variable ping_cv_;
@@ -21649,6 +21654,7 @@ inline bool WebSocket::send_frame(Opcode op, const char *data, size_t len,
2164921654
}
2165021655

2165121656
inline ReadResult WebSocket::read(std::string &msg) {
21657+
std::unique_lock<std::mutex> read_lock(read_mutex_);
2165221658
while (!closed_) {
2165321659
Opcode opcode;
2165421660
std::string payload;
@@ -21734,6 +21740,9 @@ inline ReadResult WebSocket::read(std::string &msg) {
2173421740
}
2173521741
// RFC 6455 Section 5.6: text frames must contain valid UTF-8
2173621742
if (result == Text && !impl::is_valid_utf8(msg)) {
21743+
// close() takes the read lock to wait for the peer's Close reply, so
21744+
// it must not run while this thread still holds it.
21745+
read_lock.unlock();
2173721746
close(CloseStatus::InvalidPayload, "invalid UTF-8");
2173821747
return Fail;
2173921748
}
@@ -21770,9 +21779,18 @@ inline void WebSocket::close(CloseStatus status, const std::string &reason) {
2177021779
}
2177121780

2177221781
// RFC 6455 Section 7.1.1: after sending a Close frame, wait for the peer's
21773-
// Close response before closing the TCP connection. Use a short timeout to
21774-
// avoid hanging if the peer doesn't respond.
21782+
// Close response before closing the TCP connection.
21783+
//
21784+
// Wait only when no other thread is parsing frames. When one is, it is the
21785+
// thread positioned to see the peer's reply, and reading here would take
21786+
// bytes out of the message it is assembling. Bailing out also leaves the
21787+
// stream, including its read timeout, entirely to that thread.
21788+
std::unique_lock<std::mutex> read_lock(read_mutex_, std::try_to_lock);
21789+
if (!read_lock.owns_lock()) { return; }
21790+
21791+
// Use a short timeout to avoid hanging if the peer doesn't respond.
2177521792
strm_.set_read_timeout(CPPHTTPLIB_WEBSOCKET_CLOSE_TIMEOUT_SECOND, 0);
21793+
2177621794
Opcode op;
2177721795
std::string resp;
2177821796
bool fin;

test/test.cc

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21793,6 +21793,166 @@ TEST(WebSocketTest, HostHeaderOverUnixSocket) {
2179321793
}
2179421794
}
2179521795

21796+
// Two threads must never parse WebSocket frames from the same stream.
21797+
// close() used to read the peer's Close reply with its own frame read, so it
21798+
// raced a reader thread that was in the middle of a payload: the payload loop
21799+
// in read_websocket_frame() keeps reading until payload_len bytes are in hand,
21800+
// so bytes taken by close() were replaced with bytes from further along the
21801+
// stream. The message kept its length and silently changed content.
21802+
//
21803+
// The raw peer below sends a frame header plus part of the payload, waits for
21804+
// the handler to call close(), and only then sends the rest. Whichever thread
21805+
// would win the race for those bytes, the message must arrive intact, because
21806+
// close() must not touch the stream while read() owns it.
21807+
TEST(WebSocketTest, CloseDoesNotStealBytesFromConcurrentRead) {
21808+
#ifndef _WIN32
21809+
signal(SIGPIPE, SIG_IGN);
21810+
#endif
21811+
21812+
const size_t payload_len = 120; // fits the 7-bit length field
21813+
const size_t prefix_len = 8;
21814+
const int attempts = 8;
21815+
21816+
std::string expected(payload_len, '\0');
21817+
for (size_t i = 0; i < payload_len; i++) {
21818+
expected[i] = static_cast<char>('a' + i % 26);
21819+
}
21820+
21821+
std::atomic<bool> peer_stalled{false};
21822+
std::atomic<bool> handler_done{false};
21823+
std::mutex received_mutex;
21824+
std::vector<std::string> received;
21825+
21826+
// Bound every wait, so a regression fails the test instead of hanging the
21827+
// suite.
21828+
auto wait_for = [](const std::atomic<bool> &flag) {
21829+
for (int i = 0; i < 500 && !flag; i++) {
21830+
std::this_thread::sleep_for(std::chrono::milliseconds(10));
21831+
}
21832+
};
21833+
21834+
Server svr;
21835+
svr.set_websocket_ping_interval(0);
21836+
svr.WebSocket("/ws", [&](const Request &, ws::WebSocket &ws) {
21837+
std::thread reader([&]() {
21838+
std::string msg;
21839+
while (ws.read(msg)) {
21840+
std::lock_guard<std::mutex> guard(received_mutex);
21841+
received.push_back(msg);
21842+
}
21843+
});
21844+
21845+
// Wait until the peer stalls mid-payload, so the reader thread is parked
21846+
// inside read_websocket_frame() when close() runs.
21847+
wait_for(peer_stalled);
21848+
ws.close();
21849+
reader.join();
21850+
handler_done = true;
21851+
});
21852+
21853+
auto port = svr.bind_to_any_port("127.0.0.1");
21854+
std::thread t([&]() { svr.listen_after_bind(); });
21855+
auto se = detail::scope_exit([&] {
21856+
svr.stop();
21857+
t.join();
21858+
});
21859+
svr.wait_until_ready();
21860+
21861+
auto send_bytes = [](socket_t s, const std::string &data) {
21862+
#ifdef _WIN32
21863+
auto n = ::send(s, data.data(), static_cast<int>(data.size()), 0);
21864+
#else
21865+
auto n = ::send(s, data.data(), data.size(), 0);
21866+
#endif
21867+
return n == static_cast<decltype(n)>(data.size());
21868+
};
21869+
21870+
// Frame header with an all-zero mask key, so the payload goes out verbatim.
21871+
// Every length used here fits the 7-bit length field.
21872+
auto masked_header = [](uint8_t first_byte, size_t len) {
21873+
std::string h;
21874+
h += static_cast<char>(first_byte);
21875+
h += static_cast<char>(0x80 | len); // masked, 7-bit length
21876+
h.append(4, '\0'); // mask key
21877+
return h;
21878+
};
21879+
21880+
for (int attempt = 0; attempt < attempts; attempt++) {
21881+
peer_stalled = false;
21882+
handler_done = false;
21883+
21884+
auto sock = ::socket(AF_INET, SOCK_STREAM, 0);
21885+
ASSERT_NE(INVALID_SOCKET, sock) << "attempt " << attempt;
21886+
auto se_sock = detail::scope_exit([&] {
21887+
if (sock != INVALID_SOCKET) { detail::close_socket(sock); }
21888+
});
21889+
detail::set_socket_opt_time(sock, SOL_SOCKET, SO_RCVTIMEO, 5, 0);
21890+
detail::set_socket_opt_time(sock, SOL_SOCKET, SO_SNDTIMEO, 5, 0);
21891+
21892+
sockaddr_in addr{};
21893+
addr.sin_family = AF_INET;
21894+
addr.sin_port = htons(static_cast<uint16_t>(port));
21895+
::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
21896+
ASSERT_EQ(
21897+
0, ::connect(sock, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)))
21898+
<< "attempt " << attempt;
21899+
21900+
ASSERT_TRUE(send_bytes(sock,
21901+
"GET /ws HTTP/1.1\r\n"
21902+
"Host: 127.0.0.1\r\n"
21903+
"Upgrade: websocket\r\n"
21904+
"Connection: Upgrade\r\n"
21905+
"Sec-WebSocket-Key: AAAAAAAAAAAAAAAAAAAAAA==\r\n"
21906+
"Sec-WebSocket-Version: 13\r\n"
21907+
"\r\n"))
21908+
<< "attempt " << attempt;
21909+
21910+
std::string response;
21911+
while (response.find("\r\n\r\n") == std::string::npos) {
21912+
char buf[512];
21913+
auto n = ::recv(sock, buf, static_cast<int>(sizeof(buf)), 0);
21914+
if (n <= 0) { break; }
21915+
response.append(buf, static_cast<size_t>(n));
21916+
}
21917+
ASSERT_NE(std::string::npos, response.find(" 101 "))
21918+
<< "attempt " << attempt;
21919+
21920+
// Send the header of a Binary message but only the first prefix_len bytes
21921+
// of its payload, leaving the reader thread stalled inside the payload.
21922+
ASSERT_TRUE(send_bytes(sock, masked_header(0x82, payload_len) +
21923+
expected.substr(0, prefix_len)))
21924+
<< "attempt " << attempt;
21925+
21926+
std::this_thread::sleep_for(std::chrono::milliseconds(50));
21927+
peer_stalled = true;
21928+
// Let close() send its Close frame and park in its own read before the
21929+
// rest of the payload arrives, so both threads are waiting for it.
21930+
std::this_thread::sleep_for(std::chrono::milliseconds(50));
21931+
21932+
std::string close_frame = masked_header(0x88, 2); // FIN + Close
21933+
close_frame += static_cast<char>(0x03); // status 1000
21934+
close_frame += static_cast<char>(0xE8);
21935+
ASSERT_TRUE(send_bytes(sock, expected.substr(prefix_len) + close_frame))
21936+
<< "attempt " << attempt;
21937+
21938+
// Closing the peer releases the reader thread even on the buggy path,
21939+
// where it waits for bytes another thread already consumed.
21940+
std::this_thread::sleep_for(std::chrono::milliseconds(50));
21941+
detail::close_socket(sock);
21942+
sock = INVALID_SOCKET;
21943+
21944+
wait_for(handler_done);
21945+
ASSERT_TRUE(handler_done) << "attempt " << attempt;
21946+
}
21947+
21948+
std::lock_guard<std::mutex> guard(received_mutex);
21949+
EXPECT_EQ(static_cast<size_t>(attempts), received.size())
21950+
<< "a message in flight when close() ran was dropped";
21951+
for (size_t i = 0; i < received.size(); i++) {
21952+
EXPECT_EQ(expected, received[i]) << "message " << i;
21953+
}
21954+
}
21955+
2179621956
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
2179721957
class WebSocketSSLIntegrationTest : public ::testing::Test {
2179821958
protected:

0 commit comments

Comments
 (0)