Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/spec/core/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -2407,6 +2407,31 @@ not a behavior change to the existing loopback-only default.
| Member | Type | Default |
|---|---|---|
| `backlog` | `int` | `64` |
| `handshakeTimeout` | `std::chrono::milliseconds` | `10 s` |
| `sendTimeout` | `std::chrono::milliseconds` | `30 s` |

`handshakeTimeout` bounds the whole RFC 6455 Upgrade read (accept to the
terminating `\r\n\r\n`), not each individual `recv` the way
`SocketBackendConfig::handshakeTimeout` (`SO_RCVTIMEO`) does — a peer that
never sends a complete request has no legitimate reason to behave that way, so
this bound carries no false-positive cost and is enforced as a single deadline
across `readHttpHeaderBlock`'s read loop, via `poll()` with the remaining
budget on each iteration, rather than a socket option that would only bound
each read and let a byte-at-a-time peer stretch the total to
`handshakeTimeout` times the header's 64 KiB cap. `sendTimeout` is `SO_SNDTIMEO`
on the accepted socket (the same mechanism and default as
`SocketBackendConfig::sendTimeout`, applied once in `acceptLoop` and never
cleared — unlike the client side, a `SocketServer` connection's steady-state
replies are meant to be bounded too): without it, a peer that stops reading
fills the kernel send buffer and parks whichever `RemoteServer` worker-pool
thread is calling `ClientConnection::sendText` forever, and that method's
existing failed-send handling (`closed.store(true); socket.shutdownBoth()`) —
which is what stops the connection from continuing to execute actions whose
replies go nowhere — never gets a chance to run. Both are `0`-disables opt-outs
that restore the pre-existing block-forever behavior; deliberately not opt-in,
since neither has the false-positive risk that ruled out an `idleTimeout`
(reaping an idle-by-design desktop client with no keepalive to tell "idle"
from "dead" apart).

### `SocketServer` (namespace `morph::net`)

Expand Down
76 changes: 67 additions & 9 deletions include/morph/net/detail/ws_handshake.hpp
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
// SPDX-License-Identifier: Apache-2.0

#pragma once
#include <poll.h>

#include <algorithm>
#include <array>
#include <cerrno>
#include <chrono>
#include <cstdint>
#include <limits>
#include <random>
#include <stdexcept>
#include <string>
#include <string_view>
#include <system_error>
#include <vector>

#include "base64.hpp"
Expand Down Expand Up @@ -263,16 +270,61 @@ struct HandshakeReadResult {
std::string leftover;
};

/// Blocks until @p socket is readable or @p deadline passes, retrying across
/// `EINTR` by recomputing what remains rather than re-arming the full wait --
/// mirrors `TcpSocket::connect()`'s own remaining-budget poll loop. Split out
/// of `readHttpHeaderBlock` so that function's own branching stays readable.
/// @throws std::runtime_error once @p deadline passes, or on a `poll()` error
/// other than `EINTR`.
inline void waitReadableUntil(const TcpSocket& socket, std::chrono::steady_clock::time_point deadline) {
for (;;) {
auto const remaining =
std::chrono::duration_cast<std::chrono::milliseconds>(deadline - std::chrono::steady_clock::now());
if (remaining.count() <= 0) {
throw std::runtime_error("readHttpHeaderBlock: handshake timed out");
}
auto const waitMs = static_cast<int>(
std::min<std::chrono::milliseconds::rep>(remaining.count(), std::numeric_limits<int>::max()));
pollfd pfd{};
pfd.fd = socket.nativeHandle();
pfd.events = POLLIN;
int const pollRc = ::poll(&pfd, 1, waitMs);
if (pollRc > 0) {
return; // readable (or hung up) -- the caller's recvSome will not block
}
if (pollRc == 0) {
throw std::runtime_error("readHttpHeaderBlock: handshake timed out");
}
if (errno != EINTR) {
throw std::runtime_error("readHttpHeaderBlock: poll failed: " + std::system_category().message(errno));
}
// EINTR: loop back and recompute the remaining budget.
}
}

