diff --git a/README-websocket.md b/README-websocket.md index 204114b..e52265e 100644 --- a/README-websocket.md +++ b/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 diff --git a/docs-src/pages/en/cookbook/w01-websocket-echo.md b/docs-src/pages/en/cookbook/w01-websocket-echo.md index 0ff515d..ffe96d6 100644 --- a/docs-src/pages/en/cookbook/w01-websocket-echo.md +++ b/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 diff --git a/docs-src/pages/en/cookbook/w02-websocket-ping.md b/docs-src/pages/en/cookbook/w02-websocket-ping.md index 3348dea..2a34bb1 100644 --- a/docs-src/pages/en/cookbook/w02-websocket-ping.md +++ b/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). diff --git a/docs-src/pages/en/cookbook/w04-websocket-binary.md b/docs-src/pages/en/cookbook/w04-websocket-binary.md index fbdde41..12eedf2 100644 --- a/docs-src/pages/en/cookbook/w04-websocket-binary.md +++ b/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; } ``` diff --git a/docs-src/pages/en/cookbook/w06-websocket-timeouts.md b/docs-src/pages/en/cookbook/w06-websocket-timeouts.md index 3ea3796..1f7742b 100644 --- a/docs-src/pages/en/cookbook/w06-websocket-timeouts.md +++ b/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. diff --git a/docs-src/pages/ja/cookbook/w01-websocket-echo.md b/docs-src/pages/ja/cookbook/w01-websocket-echo.md index 0dcea3f..3067a8a 100644 --- a/docs-src/pages/ja/cookbook/w01-websocket-echo.md +++ b/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)を参照) ## クライアント: エコーを叩く diff --git a/docs-src/pages/ja/cookbook/w02-websocket-ping.md b/docs-src/pages/ja/cookbook/w02-websocket-ping.md index 7190e4e..c523f72 100644 --- a/docs-src/pages/ja/cookbook/w02-websocket-ping.md +++ b/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)を参照してください。 diff --git a/docs-src/pages/ja/cookbook/w04-websocket-binary.md b/docs-src/pages/ja/cookbook/w04-websocket-binary.md index dad12e1..ec08370 100644 --- a/docs-src/pages/ja/cookbook/w04-websocket-binary.md +++ b/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; } ``` diff --git a/docs-src/pages/ja/cookbook/w06-websocket-timeouts.md b/docs-src/pages/ja/cookbook/w06-websocket-timeouts.md index 6653e93..ccbb1d5 100644 --- a/docs-src/pages/ja/cookbook/w06-websocket-timeouts.md +++ b/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)を参照してください。 diff --git a/httplib.h b/httplib.h index a13de9c..ac9b864 100644 --- a/httplib.h +++ b/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 + void set_read_timeout(const std::chrono::duration &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 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(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(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(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(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(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(ext), 2) != 2) { return false; } + if (read_exact(strm, ext, 2) != FrameRead::Ok) { return FrameRead::Fail; } payload_len = (static_cast(ext[0]) << 8) | ext[1]; } else if (payload_len == 127) { uint8_t ext[8]; - if (strm.read(reinterpret_cast(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(mask_key), 4) != 4) { return false; } + if (read_exact(strm, mask_key, 4) != FrameRead::Ok) { + return FrameRead::Fail; + } } // Read payload payload.resize(static_cast(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(payload_len - total_read)); - if (n <= 0) { return false; } - total_read += static_cast(n); - } + if (payload_len > 0 && + read_exact(strm, &payload[0], static_cast(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(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(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 read_timeout_sec_; + std::atomic 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(new detail::WebSocketSSLStream( strm.socket(), const_cast(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 +inline void WebSocket::set_read_timeout( + const std::chrono::duration &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 &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 &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 &strm, } strm = std::unique_ptr(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( - 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) { diff --git a/test/test.cc b/test/test.cc index 014b756..cbc0140 100644 --- a/test/test.cc +++ b/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::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(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(0x81); // FIN + Text + frame += static_cast(0x80 | 126); // masked, 16-bit length follows + frame += static_cast(body.size() >> 8); + frame += static_cast(body.size() & 0xff); + for (size_t i = 0; i < 4; i++) { + frame += static_cast(mask[i]); + } + for (size_t i = 0; i < body.size(); i++) { + frame += static_cast(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; diff --git a/test/test_websocket_heartbeat.cc b/test/test_websocket_heartbeat.cc index 4aa236a..389f70b 100644 --- a/test/test_websocket_heartbeat.cc +++ b/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 #include "gtest/gtest.h"