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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Non-blocking epoll connect precondition**: the epoll backend now rejects
`async_connect()` on a blocking socket with `-EINVAL` before `connect(2)` can
block a scheduler worker. Direct callers must set `O_NONBLOCK` first (#997).
- **Bounded raw WebSocket parsing by default**: `websocket::frame_parser` now
applies the same 16 MiB aggregate message limit as the high-level client and
server configurations. Direct parser users must explicitly set a limit of
Expand Down
13 changes: 13 additions & 0 deletions include/elio/io/epoll_backend.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,19 @@ class epoll_backend : public io_backend {
break;

case io_op::connect: {
const int fd_flags = ::fcntl(req.fd, F_GETFL);
if (fd_flags < 0) {
return queue_ready_op(std::move(op),
io_result{-errno, 0});
}
if ((fd_flags & O_NONBLOCK) == 0) {
// connect(2) may block the scheduler worker unless the
// caller keeps the socket non-blocking. Do not silently
// mutate the shared open-file description here.
return queue_ready_op(std::move(op),
io_result{-EINVAL, 0});
}

socklen_t addrlen = req.addrlen ? *req.addrlen : 0;
if (::connect(req.fd, req.addr, addrlen) == 0) {
return queue_ready_op(std::move(op), io_result{0, 0});
Expand Down
49 changes: 49 additions & 0 deletions tests/unit/test_io.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1450,6 +1450,55 @@ TEST_CASE("epoll_backend async connect initiates TCP handshake",
close(listen_fd);
}

TEST_CASE("epoll_backend rejects blocking async connect sockets",
"[io][epoll][connect][nonblocking]") {
int client_fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
REQUIRE(client_fd >= 0);
const int fd_flags = fcntl(client_fd, F_GETFL);
REQUIRE(fd_flags >= 0);
REQUIRE((fd_flags & O_NONBLOCK) == 0);

struct sockaddr_in target{};
target.sin_family = AF_INET;
target.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
target.sin_port = htons(9);
socklen_t target_len = sizeof(target);

epoll_backend backend;
io_request req{};
req.op = io_op::connect;
req.fd = client_fd;
req.addr = reinterpret_cast<struct sockaddr*>(&target);
req.addrlen = &target_len;
req.awaiter = std::noop_coroutine();

REQUIRE(backend.prepare(req));
REQUIRE(backend.poll(std::chrono::milliseconds(0)) == 1);
REQUIRE(epoll_backend::get_last_result().result == -EINVAL);
REQUIRE_FALSE(backend.has_pending());

REQUIRE(close(client_fd) == 0);
}

TEST_CASE("epoll_backend preserves fcntl errors for async connect",
"[io][epoll][connect][nonblocking]") {
epoll_backend backend;
struct sockaddr_in target{};
socklen_t target_len = sizeof(target);

io_request req{};
req.op = io_op::connect;
req.fd = -1;
req.addr = reinterpret_cast<struct sockaddr*>(&target);
req.addrlen = &target_len;
req.awaiter = std::noop_coroutine();

REQUIRE(backend.prepare(req));
REQUIRE(backend.poll(std::chrono::milliseconds(0)) == 1);
REQUIRE(epoll_backend::get_last_result().result == -EBADF);
REQUIRE_FALSE(backend.has_pending());
}

TEST_CASE("epoll_backend async connect preserves refused SO_ERROR",
"[io][epoll][connect][regression]") {
int reserve_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
Expand Down
2 changes: 1 addition & 1 deletion wiki/API-Contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ requests does not make their higher-level ordering or object lifetime safe.
| `io::async_recv()` and `io::async_send()` | Submit one socket receive/send request and report actual transfer or errno. Socket sends suppress process-level `SIGPIPE` where the platform provides per-call suppression. Cancellable overloads honor the provided token at the documented wait points. | Keep the socket fd and buffer alive until completion. Treat short socket I/O and zero-length peer close according to protocol needs. |
| `io::async_sendmsg()` | Submits one socket scatter-gather send over the supplied `iovec` array and reports actual transfer or errno. It is not a general `sendmsg(2)` wrapper for destination addresses or ancillary/control data. It suppresses process-level `SIGPIPE` where the platform provides per-call suppression. | Keep the socket fd, `iovec` array, and referenced buffers alive until completion. Handle short socket writes, transient readiness errors, and protocol-level retry/advance logic. |
| `io::async_accept()` | Submits one accept operation and returns the accepted fd in `io_result::result` on success. | Keep the listening fd, optional address storage, and optional `socklen_t` storage alive until completion. Close or wrap the accepted fd on every success path. |
| `io::async_connect()` | Submits one connect operation for the supplied fd and sockaddr. Cancellable overloads honor the provided token at the documented wait points. | Keep the fd and sockaddr storage valid until completion. Check the result before using the socket as connected. |
| `io::async_connect()` | Submits one connect operation for the supplied fd and sockaddr. Cancellable overloads honor the provided token at the documented wait points. The epoll backend rejects a blocking socket with `-EINVAL` before calling `connect(2)` so a scheduler worker cannot block. | Keep the fd and sockaddr storage valid until completion. When epoll may be selected, set `O_NONBLOCK` before submission and do not clear it until completion. Check the result before using the socket as connected. |
| `io::async_close()` | Submits fd close through the backend where supported and reports the close result. | Do not reuse or close the same fd concurrently through another path. Treat close ordering with in-flight operations as caller-owned unless a higher-level wrapper documents safe teardown. |
| `io::async_poll_read()` and `io::async_poll_write()` | Wait for readiness according to the active backend and return readiness/error status. Cancellable overloads honor the provided token. | Keep the fd alive while polling and re-check the actual operation result after readiness. |
| `io::batch_read_segment` and `io::batch_write_segment` | Describe per-segment file offsets, buffers, and lengths for batch helpers. | Ensure segment buffers remain valid and that overlapping output regions are intentional and externally synchronized. |
Expand Down
2 changes: 2 additions & 0 deletions wiki/API-Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1459,6 +1459,8 @@ if (accept_result.result >= 0) {
}

// Connect
// Direct callers must keep fd non-blocking when the epoll backend may be used.
// A blocking socket is rejected with result.result == -EINVAL.
auto result = co_await async_connect(fd, addr, addrlen);

// Close
Expand Down