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.
This commit is contained in:
yhirose committed 2026-08-24 17:30:04 -04:00
1 parent f82d2d90b6
commit 00d1f54267
3 files changed
+182 -4

No files matched your search

+20 -2
View File
@@ -4331,6 +4331,11 @@ private:
int unacked_pings_ = 0;
std::atomic<bool> closed_{false};
std::mutex write_mutex_;
// Owned by whichever thread is parsing frames off strm_. Only one thread
// may do so: read_websocket_frame() reads a payload until it has the whole
// declared length, so a second parser stealing bytes silently corrupts the
// message the first one is assembling.
std::mutex read_mutex_;
std::thread ping_thread_;
std::mutex ping_mutex_;
std::condition_variable ping_cv_;
@@ -21649,6 +21654,7 @@ inline bool WebSocket::send_frame(Opcode op, const char *data, size_t len,
}
inline ReadResult WebSocket::read(std::string &msg) {
std::unique_lock<std::mutex> read_lock(read_mutex_);
while (!closed_) {
Opcode opcode;
std::string payload;
@@ -21734,6 +21740,9 @@ inline ReadResult WebSocket::read(std::string &msg) {
}
// RFC 6455 Section 5.6: text frames must contain valid UTF-8
if (result == Text && !impl::is_valid_utf8(msg)) {
// close() takes the read lock to wait for the peer's Close reply, so
// it must not run while this thread still holds it.
read_lock.unlock();
close(CloseStatus::InvalidPayload, "invalid UTF-8");
return Fail;
}
@@ -21770,9 +21779,18 @@ inline void WebSocket::close(CloseStatus status, const std::string &reason) {
}
// RFC 6455 Section 7.1.1: after sending a Close frame, wait for the peer's
// Close response before closing the TCP connection. Use a short timeout to
// avoid hanging if the peer doesn't respond.
// Close response before closing the TCP connection.
//
// Wait only when no other thread is parsing frames. When one is, it is the
// thread positioned to see the peer's reply, and reading here would take
// bytes out of the message it is assembling. Bailing out also leaves the
// stream, including its read timeout, entirely to that thread.
std::unique_lock<std::mutex> read_lock(read_mutex_, std::try_to_lock);
if (!read_lock.owns_lock()) { return; }
// Use a short timeout to avoid hanging if the peer doesn't respond.
strm_.set_read_timeout(CPPHTTPLIB_WEBSOCKET_CLOSE_TIMEOUT_SECOND, 0);
Opcode op;
std::string resp;
bool fin;