From ed3e79fc7e42b4d3a8048ae88640619123cb32e1 Mon Sep 17 00:00:00 2001 From: Coldwings Date: Tue, 21 Jul 2026 22:41:46 +0800 Subject: [PATCH] fix(io): reject blocking epoll connects --- CHANGELOG.md | 3 ++ include/elio/io/epoll_backend.hpp | 13 ++++++++ tests/unit/test_io.cpp | 49 +++++++++++++++++++++++++++++++ wiki/API-Contracts.md | 2 +- wiki/API-Reference.md | 2 ++ 5 files changed, 68 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d8bb339..f4582e80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/include/elio/io/epoll_backend.hpp b/include/elio/io/epoll_backend.hpp index aa194d2d..96433d80 100644 --- a/include/elio/io/epoll_backend.hpp +++ b/include/elio/io/epoll_backend.hpp @@ -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}); diff --git a/tests/unit/test_io.cpp b/tests/unit/test_io.cpp index 5523e332..1c915284 100644 --- a/tests/unit/test_io.cpp +++ b/tests/unit/test_io.cpp @@ -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(&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(&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); diff --git a/wiki/API-Contracts.md b/wiki/API-Contracts.md index 208d7d95..a9ba1951 100644 --- a/wiki/API-Contracts.md +++ b/wiki/API-Contracts.md @@ -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. | diff --git a/wiki/API-Reference.md b/wiki/API-Reference.md index dd84b81f..db9cc89b 100644 --- a/wiki/API-Reference.md +++ b/wiki/API-Reference.md @@ -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