فهرست منبع

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.
yhirose 1 ماه پیش
والد
کامیت
199d7ee248

+ 22 - 5
README-websocket.md

@@ -57,14 +57,30 @@ if (ws.connect()) {
 
 ```cpp
 enum ReadResult : int {
-    Fail   = 0,  // Connection closed or error
-    Text   = 1,  // UTF-8 text message
-    Binary = 2,  // Binary message
+    Fail    = 0,  // Connection closed or error
+    Text    = 1,  // UTF-8 text message
+    Binary  = 2,  // Binary message
+    Timeout = 3,  // Read timeout elapsed; connection still open
 };
 ```
 
 Returned by `read()`. Since `Fail` is `0`, the result works naturally in boolean contexts — `while (ws.read(msg))` continues until the connection closes. When you need to distinguish text from binary, check the return value directly.
 
+`Timeout` only appears once a read timeout is in effect (a client waits forever unless you set one; a server uses `CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND`). It means the timeout elapsed on a message boundary: nothing was consumed and the connection is still open, so you can send on it and read again.
+
+**`msg` is left untouched on `Timeout`.** Because `Timeout` is non-zero, `while (ws.read(msg))` keeps looping — with the *previous* message still in `msg`. Once a read timeout is set, test the result instead:
+
+```cpp
+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) { continue; }  // nothing yet; send if you like
+    if (r == httplib::ws::Fail) { break; }
+    handle(msg);
+}
+```
+
 ### CloseStatus
 
 ```cpp
@@ -409,7 +425,8 @@ if (ws.connect()) {
 | Macro                                       | Default           | Description                                              |
 |---------------------------------------------|-------------------|----------------------------------------------------------|
 | `CPPHTTPLIB_WEBSOCKET_MAX_PAYLOAD_LENGTH`   | `16777216` (16MB) | Maximum payload size per message                         |
-| `CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND`  | `300`             | Read timeout for WebSocket connections (seconds)         |
+| `CPPHTTPLIB_WEBSOCKET_CLIENT_READ_TIMEOUT_SECOND` | `0`         | Client read timeout (seconds); `0` waits forever         |
+| `CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND` | `300`       | Server read timeout (seconds)                            |
 | `CPPHTTPLIB_WEBSOCKET_CLOSE_TIMEOUT_SECOND` | `5`               | Timeout for waiting peer's Close response (seconds)      |
 | `CPPHTTPLIB_WEBSOCKET_PING_INTERVAL_SECOND` | `30`              | Automatic Ping interval for heartbeat (seconds)          |
 | `CPPHTTPLIB_WEBSOCKET_MAX_MISSED_PONGS`     | `0` (disabled)    | Close the connection after N consecutive unacked pings   |
@@ -446,7 +463,7 @@ The server side has the same `set_websocket_max_missed_pongs()`.
 
 With the default ping interval of 30 seconds, `max_missed_pongs = 2` detects a dead peer within ~60 seconds. The counter is reset every time a Pong frame is received, so the mechanism only works when your code is actively calling `read()` — exactly the pattern a normal WebSocket client already uses.
 
-**The default is `0`**, which means "never close the connection because of missing pongs." Pings are still sent on the heartbeat interval, but their responses are not checked. Even so, a dead connection does not linger forever: while your code is inside `read()`, `CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND` (default **300 seconds = 5 minutes**) acts as a backstop and `read()` fails if no frame arrives in time. `max_missed_pongs` is the knob for detecting an unresponsive peer faster than that 5-minute fallback.
+**The default is `0`**, which means "never close the connection because of missing pongs." Pings are still sent on the heartbeat interval, but their responses are not checked. On the server side a dead connection still does not linger: while a handler is inside `read()`, `CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND` (default **300 seconds = 5 minutes**) acts as a backstop. A client has no such backstop — it waits forever unless you set a read timeout — so there `max_missed_pongs` is what notices an unresponsive peer at all. On either side it is also the knob for noticing one *faster* than the 5-minute fallback.
 
 ## Threading Model
 

+ 1 - 0
docs-src/pages/en/cookbook/w01-websocket-echo.md

@@ -36,6 +36,7 @@ The `read()` return value is a `ReadResult` enum:
 - `ReadResult::Text`: received a text message
 - `ReadResult::Binary`: received a binary message
 - `ReadResult::Fail`: error, or connection closed
+- `ReadResult::Timeout`: the read timeout elapsed with nothing received; the connection is still open. Only appears once a read timeout is set — see [W06. Set Timeouts](../w06-websocket-timeouts)
 
 ## Client: talk to the echo server
 

+ 1 - 1
docs-src/pages/en/cookbook/w02-websocket-ping.md

@@ -75,6 +75,6 @@ The counter is reset whenever `read()` consumes an incoming Pong frame, so this
 
 `max_missed_pongs` defaults to `0`, which means "never close the connection because of missing pongs." Pings are still sent on the heartbeat interval, but their responses aren't checked. If you want unresponsive-peer detection, set it explicitly to `1` or higher.
 
-Even with `0`, a dead connection won't linger forever: while your code is inside `read()`, `CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND` (default **300 seconds = 5 minutes**) acts as a backstop and `read()` fails if no frame arrives in time. Think of `max_missed_pongs` as the knob for detecting an unresponsive peer **faster** than that.
+On the server side, even with `0`, a dead connection won't linger forever: while a handler is inside `read()`, `CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND` (default **300 seconds = 5 minutes**) acts as a backstop. A client has no backstop of its own — it waits forever unless you set a read timeout — so there `max_missed_pongs` is what notices an unresponsive peer at all. On either side, it is also how you notice one **faster** than that 5-minute fallback.
 
 > For handling a closed connection, see [W03. Handle connection close](../w03-websocket-close).

+ 3 - 0
docs-src/pages/en/cookbook/w04-websocket-binary.md

@@ -42,6 +42,9 @@ switch (result) {
   case httplib::ws::ReadResult::Fail:
     // error or closed
     break;
+  case httplib::ws::ReadResult::Timeout:
+    // read timeout elapsed; the connection is still open
+    break;
 }
 ```
 

+ 37 - 4
docs-src/pages/en/cookbook/w06-websocket-timeouts.md

@@ -9,7 +9,7 @@ status: "draft"
 | Kind | API | Default |
 | --- | --- | --- |
 | Connection | `set_connection_timeout` | 300s |
-| Read | `set_read_timeout` | 300s (`CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND`) |
+| Read | `set_read_timeout` | none — waits forever (`CPPHTTPLIB_WEBSOCKET_CLIENT_READ_TIMEOUT_SECOND`) |
 | Write | `set_write_timeout` | 5s |
 
 ## Basic usage
@@ -26,7 +26,7 @@ if (ws.connect()) {
 }
 ```
 
-Set these before calling `connect()`.
+Set the connection and write timeouts before calling `connect()`. The read timeout can be changed at any time — setting it on an open connection takes effect on the next `read()`.
 
 ## Use `std::chrono`
 
@@ -40,9 +40,42 @@ ws.set_read_timeout(30s);
 ws.set_write_timeout(10s);
 ```
 
-## Watch out for what the read timeout means
+## What the read timeout means
 
-`set_read_timeout()` applies to a single `read()` call. If no message arrives within that time, `read()` returns `ReadResult::Fail`. For connections where long idle periods are normal — waiting on notifications, for example — set a longer timeout, or reconnect from your application code when the read fails.
+`set_read_timeout()` applies to a single `read()` call. If no message arrives within that time, `read()` returns `ReadResult::Timeout`: **the connection is still open** and nothing was consumed, so you can send on it and read again. That is what separates it from `ReadResult::Fail`, which means the connection is gone.
+
+This is what lets one thread own a connection in both directions:
+
+```cpp
+using namespace std::chrono_literals;
+
+ws.set_read_timeout(100ms);
+std::string msg;
+while (ws.is_open()) {
+  auto r = ws.read(msg);
+  if (r == httplib::ws::Timeout) {
+    flush_outgoing(ws);  // nothing arrived — send whatever is queued
+    continue;
+  }
+  if (r == httplib::ws::Fail) { break; }
+  handle(msg);
+}
+```
+
+Without a read timeout, `read()` blocks until a message arrives, so the thread holding the connection never gets to its writes.
+
+Two things to know about `Timeout`:
+
+- It leaves `msg` untouched, and it is non-zero. So `while (ws.read(msg))` is not usable once a read timeout is set — the loop would keep running with the *previous* message still in `msg`.
+- It is only reported on a message boundary. If the timeout elapses partway through a fragmented message, that message cannot be resumed and `read()` returns `Fail`.
+
+For connections where long idle periods are normal — waiting on notifications, for example — either leave the read timeout unset, or treat `Timeout` as the no-op it is and keep looping.
+
+## On the server side
+
+A handler's `ws::WebSocket` has `set_read_timeout()` too, and the pattern above is how a handler relays between connections instead of parking in `read()`.
+
+The server default is 300s (`CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND`) rather than "forever": it is a backstop that reclaims a worker from a peer that has gone silent, since a WebSocket handler holds its worker for the life of the connection.
 
 > Unresponsive-peer detection via Ping/Pong is a separate mechanism. See [W02. Set a WebSocket Heartbeat](../w02-websocket-ping) for details.
 

+ 2 - 1
docs-src/pages/ja/cookbook/w01-websocket-echo.md

@@ -31,11 +31,12 @@ int main() {
 
 `svr.WebSocket()`でWebSocket用のハンドラを登録します。ハンドラが呼ばれた時点で、すでにWebSocketのハンドシェイクは完了しています。ループの中で`ws.read()`して`ws.send()`するだけで、エコー動作が完成します。
 
-`read()`の返り値は`ReadResult`列挙値で、次の3種類です。
+`read()`の返り値は`ReadResult`列挙値で、次の4種類です。
 
 - `ReadResult::Text`: テキストメッセージを受信
 - `ReadResult::Binary`: バイナリメッセージを受信
 - `ReadResult::Fail`: エラー、または接続が閉じた
+- `ReadResult::Timeout`: 何も受信しないまま読み取りタイムアウトが経過した。接続は開いたまま。読み取りタイムアウトを設定したときだけ返る([W06. タイムアウトを設定する](../w06-websocket-timeouts)を参照)
 
 ## クライアント: エコーを叩く
 

+ 1 - 1
docs-src/pages/ja/cookbook/w02-websocket-ping.md

@@ -75,6 +75,6 @@ cli.set_websocket_max_missed_pongs(2); // 2回連続でPongが返ってこなけ
 
 `max_missed_pongs`のデフォルトは`0`で、これは「Pongが何回返ってこなくてもこの仕組みでは切断しない」という意味です。Ping自体は送られ続けますが、応答の有無はチェックされません。無応答ピアを検出したい場合は明示的に`1`以上を設定してください。
 
-ただし`0`のままでも最終的に接続が残り続けることはありません。`read()`を呼んでいる間は`CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND`(デフォルト**300秒 = 5分**)が保険として働き、フレームが一定時間来なければ`read()`が失敗します。つまり`max_missed_pongs`は「**もっと速く**無応答を検出したい」ときに使うオプションだと考えてください。
+サーバ側は`0`のままでも接続が残り続けることはありません。ハンドラが`read()`を呼んでいる間は`CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND`(デフォルト**300秒 = 5分**)が保険として働きます。一方クライアント側にはこの保険がなく、読み取りタイムアウトを設定しない限り無期限に待つので、無応答ピアを検出する手段は`max_missed_pongs`だけです。どちらの側でも「**もっと速く**検出したい」ときに使うオプションでもあります。
 
 > 接続が閉じたときの処理は[W03. 接続クローズをハンドリングする](../w03-websocket-close)を参照してください。

+ 3 - 0
docs-src/pages/ja/cookbook/w04-websocket-binary.md

@@ -42,6 +42,9 @@ switch (result) {
   case httplib::ws::ReadResult::Fail:
     // エラーまたは切断
     break;
+  case httplib::ws::ReadResult::Timeout:
+    // 読み取りタイムアウト。接続は開いたまま
+    break;
 }
 ```
 

+ 37 - 4
docs-src/pages/ja/cookbook/w06-websocket-timeouts.md

@@ -9,7 +9,7 @@ status: "draft"
 | 種類 | API | デフォルト |
 | --- | --- | --- |
 | 接続タイムアウト | `set_connection_timeout` | 300秒 |
-| 読み取りタイムアウト | `set_read_timeout` | 300秒(`CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND`) |
+| 読み取りタイムアウト | `set_read_timeout` | なし。無期限に待つ(`CPPHTTPLIB_WEBSOCKET_CLIENT_READ_TIMEOUT_SECOND`) |
 | 書き込みタイムアウト | `set_write_timeout` | 5秒 |
 
 ## 基本の使い方
@@ -26,7 +26,7 @@ if (ws.connect()) {
 }
 ```
 
-`connect()`を呼ぶ前に設定してください。
+接続タイムアウトと書き込みタイムアウトは`connect()`を呼ぶ前に設定してください。読み取りタイムアウトはいつでも変更でき、接続済みの状態で設定した場合は次の`read()`から効きます。
 
 ## `std::chrono`で指定する
 
@@ -40,9 +40,42 @@ ws.set_read_timeout(30s);
 ws.set_write_timeout(10s);
 ```
 
-## 読み取りタイムアウトの意味に注意
+## 読み取りタイムアウトの意味
 
-`set_read_timeout()`は「1回の`read()`呼び出し」に対するタイムアウトです。メッセージが届かないまま指定時間が経過すると`read()`が`ReadResult::Fail`を返します。通知の待受のように長時間メッセージが来ないことが正常な接続では、意図せず切断されないよう長めに設定するか、切断されたらアプリケーション側で再接続してください。
+`set_read_timeout()`は「1回の`read()`呼び出し」に対するタイムアウトです。メッセージが届かないまま指定時間が経過すると`read()`は`ReadResult::Timeout`を返します。このとき**接続は開いたまま**で、1バイトも読み進めていないので、そのまま送信して読み直せます。接続が失われたことを意味する`ReadResult::Fail`とはここが違います。
+
+1本の接続を1つのスレッドで双方向に扱えるのはこのためです。
+
+```cpp
+using namespace std::chrono_literals;
+
+ws.set_read_timeout(100ms);
+std::string msg;
+while (ws.is_open()) {
+  auto r = ws.read(msg);
+  if (r == httplib::ws::Timeout) {
+    flush_outgoing(ws);  // 何も届いていない。溜まっている分を送る
+    continue;
+  }
+  if (r == httplib::ws::Fail) { break; }
+  handle(msg);
+}
+```
+
+読み取りタイムアウトを設定しないと`read()`はメッセージが届くまで戻らないので、接続を持っているスレッドは送信に手が回りません。
+
+`Timeout`について2点あります。
+
+- `msg`は書き換えられません。値も0以外なので、読み取りタイムアウトを設定した状態で`while (ws.read(msg))`と書くと、**前回のメッセージ**が`msg`に残ったままループが回り続けます。
+- 報告されるのはメッセージの境界だけです。分割されたメッセージの途中でタイムアウトした場合、そのメッセージは再開できないので`read()`は`Fail`を返します。
+
+通知の待受のように長時間メッセージが来ないことが正常な接続では、読み取りタイムアウトを設定しないままにするか、`Timeout`を「まだ何も来ていない」印として扱ってループを続けてください。
+
+## サーバ側
+
+ハンドラが受け取る`ws::WebSocket`にも`set_read_timeout()`があります。ハンドラが`read()`で止まったままにならないので、上と同じ書き方で複数の接続の間をメッセージが中継できます。
+
+サーバ側のデフォルトは「無期限」ではなく300秒(`CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND`)です。WebSocketのハンドラは接続が続く限りワーカーを1つ占有するので、無言になったピアからワーカーを回収する保険として働きます。
 
 > Ping/Pongによる無応答ピア検出は別の仕組みです。詳しくは[W02. ハートビートを設定する](../w02-websocket-ping)を参照してください。
 

+ 165 - 45
httplib.h

@@ -215,8 +215,33 @@
 #define CPPHTTPLIB_WEBSOCKET_MAX_PAYLOAD_LENGTH 16777216
 #endif
 
-#ifndef CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND
-#define CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND 300
+// One macro used to set the read timeout for both sides. They want different
+// defaults: a client's read timeout is the caller's own tool (it waits forever
+// until asked not to), while a server keeps a ceiling that reclaims a worker
+// from a peer that has gone quiet. The old name still works and sets both.
+#ifdef CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND
+#pragma message(                                                               \
+    "CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND is deprecated; define "          \
+    "CPPHTTPLIB_WEBSOCKET_CLIENT_READ_TIMEOUT_SECOND and/or "                  \
+    "CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND instead")
+#ifndef CPPHTTPLIB_WEBSOCKET_CLIENT_READ_TIMEOUT_SECOND
+#define CPPHTTPLIB_WEBSOCKET_CLIENT_READ_TIMEOUT_SECOND                        \
+  CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND
+#endif
+#ifndef CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND
+#define CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND                        \
+  CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND
+#endif
+#endif
+
+// 0 waits forever. A read timeout is how a caller gets control back to send on
+// the same connection; it is not a liveness check (that is ping/pong).
+#ifndef CPPHTTPLIB_WEBSOCKET_CLIENT_READ_TIMEOUT_SECOND
+#define CPPHTTPLIB_WEBSOCKET_CLIENT_READ_TIMEOUT_SECOND 0
+#endif
+
+#ifndef CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND
+#define CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND 300
 #endif
 
 #ifndef CPPHTTPLIB_WEBSOCKET_CLOSE_TIMEOUT_SECOND
@@ -4351,7 +4376,11 @@ enum class CloseStatus : uint16_t {
   InternalError = 1011,
 };
 
-enum ReadResult : int { Fail = 0, Text = 1, Binary = 2 };
+// Timeout is returned only when a read timeout was set and it elapsed before
+// any byte of a frame arrived: nothing was consumed and the connection is
+// still open, so the caller can send on it and read again. `msg` is left
+// untouched, so a `while (ws.read(msg))` loop must not treat it as a message.
+enum ReadResult : int { Fail = 0, Text = 1, Binary = 2, Timeout = 3 };
 
 // Result of WebSocketClient::connect(). Truthy only when the WebSocket
 // upgrade handshake fully succeeded. On failure error() identifies the
@@ -4411,6 +4440,13 @@ public:
   const Request &request() const;
   bool is_open() const;
 
+  // Bound how long read() waits before returning Timeout. 0 waits forever.
+  // A server handler owns its connection's timeout this way; a client sets it
+  // through WebSocketClient. Safe to call while another thread is in read().
+  void set_read_timeout(time_t sec, time_t usec = 0);
+  template <class Rep, class Period>
+  void set_read_timeout(const std::chrono::duration<Rep, Period> &duration);
+
 private:
   friend class httplib::Server;
   friend class WebSocketClient;
@@ -4533,7 +4569,7 @@ private:
   bool is_valid_ = false;
   socket_t sock_ = INVALID_SOCKET;
   std::unique_ptr<WebSocket> ws_;
-  time_t read_timeout_sec_ = CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND;
+  time_t read_timeout_sec_ = CPPHTTPLIB_WEBSOCKET_CLIENT_READ_TIMEOUT_SECOND;
   time_t read_timeout_usec_ = 0;
   time_t write_timeout_sec_ = CPPHTTPLIB_CLIENT_WRITE_TIMEOUT_SECOND;
   time_t write_timeout_usec_ = CPPHTTPLIB_CLIENT_WRITE_TIMEOUT_USECOND;
@@ -4592,8 +4628,14 @@ namespace impl {
 
 bool is_valid_utf8(const std::string &s);
 
-bool read_websocket_frame(Stream &strm, Opcode &opcode, std::string &payload,
-                          bool &fin, bool expect_masked, size_t max_len);
+// Three states, because a failure that consumed bytes and one that consumed
+// none are not the same thing: the first has left the stream in the middle of
+// a frame and the connection cannot be reused, the second can just be retried.
+enum class FrameRead { Ok, Fail, Timeout };
+
+FrameRead read_websocket_frame(Stream &strm, Opcode &opcode,
+                               std::string &payload, bool &fin,
+                               bool expect_masked, size_t max_len);
 
 } // namespace impl
 
@@ -5512,17 +5554,42 @@ inline bool write_websocket_frame(Stream &strm, ws::Opcode opcode,
 namespace ws {
 namespace impl {
 
-inline bool read_websocket_frame(Stream &strm, Opcode &opcode,
-                                 std::string &payload, bool &fin,
-                                 bool expect_masked, size_t max_len) {
-  // Read first 2 bytes
+// Read exactly `size` bytes. Stream::read may return less than asked for -- it
+// hands back whatever its buffer already holds -- so every multi-byte field has
+// to loop. Reading a 2-byte header with a single read() fails whenever the
+// header straddles the read buffer's boundary.
+//
+// Timeout is reported only when nothing at all was consumed. Once a byte has
+// been taken the stream sits mid-field and cannot be resumed, so a timeout
+// there is a failure like any other. (When read() fails it always records why,
+// so the error belongs to this call and not to an earlier one.)
+inline FrameRead read_exact(Stream &strm, void *buf, size_t size) {
+  auto p = static_cast<char *>(buf);
+  size_t total = 0;
+  while (total < size) {
+    auto n = strm.read(p + total, size - total);
+    if (n <= 0) {
+      auto timed_out = total == 0 && strm.get_error() == Error::Timeout;
+      return timed_out ? FrameRead::Timeout : FrameRead::Fail;
+    }
+    total += static_cast<size_t>(n);
+  }
+  return FrameRead::Ok;
+}
+
+inline FrameRead read_websocket_frame(Stream &strm, Opcode &opcode,
+                                      std::string &payload, bool &fin,
+                                      bool expect_masked, size_t max_len) {
+  // Read first 2 bytes. This is the only read that may report a timeout: it
+  // sits on a frame boundary, where nothing has been consumed yet.
   uint8_t header[2];
-  if (strm.read(reinterpret_cast<char *>(header), 2) != 2) { return false; }
+  FrameRead first = read_exact(strm, header, 2);
+  if (first != FrameRead::Ok) { return first; }
 
   fin = (header[0] & 0x80) != 0;
 
   // RSV1, RSV2, RSV3 must be 0 when no extension is negotiated
-  if (header[0] & 0x70) { return false; }
+  if (header[0] & 0x70) { return FrameRead::Fail; }
 
   opcode = static_cast<Opcode>(header[0] & 0x0F);
   bool masked = (header[1] & 0x80) != 0;
@@ -5532,46 +5599,44 @@ inline bool read_websocket_frame(Stream &strm, Opcode &opcode,
   // MUST have a payload length of 125 bytes or less
   bool is_control = (static_cast<uint8_t>(opcode) & 0x08) != 0;
   if (is_control) {
-    if (!fin) { return false; }
-    if (payload_len > 125) { return false; }
+    if (!fin) { return FrameRead::Fail; }
+    if (payload_len > 125) { return FrameRead::Fail; }
   }
 
-  if (masked != expect_masked) { return false; }
+  if (masked != expect_masked) { return FrameRead::Fail; }
 
   // Extended payload length
   if (payload_len == 126) {
     uint8_t ext[2];
-    if (strm.read(reinterpret_cast<char *>(ext), 2) != 2) { return false; }
+    if (read_exact(strm, ext, 2) != FrameRead::Ok) { return FrameRead::Fail; }
     payload_len = (static_cast<uint64_t>(ext[0]) << 8) | ext[1];
   } else if (payload_len == 127) {
     uint8_t ext[8];
-    if (strm.read(reinterpret_cast<char *>(ext), 8) != 8) { return false; }
+    if (read_exact(strm, ext, 8) != FrameRead::Ok) { return FrameRead::Fail; }
     // RFC 6455 Section 5.2: the most significant bit MUST be 0
-    if (ext[0] & 0x80) { return false; }
+    if (ext[0] & 0x80) { return FrameRead::Fail; }
     payload_len = 0;
     for (int i = 0; i < 8; i++) {
       payload_len = (payload_len << 8) | ext[i];
     }
   }
 
-  if (payload_len > max_len) { return false; }
+  if (payload_len > max_len) { return FrameRead::Fail; }
 
   // Read mask key if present
   uint8_t mask_key[4] = {0};
   if (masked) {
-    if (strm.read(reinterpret_cast<char *>(mask_key), 4) != 4) { return false; }
+    if (read_exact(strm, mask_key, 4) != FrameRead::Ok) {
+      return FrameRead::Fail;
+    }
   }
 
   // Read payload
   payload.resize(static_cast<size_t>(payload_len));
-  if (payload_len > 0) {
-    size_t total_read = 0;
-    while (total_read < payload_len) {
-      auto n = strm.read(&payload[total_read],
-                         static_cast<size_t>(payload_len - total_read));
-      if (n <= 0) { return false; }
-      total_read += static_cast<size_t>(n);
-    }
+  if (payload_len > 0 &&
+      read_exact(strm, &payload[0], static_cast<size_t>(payload_len)) !=
+          FrameRead::Ok) {
+    return FrameRead::Fail;
   }
 
   // Unmask if needed
@@ -5581,7 +5646,7 @@ inline bool read_websocket_frame(Stream &strm, Opcode &opcode,
     }
   }
 
-  return true;
+  return FrameRead::Ok;
 }
 
 } // namespace impl
@@ -6328,7 +6393,10 @@ inline ssize_t select_impl(socket_t sock, short events, time_t sec,
   pfd.events = events;
   pfd.revents = 0;
 
-  auto timeout = static_cast<int>(sec * 1000 + usec / 1000);
+  // A negative timeout waits forever, poll's own convention. 0 keeps meaning
+  // "return immediately", which callers here rely on to probe a socket.
+  auto timeout =
+      sec < 0 ? -1 : static_cast<int>(sec * 1000 + usec / 1000);
 
   return handle_EINTR([&]() { return poll_wrapper(&pfd, 1, timeout); });
 }
@@ -6410,8 +6478,11 @@ private:
   bool ensure_readable();
 
   socket_t sock_;
-  time_t read_timeout_sec_;
-  time_t read_timeout_usec_;
+  // Atomic because ws::WebSocket::set_read_timeout() reaches this from another
+  // thread while a read is in flight -- that is the point of it, for a caller
+  // holding one connection and wanting control back to send on it.
+  std::atomic<time_t> read_timeout_sec_;
+  std::atomic<time_t> read_timeout_usec_;
   time_t write_timeout_sec_;
   time_t write_timeout_usec_;
   time_t max_timeout_msec_;
@@ -12604,12 +12675,19 @@ inline ssize_t WebSocketSSLStream::read(char *ptr, size_t size) {
         needs_readable || (err.code == tls::ErrorCode::SyscallError &&
                            WSAGetLastError() == WSAETIMEDOUT);
 #endif
-    if (!needs_readable && err.code != tls::ErrorCode::WantWrite) { return -1; }
+    if (!needs_readable && err.code != tls::ErrorCode::WantWrite) {
+      error_ = Error::Read;
+      return -1;
+    }
     if (!(needs_readable ? wait_readable() : wait_writable())) {
       error_ = Error::Timeout;
       return -1;
     }
   }
+  // Out of retries. Recording a reason matters: a caller that reads get_error()
+  // to tell a timeout from a close would otherwise see whatever the previous
+  // failure left behind (error_ is never cleared on success).
+  error_ = Error::Read;
   return -1;
 }
 
@@ -14352,7 +14430,7 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
             auto ws_strm =
                 std::unique_ptr<Stream>(new detail::WebSocketSSLStream(
                     strm.socket(), const_cast<tls::session_t>(req.ssl),
-                    CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND, 0,
+                    CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND, 0,
                     write_timeout_sec_, write_timeout_usec_));
             ws::WebSocket ws(std::move(ws_strm), req, true,
                              websocket_ping_interval_sec_,
@@ -14362,7 +14440,8 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
           }
 #endif
           // Use WebSocket-specific read timeout instead of HTTP timeout
-          strm.set_read_timeout(CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND, 0);
+          strm.set_read_timeout(CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND,
+                                0);
           ws::WebSocket ws(strm, req, true, websocket_ping_interval_sec_,
                            websocket_max_missed_pongs_);
           entry.handler(req, ws);
@@ -22180,8 +22259,13 @@ inline ReadResult WebSocket::read(std::string &msg) {
     std::string payload;
     bool fin;
 
-    if (!impl::read_websocket_frame(strm_, opcode, payload, fin, is_server_,
-                                    CPPHTTPLIB_WEBSOCKET_MAX_PAYLOAD_LENGTH)) {
+    impl::FrameRead r =
+        impl::read_websocket_frame(strm_, opcode, payload, fin, is_server_,
+                                   CPPHTTPLIB_WEBSOCKET_MAX_PAYLOAD_LENGTH);
+    // A timeout landed on a frame boundary: the connection is untouched and
+    // still usable, so hand control back without closing it.
+    if (r == impl::FrameRead::Timeout) { return Timeout; }
+    if (r != impl::FrameRead::Ok) {
       closed_ = true;
       return Fail;
     }
@@ -22218,9 +22302,14 @@ inline ReadResult WebSocket::read(std::string &msg) {
           Opcode cont_opcode;
           std::string cont_payload;
           bool cont_fin;
-          if (!impl::read_websocket_frame(
+          // A timeout is not reportable here: half of a fragmented message is
+          // already in `msg` and read() has no way to resume it, so it is a
+          // failure like any other. Timeouts are only ever seen on a message
+          // boundary.
+          if (impl::read_websocket_frame(
                   strm_, cont_opcode, cont_payload, cont_fin, is_server_,
-                  CPPHTTPLIB_WEBSOCKET_MAX_PAYLOAD_LENGTH)) {
+                  CPPHTTPLIB_WEBSOCKET_MAX_PAYLOAD_LENGTH) !=
+              impl::FrameRead::Ok) {
             closed_ = true;
             return Fail;
           }
@@ -22314,7 +22403,8 @@ inline void WebSocket::close(CloseStatus status, const std::string &reason) {
   Opcode op;
   std::string resp;
   bool fin;
-  while (impl::read_websocket_frame(strm_, op, resp, fin, is_server_, 125)) {
+  while (impl::read_websocket_frame(strm_, op, resp, fin, is_server_, 125) ==
+         impl::FrameRead::Ok) {
     if (op == Opcode::Close) { break; }
   }
 }
@@ -22359,6 +22449,21 @@ inline const Request &WebSocket::request() const { return req_; }
 
 inline bool WebSocket::is_open() const { return !closed_; }
 
+inline void WebSocket::set_read_timeout(time_t sec, time_t usec) {
+  // 0 waits forever here, as it does for SO_RCVTIMEO. The stream waits with
+  // poll(), where 0 would instead mean "return immediately", so hand it the
+  // negative poll uses for an unbounded wait.
+  if (sec == 0 && usec == 0) { sec = -1; }
+  strm_.set_read_timeout(sec, usec);
+}
+
+template <class Rep, class Period>
+inline void WebSocket::set_read_timeout(
+    const std::chrono::duration<Rep, Period> &duration) {
+  detail::duration_to_sec_and_usec(
+      duration, [&](time_t sec, time_t usec) { set_read_timeout(sec, usec); });
+}
+
 // WebSocketClient implementation
 inline WebSocketClient::WebSocketClient(
     const std::string &scheme_host_port_path, const Headers &headers)
@@ -22461,6 +22566,16 @@ inline void WebSocketClient::shutdown_and_close() {
 inline bool WebSocketClient::create_stream(std::unique_ptr<Stream> &strm,
                                            Error &error, int &ssl_error,
                                            uint64_t &ssl_backend_error) {
+  // A read timeout of 0 means "wait forever", the way SO_RCVTIMEO reads it.
+  // The streams wait with poll(), where 0 instead means "return immediately",
+  // so they are given the negative poll uses for an unbounded wait.
+  auto unbounded = read_timeout_sec_ == 0 && read_timeout_usec_ == 0;
+  time_t strm_read_sec = unbounded ? -1 : read_timeout_sec_;
+  time_t strm_read_usec = unbounded ? 0 : read_timeout_usec_;
+  // The handshake belongs to establishing the connection, so an unset read
+  // timeout leaves it bounded by the connection timeout instead of forever.
+  time_t hs_sec = unbounded ? connection_timeout_sec_ : read_timeout_sec_;
+  time_t hs_usec = unbounded ? connection_timeout_usec_ : read_timeout_usec_;
 #ifdef CPPHTTPLIB_SSL_ENABLED
   if (is_ssl_) {
     // A plain flag rather than SSLClient::load_certs()'s call_once: connect()
@@ -22480,8 +22595,8 @@ inline bool WebSocketClient::create_stream(std::unique_ptr<Stream> &strm,
     detail::ClientTlsSessionError tls_error;
     if (!detail::setup_client_tls_session(host_, tls_ctx_, tls_session_, sock_,
                                           server_certificate_verification_,
-                                          read_timeout_sec_, read_timeout_usec_,
-                                          &tls_error, options)) {
+                                          hs_sec, hs_usec, &tls_error,
+                                          options)) {
       error = tls_error.error;
       ssl_error = tls_error.ssl_error;
       ssl_backend_error = tls_error.backend_error;
@@ -22489,17 +22604,19 @@ inline bool WebSocketClient::create_stream(std::unique_ptr<Stream> &strm,
     }
 
     strm = std::unique_ptr<Stream>(new detail::WebSocketSSLStream(
-        sock_, tls_session_, read_timeout_sec_, read_timeout_usec_,
-        write_timeout_sec_, write_timeout_usec_));
+        sock_, tls_session_, strm_read_sec, strm_read_usec, write_timeout_sec_,
+        write_timeout_usec_));
     return true;
   }
 #else
   (void)error;
   (void)ssl_error;
   (void)ssl_backend_error;
+  (void)hs_sec;
+  (void)hs_usec;
 #endif
   strm = std::unique_ptr<Stream>(
-      new detail::SocketStream(sock_, read_timeout_sec_, read_timeout_usec_,
+      new detail::SocketStream(sock_, strm_read_sec, strm_read_usec,
                                write_timeout_sec_, write_timeout_usec_));
   return true;
 }
@@ -22601,6 +22718,9 @@ inline const std::string &WebSocketClient::subprotocol() const {
 inline void WebSocketClient::set_read_timeout(time_t sec, time_t usec) {
   read_timeout_sec_ = sec;
   read_timeout_usec_ = usec;
+  // The members above only seed the next connect(); read() consults the
+  // stream, so an already-open connection has to be told directly.
+  if (ws_) { ws_->set_read_timeout(sec, usec); }
 }
 
 inline void WebSocketClient::set_write_timeout(time_t sec, time_t usec) {

+ 175 - 26
test/test.cc

@@ -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
test/test_websocket_heartbeat.cc

@@ -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"