/// @brief Reads bytes from @p socket until the `\r\n\r\n` header terminator.
/// @param socket Connected socket to read from.
/// @param socket Connected socket to read from.
/// @param timeout Bound on the *whole* read, from the first byte to the
/// terminator; zero disables it (block indefinitely). Enforced
/// by polling for readiness with the remaining budget before
/// each `recvSome` -- deliberately not `SO_RCVTIMEO`, which
/// restarts on every `recv` and so only bounds each individual
/// read: a peer trickling one byte per interval could then
/// stretch the whole handshake to roughly @p timeout times the
/// 64 KiB header cap below. A single deadline across the loop
/// keeps @p timeout an honest bound on the total.
/// @return The header text and any leftover bytes read past the terminator.
/// @throws std::runtime_error if the peer closes before completing the header,
/// or if the header exceeds a ~64 KiB safety cap. The check runs before
/// if the header exceeds a ~64 KiB safety cap (the check runs before
/// each `recvSome`, so the true bound is 64 KiB rounded up to the next
/// read chunk (tests/net/test_handshake_over_socket.cpp says the same).
inline HandshakeReadResult readHttpHeaderBlock(TcpSocket& socket) {
/// read chunk -- tests/net/test_handshake_over_socket.cpp says the
/// same), or if @p timeout elapses before the terminator arrives.
inline HandshakeReadResult readHttpHeaderBlock(TcpSocket& socket,
std::chrono::milliseconds timeout = std::chrono::milliseconds{0}) {
std::string buf;
char chunk[4096];
bool const bounded = timeout.count() > 0;
auto const deadline = std::chrono::steady_clock::now() + timeout;
for (;;) {
auto pos = buf.find("\r\n\r\n");
if (pos != std::string::npos) {
Expand All @@ -284,6 +336,9 @@ inline HandshakeReadResult readHttpHeaderBlock(TcpSocket& socket) {
if (buf.size() > 64u * 1024u) {
throw std::runtime_error("readHttpHeaderBlock: header exceeds maximum size (64 KiB)");
}
if (bounded) {
waitReadableUntil(socket, deadline);
}
std::size_t got = socket.recvSome(chunk, sizeof(chunk));
if (got == 0) {
throw std::runtime_error("readHttpHeaderBlock: peer closed connection during handshake");
Expand All @@ -303,19 +358,22 @@ inline std::string performClientHandshake(TcpSocket& socket, const ParsedWsUrl&
std::string key = generateClientKey();
std::string request = buildClientHandshakeRequest(url, key);
socket.sendAll(request.data(), request.size());
HandshakeReadResult result = readHttpHeaderBlock(socket);
HandshakeReadResult const result = readHttpHeaderBlock(socket);
verifyServerHandshakeResponse(result.header, key);
return result.leftover;
}

/// @brief Performs the server side of the WebSocket handshake over @p socket.
/// @param socket Freshly accepted socket.
/// @param socket Freshly accepted socket.
/// @param handshakeTimeout Bound on the whole handshake read; see
/// `readHttpHeaderBlock`. Zero disables it.
/// @return Leftover bytes read past the request header — feed these into a
/// `WsFrameReader` before reading further from the socket.
/// @throws std::runtime_error if the client's request is malformed or missing
/// the required headers.
inline std::string performServerHandshake(TcpSocket& socket) {
HandshakeReadResult result = readHttpHeaderBlock(socket);
/// the required headers, or if @p handshakeTimeout elapses first.
inline std::string performServerHandshake(TcpSocket& socket,
std::chrono::milliseconds handshakeTimeout = std::chrono::milliseconds{0}) {
HandshakeReadResult const result = readHttpHeaderBlock(socket, handshakeTimeout);
ClientHandshakeRequest req = parseClientHandshakeRequest(result.header);
std::string response = buildServerHandshakeResponse(req.key);
socket.sendAll(response.data(), response.size());
Expand Down
29 changes: 27 additions & 2 deletions include/morph/net/socket_server.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include <array>
#include <atomic>
#include <cerrno>
#include <chrono>
#include <cstdint>
#include <memory>
#include <morph/core/remote.hpp>
Expand All @@ -28,6 +29,22 @@ namespace morph::net {
struct SocketServerConfig {
/// @brief Pending-connection backlog passed to the listening socket.
int backlog = 64;

/// @brief Bound on completing the RFC 6455 Upgrade handshake, from
/// accept() to the request's terminating `\r\n\r\n`. See
/// `readHttpHeaderBlock`'s `timeout` parameter for how this is enforced
/// and why (deliberately not `SocketBackendConfig::handshakeTimeout`'s
/// `SO_RCVTIMEO` approach). Zero disables it (the previous behavior:
/// block forever).
std::chrono::milliseconds handshakeTimeout{10000};

/// @brief Bound on a single `::send` that is making no progress, applied
/// to every accepted connection via `SO_SNDTIMEO` (see `acceptLoop`).
/// Without it, a peer that stops reading parks whichever thread is
/// replying in `sendAll` forever, and `ClientConnection::sendText`'s
/// existing failed-send handling never gets a chance to run. Zero
/// disables it (the previous behavior: block forever).
std::chrono::milliseconds sendTimeout{30000};
};

/// @brief Raw-socket WebSocket server front for `morph::backend::RemoteServer`.
Expand Down Expand Up @@ -60,7 +77,8 @@ class SocketServer {
/// `docs/spec/concurrency_and_lifetimes.md`, "Destruction
/// ordering").
/// @param port TCP port to listen on. Pass 0 to let the OS pick a free port.
/// @param cfg Backlog tuning. Default: 64-connection backlog.
/// @param cfg Backlog and timeout tuning. Default: 64-connection
/// backlog, 10s handshake timeout, 30s send timeout.
// Copy/move are implicitly deleted by the non-copyable/non-movable
// std::mutex/std::thread members below — no explicit `= delete` needed
// (matches the rest of the codebase's convention, e.g. `LocalBackend`).
Expand Down Expand Up @@ -270,6 +288,13 @@ class SocketServer {
if (_closing.load()) {
return;
}
if (_cfg.sendTimeout.count() > 0) {
// See `SocketServerConfig::sendTimeout`. Applies to both
// `ClientConnection::sendText()` and `sendControlFrame()`,
// which share this socket. Best-effort, like every other
// socket-option application in this class.
static_cast<void>(clientSocket->setSendTimeout(_cfg.sendTimeout));
}
// Before taking on another one: nothing else removes a finished
// connection, so without this an fd and a joinable thread handle
// accumulate per connection *ever accepted*, not per live
Expand Down Expand Up @@ -357,7 +382,7 @@ class SocketServer {

std::string leftover;
try {
leftover = ::morph::net::detail::performServerHandshake(conn->socket);
leftover = ::morph::net::detail::performServerHandshake(conn->socket, _cfg.handshakeTimeout);
} catch (const std::exception&) {
conn->closed.store(true);
return;
Expand Down
Loading
Loading