diff --git a/doc/modules/ROOT/pages/4.guide/4r.wait.adoc b/doc/modules/ROOT/pages/4.guide/4r.wait.adoc index 09842d4d0..4f21c316f 100644 --- a/doc/modules/ROOT/pages/4.guide/4r.wait.adoc +++ b/doc/modules/ROOT/pages/4.guide/4r.wait.adoc @@ -45,29 +45,35 @@ include::example$snippets/4r_wait.cpp[tag=wait_read,indent=0] == Wrapping a Nonblocking C API The original motivation is libraries such as libssh and libpq that -manage their own buffers on an `O_NONBLOCK` fd. They need a "tell me -when the fd is ready" primitive that does not steal bytes from the -stream. +manage their own buffers and do their own I/O on an `O_NONBLOCK` +socket. They need two things from the surrounding event loop: "tell +me when the fd is ready" without stealing bytes from the stream, and +"never touch my descriptor". + +`wait()` provides the first: it never reads, writes, or consumes the +socket's pending error, so the library's next `PQconsumeInput` (or +equivalent) sees everything the kernel has delivered. Adoption +provides the second, with one rule to follow: `assign()` takes +ownership and will close the descriptor, so adopt a `dup()` of the +library's fd rather than the fd itself. Readiness lives on the open +file description, which both descriptors share — the duplicate +reports exactly the library's readiness, and closing it can never +close the library's connection. Neither `assign()` nor `wait()` +alters the descriptor's flags, so the library's non-blocking +configuration is untouched. -The typical pattern (sketched against a hypothetical libpq -integration): - -[source,cpp,role=pseudocode] +[source,cpp] ---- -// pq is some PG connection holding a nonblocking socket fd. -corosio::tcp_socket sock = adopt_fd(ioc, PQsocket(pq)); - -while (PQisBusy(pq)) { - auto [ec] = co_await sock.wait(corosio::wait_type::read); - if (ec) co_return ec; - if (PQconsumeInput(pq) == 0) - co_return last_pq_error(pq); -} +include::example$snippets/4r_wait.cpp[tag=foreign_adopt,indent=0] ---- -Because `wait()` does not call `recv()`, the C library's next -`PQconsumeInput` (or equivalent) sees all the data the kernel has -delivered. +Never call `read_some()` or `write_some()` on the adopted socket — +the library owns the byte stream; corosio supplies readiness only. + +On Windows, `dup()` does not duplicate a `SOCKET`. Either adopt the +library's socket directly and `release()` it before the library needs +exclusive ownership again, or create a true duplicate with +`WSADuplicateSocketW` and adopt that. == Acceptors @@ -85,6 +91,10 @@ This is useful when application-level conditions must be checked before consuming the next connection (rate limiting, backpressure signaling) without holding an `accept()` call open. +A connection already queued when the wait begins completes it +immediately — including on an adopted listener whose backlog predates +the adoption, the socket-activation handoff shape. + == Cancellation `wait()` honors the stop token of its `co_await` environment and the @@ -107,32 +117,37 @@ include::example$snippets/4r_wait.cpp[tag=wait_timeout,indent=0] == `wait_type::write` Semantics -`wait(wait_type::write)` always completes immediately with success on -a connected socket. This matches asio's behavior on the IOCP backend -and gives a consistent contract across all corosio backends. The -intended use is: "I want to know I can write now," not "I want to -park until the send buffer drains after backpressure." +`wait(wait_type::write)` completes when the socket can accept a +non-blocking write. On a socket that is not backpressured this is +immediate; once the send buffer is full the wait parks until the peer +drains enough of it for a write to make progress again. + +That is the signal an external flush loop needs: code that owns its +own buffers and retries "when the socket is writable" would busy-spin +if the wait completed unconditionally, precisely when the socket is +congested. Code that hands its buffers to `write_some()` does not need +`wait(wait_type::write)` at all — `write_some()` already parks on the +same condition. -Backpressure on the send path is already surfaced by `write_some()` -returning fewer bytes than requested (or `EAGAIN`-equivalent -behavior); use that signal rather than `wait(wait_type::write)` to -react to a full send buffer. +Acceptors are the exception: writability has no meaning for a +listening socket, so `wait(wait_type::write)` on an acceptor fails +with `errc::operation_not_supported` on every backend. == Backend Notes -On Linux (epoll) and BSD/macOS (kqueue) the read and error waits -register interest in the fd's read or error event without performing -any I/O syscall. On the select backend the same registration -semantics apply through the select-loop's fd sets. Write waits -short-circuit and never enter the reactor (see above). +On Linux (epoll) and BSD/macOS (kqueue) a wait registers interest in +the fd's read, write, or error event without performing any I/O +syscall. On the select backend the same registration semantics apply +through the select-loop's fd sets, whose write set includes fds with a +parked write wait. On Windows (IOCP), stream-socket `wait_read` uses a zero-byte `WSARecv`: the kernel signals completion when data is available without consuming bytes. All other waits (datagram-read, -acceptor-read, error-wait) route through an auxiliary `WSAPoll`-based -reactor that runs on a dedicated thread and bridges into the IOCP -via `PostQueuedCompletionStatus`. The public API is uniform across -platforms. +acceptor-read, write-wait, error-wait) route through an auxiliary +`WSAPoll`-based reactor that runs on a dedicated thread and bridges +into the IOCP via `PostQueuedCompletionStatus`. The public API is +uniform across platforms. == See Also diff --git a/include/boost/corosio/detail/local_stream_acceptor_service.hpp b/include/boost/corosio/detail/local_stream_acceptor_service.hpp index 4d4610bfe..058825a24 100644 --- a/include/boost/corosio/detail/local_stream_acceptor_service.hpp +++ b/include/boost/corosio/detail/local_stream_acceptor_service.hpp @@ -54,6 +54,21 @@ class BOOST_COROSIO_DECL local_stream_acceptor_service int type, int protocol) = 0; + /** Adopt an existing listening socket. + + Validates @p fd, closes any socket the implementation already + holds, and registers the adopted descriptor with the backend. + Listen state is not verified. + + @param impl The acceptor implementation to assign to. + @param fd The native socket to adopt. Ownership transfers only + on success. + @return Error code on failure, empty on success. + */ + virtual std::error_code assign_socket( + local_stream_acceptor::implementation& impl, + native_handle_type fd) = 0; + /** Bind an open acceptor to a local endpoint. @pre @p impl was opened via open_acceptor_socket(). diff --git a/include/boost/corosio/detail/tcp_acceptor_service.hpp b/include/boost/corosio/detail/tcp_acceptor_service.hpp index c74e8451a..f594b0b49 100644 --- a/include/boost/corosio/detail/tcp_acceptor_service.hpp +++ b/include/boost/corosio/detail/tcp_acceptor_service.hpp @@ -51,6 +51,21 @@ class BOOST_COROSIO_DECL tcp_acceptor_service int type, int protocol) = 0; + /** Adopt an existing listening socket. + + Validates @p fd, closes any socket the implementation already + holds, and registers the adopted descriptor with the backend. + Listen state is not verified. + + @param impl The acceptor implementation to assign to. + @param fd The native socket to adopt. Ownership transfers only + on success. + @return Error code on failure, empty on success. + */ + virtual std::error_code assign_socket( + tcp_acceptor::implementation& impl, + native_handle_type fd) = 0; + /** Bind an open acceptor to a local endpoint. @param impl The acceptor implementation to bind. diff --git a/include/boost/corosio/detail/tcp_service.hpp b/include/boost/corosio/detail/tcp_service.hpp index 94a5fb92d..bf7720547 100644 --- a/include/boost/corosio/detail/tcp_service.hpp +++ b/include/boost/corosio/detail/tcp_service.hpp @@ -49,6 +49,22 @@ class BOOST_COROSIO_DECL tcp_service int type, int protocol) = 0; + /** Assign an existing native socket handle to a socket. + + Adopts a pre-created socket handle. On success the impl + takes ownership and will close the handle. On failure the + caller retains ownership and must close it. If the impl is + already open, its pending operations are cancelled and the + held socket is closed before the new one is adopted. + + @param impl The socket implementation to assign to. + @param fd The native socket handle to adopt. + @return Error code on failure, empty on success. + */ + virtual std::error_code assign_socket( + tcp_socket::implementation& impl, + native_handle_type fd) = 0; + /** Bind a stream socket to a local endpoint. @param impl The socket implementation to bind. diff --git a/include/boost/corosio/detail/udp_service.hpp b/include/boost/corosio/detail/udp_service.hpp index 3674f81f6..a575fa9d4 100644 --- a/include/boost/corosio/detail/udp_service.hpp +++ b/include/boost/corosio/detail/udp_service.hpp @@ -51,6 +51,22 @@ class BOOST_COROSIO_DECL udp_service int type, int protocol) = 0; + /** Assign an existing native socket handle to a socket. + + Adopts a pre-created socket handle. On success the impl + takes ownership and will close the handle. On failure the + caller retains ownership and must close it. If the impl is + already open, its pending operations are cancelled and the + held socket is closed before the new one is adopted. + + @param impl The socket implementation to assign to. + @param fd The native socket handle to adopt. + @return Error code on failure, empty on success. + */ + virtual std::error_code assign_socket( + udp_socket::implementation& impl, + native_handle_type fd) = 0; + /** Bind a datagram socket to a local endpoint. @param impl The socket implementation to bind. diff --git a/include/boost/corosio/local_datagram_socket.hpp b/include/boost/corosio/local_datagram_socket.hpp index 8bfe16d80..2fbe51af1 100644 --- a/include/boost/corosio/local_datagram_socket.hpp +++ b/include/boost/corosio/local_datagram_socket.hpp @@ -831,14 +831,31 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object return opt; } - /** Assign an existing file descriptor to this socket. - - The socket must not already be open. The fd is adopted - and registered with the platform reactor. - - @param fd The file descriptor to adopt. - - @throws std::system_error on failure. + /** Assign an existing native socket to this object. + + Adopts a Unix domain datagram socket created outside the + library — from `socketpair()`, received over `SCM_RIGHTS`, + or made natively — and registers it with the backend. The + socket must be a datagram socket in the `AF_UNIX` family. + Adoption never alters the descriptor's flags or options; the + fd must already be non-blocking. + + If this object is already open, pending operations complete + with `errc::operation_canceled` and the held socket is + closed before the new one is adopted. + + @par Exception Safety + Strong guarantee on validation failure: the object is + unchanged. If backend registration fails, the object either + retains its previous socket or is left closed, depending on + the backend. In all failure cases the caller retains + ownership of `fd`. + + @param fd The native socket to adopt. On success the object + owns it and will close it. + + @throws std::system_error On validation or registration + failure. */ void assign(native_handle_type fd); diff --git a/include/boost/corosio/local_stream_acceptor.hpp b/include/boost/corosio/local_stream_acceptor.hpp index 3a7c67a3e..6a6352c0c 100644 --- a/include/boost/corosio/local_stream_acceptor.hpp +++ b/include/boost/corosio/local_stream_acceptor.hpp @@ -324,7 +324,14 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object Suspends until the listen socket is ready in the requested direction. For `wait_type::read`, completion signals that a subsequent @ref accept will succeed - without blocking. No connection is consumed. + without blocking; a connection already queued when the + wait begins completes it immediately. No connection is + consumed. + + @note `wait_type::write` is not usable on an acceptor: + writability carries no meaning for a listening socket, so + the wait fails with `errc::operation_not_supported` on + every backend. @param w The wait direction. @@ -384,6 +391,48 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object */ native_handle_type release(); + /** Get the native socket handle. + + @return The native socket handle, or -1/INVALID_SOCKET if not + open. + + @par Preconditions + None. May be called on closed acceptors. + */ + native_handle_type native_handle() const noexcept; + + /** Assign an existing native socket to this acceptor. + + Adopts a listening socket created outside the library — + received from a service manager, inherited, or made natively — + and registers it with the backend. The socket must be a + listening stream socket in the local IPC family. Adoption + never alters the descriptor's flags or options: on POSIX the + fd must already be non-blocking, and on Windows the socket + must be overlapped-capable. + + Adoption does not verify listen state; @ref accept reports the + error if the socket is not listening. + + If this object is already open, pending operations complete + with `errc::operation_canceled` and the held socket is closed + before the new one is adopted. + + @par Exception Safety + Strong guarantee on validation failure: the object is + unchanged. If backend registration fails, the object either + retains its previous socket or is left closed, depending on + the backend. In all failure cases the caller retains + ownership of `fd`. + + @param fd The native socket to adopt. On success the object + owns it and will close it. + + @throws std::system_error On validation or registration + failure. + */ + void assign(native_handle_type fd); + /** Return the local endpoint the acceptor is bound to. Returns a default-constructed (empty) endpoint if the @@ -490,6 +539,9 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object /// Return whether the underlying socket is open. virtual bool is_open() const noexcept = 0; + /// Return the native handle, or the platform sentinel if closed. + virtual native_handle_type native_handle() const noexcept = 0; + /// Release and return the native handle without closing. virtual native_handle_type release_socket() noexcept = 0; diff --git a/include/boost/corosio/local_stream_socket.hpp b/include/boost/corosio/local_stream_socket.hpp index 98b385473..87b6d4f70 100644 --- a/include/boost/corosio/local_stream_socket.hpp +++ b/include/boost/corosio/local_stream_socket.hpp @@ -466,16 +466,32 @@ class BOOST_COROSIO_DECL local_stream_socket : public io_stream return opt; } - /** Assign an existing file descriptor to this socket. - - The socket must not already be open. The fd is adopted - and registered with the platform reactor. Used by - connect_pair() to wrap socketpair() fds. - - @param fd The file descriptor to adopt. Must be a valid, - open, non-blocking Unix stream socket. - - @throws std::system_error on failure. + /** Assign an existing native socket to this object. + + Adopts a Unix domain stream socket created outside the + library — from `socketpair()`, received over `SCM_RIGHTS`, + or made natively — and registers it with the backend. The + socket must be a stream socket in the `AF_UNIX` family. + Adoption never alters the descriptor's flags or options: on + POSIX the fd must already be non-blocking, and on Windows + the socket must be overlapped-capable. + + If this object is already open, pending operations complete + with `errc::operation_canceled` and the held socket is + closed before the new one is adopted. + + @par Exception Safety + Strong guarantee on validation failure: the object is + unchanged. If backend registration fails, the object either + retains its previous socket or is left closed, depending on + the backend. In all failure cases the caller retains + ownership of `fd`. + + @param fd The native socket to adopt. On success the object + owns it and will close it. + + @throws std::system_error On validation or registration + failure. */ void assign(native_handle_type fd); diff --git a/include/boost/corosio/native/detail/epoll/epoll_scheduler.hpp b/include/boost/corosio/native/detail/epoll/epoll_scheduler.hpp index d8489bbb8..dfbbc135f 100644 --- a/include/boost/corosio/native/detail/epoll/epoll_scheduler.hpp +++ b/include/boost/corosio/native/detail/epoll/epoll_scheduler.hpp @@ -113,8 +113,12 @@ class BOOST_COROSIO_DECL epoll_scheduler final : public reactor_scheduler @param fd The file descriptor to register. @param desc Pointer to descriptor data (stored in epoll_event.data.ptr). + + @return The error if registration fails, otherwise a default + constructed error code. */ - void register_descriptor(int fd, reactor_descriptor_state* desc) const; + std::error_code + register_descriptor(int fd, reactor_descriptor_state* desc) const; /** Deregister a persistently registered descriptor. @@ -125,7 +129,8 @@ class BOOST_COROSIO_DECL epoll_scheduler final : public reactor_scheduler /// Watch the read end of the POSIX signal self-pipe (see scheduler.hpp). void register_signal_reader(int read_fd) override { - register_descriptor(read_fd, signal_pipe_reader_.arm()); + if (auto ec = register_descriptor(read_fd, signal_pipe_reader_.arm())) + detail::throw_system_error(ec, "epoll_ctl (register)"); } private: @@ -252,7 +257,7 @@ epoll_scheduler::configure_reactor( event_buffer_.resize(max_events_per_poll_); } -inline void +inline std::error_code epoll_scheduler::register_descriptor(int fd, reactor_descriptor_state* desc) const { epoll_event ev{}; @@ -260,7 +265,7 @@ epoll_scheduler::register_descriptor(int fd, reactor_descriptor_state* desc) con ev.data.ptr = desc; if (::epoll_ctl(epoll_fd_, EPOLL_CTL_ADD, fd, &ev) < 0) - detail::throw_system_error(make_err(errno), "epoll_ctl (register)"); + return make_err(errno); desc->registered_events = ev.events; desc->fd = fd; @@ -272,6 +277,7 @@ epoll_scheduler::register_descriptor(int fd, reactor_descriptor_state* desc) con desc->impl_ref_.reset(); desc->read_ready = false; desc->write_ready = false; + return {}; } inline void diff --git a/include/boost/corosio/native/detail/epoll/epoll_types.hpp b/include/boost/corosio/native/detail/epoll/epoll_types.hpp index d94f502b1..39768dd51 100644 --- a/include/boost/corosio/native/detail/epoll/epoll_types.hpp +++ b/include/boost/corosio/native/detail/epoll/epoll_types.hpp @@ -56,6 +56,12 @@ class epoll_tcp_socket final public: explicit epoll_tcp_socket(epoll_tcp_service& svc) noexcept : base_type(svc) {} + + native_handle_type release_socket() noexcept override + { + hook_ = {}; + return this->do_release_socket(); + } }; class epoll_local_stream_socket final @@ -94,6 +100,11 @@ class epoll_udp_socket final public: explicit epoll_udp_socket(epoll_udp_service& svc) noexcept : base_type(svc) {} + + native_handle_type release_socket() noexcept override + { + return this->do_release_socket(); + } }; class epoll_local_datagram_socket final @@ -162,11 +173,6 @@ class epoll_local_stream_acceptor final explicit epoll_local_stream_acceptor( epoll_local_stream_acceptor_service& svc) noexcept : base_type(svc) {} - - native_handle_type release_socket() noexcept override - { - return this->do_release_socket(); - } }; // --- Services --- diff --git a/include/boost/corosio/native/detail/io_uring/io_uring_acceptor_ops.hpp b/include/boost/corosio/native/detail/io_uring/io_uring_acceptor_ops.hpp index 84bff11bb..f2d4ef93e 100644 --- a/include/boost/corosio/native/detail/io_uring/io_uring_acceptor_ops.hpp +++ b/include/boost/corosio/native/detail/io_uring/io_uring_acceptor_ops.hpp @@ -25,6 +25,7 @@ #include #include +#include namespace boost::corosio::detail { @@ -76,6 +77,21 @@ struct uring_multi_accept_op : io_uring_op SOCK_NONBLOCK | SOCK_CLOEXEC); } + /** Dispose of a connection the kernel accepted for a retired + arming. + + The acceptor that armed this op has moved to another + descriptor, so no waiter will ever take delivery. The fd is + already installed in the process table — dropping the CQE + without closing it leaks it for the life of the process. + */ + static void do_retired_cqe( + io_uring_op* /*base*/, int res, unsigned /*flags*/) noexcept + { + if (res >= 0) + ::close(res); + } + static void do_cqe(io_uring_op* base, int res, unsigned flags, ready_queue& /*local*/) noexcept { diff --git a/include/boost/corosio/native/detail/io_uring/io_uring_multishot_acceptor.hpp b/include/boost/corosio/native/detail/io_uring/io_uring_multishot_acceptor.hpp index 412ac491c..efe2be8f0 100644 --- a/include/boost/corosio/native/detail/io_uring/io_uring_multishot_acceptor.hpp +++ b/include/boost/corosio/native/detail/io_uring/io_uring_multishot_acceptor.hpp @@ -23,9 +23,11 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -39,6 +41,26 @@ namespace boost::corosio::detail { +/** Check whether a descriptor is in the listening state. + + Multishot accept fails immediately on a non-listening socket and + the re-arm path would spin on that failure, so an adopted fd is + only armed when the kernel reports a listener. A query the + platform refuses is treated as listening so adoption still works. + + @param fd The descriptor to probe. + @return True unless the kernel positively reports a non-listener. +*/ +inline bool +fd_is_listening(int fd) noexcept +{ + int accepting = 0; + socklen_t alen = sizeof(accepting); + if (::getsockopt(fd, SOL_SOCKET, SO_ACCEPTCONN, &accepting, &alen) != 0) + return true; + return accepting != 0; +} + template class io_uring_multishot_acceptor_base : public ImplBase @@ -72,6 +94,9 @@ class io_uring_multishot_acceptor_base /// The stop callback is armed before the node is queued, so /// cancel_waiter must not unlink a node it never queued. bool queued = false; + /// A readiness wait rather than an accept: completion + /// observes a pending connection without consuming it. + bool peek = false; std::optional> stop_cb; }; @@ -82,8 +107,21 @@ class io_uring_multishot_acceptor_base mutable std::mutex mutex_; intrusive_list ready_fds_; intrusive_list waiters_; + /// Single parked readiness wait (guarded by `mutex_`). Multishot + /// accepting drains the kernel queue instantly, so a listener's + /// readiness lives in `ready_fds_`, not in `poll()`; the wait is + /// completed by the next delivery instead of a kernel poll. + waiter_node* read_wait_ = nullptr; std::unique_ptr multi_op_; bool closing_ = false; + /// Bumped whenever an arming is retired. A re-arm posted for an + /// earlier generation must not resubmit: `multi_op_` now names a + /// different op, and resubmitting a live one would alias a single + /// `user_data` across two kernel armings. The re-arm's check is + /// not atomic with its submit — a retirement landing between them + /// (concurrent `assign()` on another thread) can still double-arm; + /// closing that needs a generation-aware submit. + std::atomic arm_generation_{0}; private: // CRTP ctor private + Derived friended so the base cannot be @@ -146,6 +184,32 @@ class io_uring_multishot_acceptor_base return fd_ >= 0; } + native_handle_type native_handle() const noexcept override + { + return fd_; + } + + native_handle_type release_socket() noexcept override + { + // Mirror the service close() path: cancel the multishot SQE and + // break the multi_op_ -> impl_ptr (shared_ptr) cycle that + // start_multishot established. Without this, the cycle keeps the + // acceptor and its multi_op_ alive after the caller takes the fd, + // which LeakSanitizer reports on process exit. Caller still owns + // the returned fd, so we do NOT ::close it here. + if (fd_ >= 0) + { + sched_->cancel_and_flush(fd_); + drain_waiters_only(); + if (multi_op_) + multi_op_->impl_ptr.reset(); + } + int fd = fd_; + fd_ = -1; + local_endpoint_ = Endpoint{}; + return fd; + } + void cancel() noexcept override { drain_waiters_only(); @@ -168,6 +232,11 @@ class io_uring_multishot_acceptor_base // on_accept_cqe_impl to surface operation_aborted. while (auto* w = waiters_.pop_front()) drained.push_back(w); + if (read_wait_) + { + drained.push_back(read_wait_); + read_wait_ = nullptr; + } } while (auto* w = drained.pop_front()) @@ -186,6 +255,90 @@ class io_uring_multishot_acceptor_base } } + /** Park a readiness wait, or complete it if a connection is + already queued. + + Multishot accepting consumes the kernel queue as connections + arrive, so a poll on the listener never reports it readable; + readiness is the impl's ready queue plus future deliveries. + */ + void park_read_wait( + std::coroutine_handle<> h, + capy::executor_ref ex, + std::stop_token const& token, + std::error_code* ec) noexcept + { + bool ready = false; + bool aborted = false; + { + std::lock_guard lk(mutex_); + if (closing_) + { + aborted = true; + } + else if (!ready_fds_.empty()) + { + ready = true; + } + } + if (ready || aborted) + { + // NOLINTNEXTLINE(bugprone-unhandled-exception-at-new) — noexcept initiation path: OOM => std::terminate is the intended behavior + auto* op = new uring_accept_op(); + op->h = h; + op->ex = ex; + op->ec_out = ec; + if (aborted) + op->cancelled.store(true, std::memory_order_release); + sched_->post(op); + return; + } + + // NOLINTNEXTLINE(bugprone-unhandled-exception-at-new) — noexcept initiation path: OOM => std::terminate is the intended behavior + auto* w = new waiter_node{}; + w->h = h; + w->ex = ex; + w->ec_out = ec; + w->owner = static_cast(this); + w->peek = true; + + // Same protocol as accept parking: arm the callback before + // the node is visible and outside `mutex_` (a pre-stopped + // token invokes the canceller synchronously, and the + // canceller takes `mutex_`). + if (token.stop_possible()) + w->stop_cb.emplace(token, waiter_canceller{w}); + + bool was_cancelled = false; + { + std::lock_guard lk(mutex_); + if (w->cancelled.load(std::memory_order_acquire) || closing_) + { + was_cancelled = true; + } + else if (ready_fds_.empty()) + { + w->queued = true; + sched_->work_started(); + read_wait_ = w; + return; + } + // else: a connection arrived while the callback was armed; + // complete as ready below. + } + + w->stop_cb.reset(); + // NOLINTNEXTLINE(bugprone-unhandled-exception-at-new) — noexcept initiation path: OOM => std::terminate is the intended behavior + auto* op = new uring_accept_op(); + op->h = w->h; + op->ex = w->ex; + op->ec_out = w->ec_out; + if (was_cancelled) + op->cancelled.store(true, std::memory_order_release); + delete w; + sched_->post(op); + } + std::error_code set_option( int level, int optname, void const* data, std::size_t size) noexcept override @@ -211,6 +364,119 @@ class io_uring_multishot_acceptor_base return {}; } + /** Retire the multishot op before the acceptor changes descriptor. + + `cancel_and_flush` and `submit_cancel_by_fd` only submit: the + terminating CQE for the previous arming is still queued when + the caller returns. Left alone, a subsequent `start_multishot` + would alias one `user_data` across two kernel ops, and the + stale `!more` CQE would observe a cleared `closing_` and take + the re-arm branch. + + Ownership therefore moves to the scheduler rather than being + drained here. Draining is a teardown-only tool — it consumes + CQEs without dispatching them, so on a live context it would + swallow unrelated ops' completions and park their coroutines + forever. Handing the op over keeps the normal run loop in + charge of every CQE, and the op stays allocated (so its + `user_data` stays reserved) until the kernel is done with it. + + Safe with no op armed, and safe after `release_socket` left + `fd_` cleared with the op still in flight. + */ + void retire_multishot() noexcept + { + // Every field below is read by the leader mid-dispatch, so all + // of it is published inside retire_op's ring_mutex_ critical + // section — including moving multi_op_ out, since + // on_accept_cqe_impl dereferences it for peer_storage. Taking + // the acceptor mutex_ too keeps the generation bump ordered + // against the re-arm path's check. Lock order is + // ring_mutex_ -> mutex_, the same order the dispatch path + // acquires them in. + sched_->retire_op( + multi_op_, [this](uring_multi_accept_op& op) noexcept { + std::lock_guard lk(mutex_); + op.impl_ptr.reset(); + op.acceptor_impl = nullptr; + op.on_cqe = nullptr; + op.retire_func = &uring_multi_accept_op::do_retired_cqe; + arm_generation_.fetch_add(1, std::memory_order_acq_rel); + }); + } + + /** Take over an already-listening descriptor. + + Clears the shutdown latch a previous release left behind and + discards connections parked from the replaced descriptor: + those belong to the socket the caller is handing away. + + @pre `retire_multishot` has run, so no arming from a previous + descriptor is still in flight. + + @param fd The adopted descriptor. + */ + void adopt_listening_fd(int fd) noexcept + { + intrusive_list stale; + { + std::lock_guard lk(mutex_); + fd_ = fd; + closing_ = false; + while (auto* r = ready_fds_.pop_front()) + stale.push_back(r); + } + while (auto* r = stale.pop_front()) + { + ::close(r->fd); + delete r; + } + } + + /** Ready an acceptor whose own descriptor is about to be armed. + + The open/bind/listen path reaches `start_multishot` without + going through `adopt_listening_fd`, and a released acceptor may + be opened and listened on again. Both of the things that path + would otherwise inherit belong to the descriptor the caller + already took away: the shutdown latch, which would have every + connection the kernel hands back closed on arrival, and the + arming still owed a terminal CQE, which a fresh submission + would alias by `user_data`. + */ + /** Ready the acceptor for a listen-time arming. + + Returns `false` when a live arming already covers the + descriptor: a re-listen only changes the backlog, and retiring + a live arming here would leave it un-cancelled in the kernel — + two armings on one listener, with the retired one's deliveries + closed on arrival. + */ + bool prepare_listen_arm() noexcept + { + { + std::lock_guard lk(mutex_); + if (multi_op_ && !closing_) + return false; + } + retire_multishot(); + intrusive_list stale; + { + std::lock_guard lk(mutex_); + closing_ = false; + // Deliveries queued by a released descriptor belong to + // the socket the caller took away, not to this listener. + while (auto* r = ready_fds_.pop_front()) + stale.push_back(r); + } + while (auto* r = stale.pop_front()) + { + ::close(r->fd); + delete r; + } + return true; + } + void start_multishot() { if (!multi_op_) @@ -225,9 +491,13 @@ class io_uring_multishot_acceptor_base else { // Reuse the existing op (re-arm path). Reset peer scratch - // so the kernel writes into a clean slot. + // so the kernel writes into a clean slot. listen_fd and + // impl_ptr are re-seeded so the op can never carry state + // from an arming that has since been torn down. multi_op_->peer_storage = sockaddr_storage{}; multi_op_->peer_len = sizeof(sockaddr_storage); + multi_op_->listen_fd = fd_; + multi_op_->impl_ptr = this->shared_from_this(); } auto* op = multi_op_.get(); @@ -385,9 +655,18 @@ class io_uring_multishot_acceptor_base std::lock_guard lk(mutex_); if (closing_) return; // on_accept_cqe_impl will drain with closing_ set if (!w->queued) - return; // not in waiters_ yet; dispatch_or_queue - // observes `cancelled` and completes the op - waiters_.remove(w); + return; // not queued yet; the parking path observes + // `cancelled` and completes the op + if (w->peek) + { + if (read_wait_ != w) + return; // already claimed by a delivery + read_wait_ = nullptr; + } + else + { + waiters_.remove(w); + } } // NOLINTNEXTLINE(bugprone-unhandled-exception-at-new) — stop-token callback: noexcept, OOM => std::terminate is the intended behavior auto* op = new uring_accept_op(); @@ -416,10 +695,21 @@ class io_uring_multishot_acceptor_base { bool was_closing = false; waiter_node* matched = nullptr; + waiter_node* claimed_peek = nullptr; intrusive_list closing_waiters; { std::lock_guard lk(mutex_); was_closing = closing_; + if (!was_closing && new_fd >= 0 && read_wait_ && + !read_wait_->cancelled.exchange( + true, std::memory_order_acq_rel)) + { + // A parked readiness wait observes the delivery + // without consuming it; the connection still flows + // to a waiter or the ready queue below. + claimed_peek = read_wait_; + read_wait_ = nullptr; + } if (was_closing) { if (new_fd >= 0) @@ -466,6 +756,19 @@ class io_uring_multishot_acceptor_base } } + if (claimed_peek) + { + claimed_peek->stop_cb.reset(); + // NOLINTNEXTLINE(bugprone-unhandled-exception-at-new) — CQE handler: noexcept, OOM => std::terminate is the intended behavior + auto* op = new uring_accept_op(); + op->h = claimed_peek->h; + op->ex = claimed_peek->ex; + op->ec_out = claimed_peek->ec_out; + delete claimed_peek; + sched_->post(op); + sched_->work_finished(); // balance the parking work_started + } + if (matched) { matched->stop_cb.reset(); @@ -513,17 +816,31 @@ class io_uring_multishot_acceptor_base struct rearm_op final : scheduler_op { std::shared_ptr self_; - explicit rearm_op(std::shared_ptr s) noexcept - : self_(std::move(s)) {} + std::uint64_t generation_; + rearm_op( + std::shared_ptr s, + std::uint64_t generation) noexcept + : self_(std::move(s)) + , generation_(generation) {} void operator()() override { - auto self = std::move(self_); + auto self = std::move(self_); + auto generation = generation_; delete this; { std::lock_guard lk(self->mutex_); if (self->closing_) return; + // The arming this was posted for may have been + // retired by assign() in the meantime. multi_op_ + // then names a different, already-armed op, and + // resubmitting it would alias one user_data + // across two kernel armings — a use-after-free + // once the first terminal CQE frees the op. + if (self->arm_generation_.load( + std::memory_order_acquire) != generation) + return; } self->start_multishot(); } @@ -531,7 +848,9 @@ class io_uring_multishot_acceptor_base void destroy() override { delete this; } }; // NOLINTNEXTLINE(bugprone-unhandled-exception-at-new) — CQE handler re-arm: noexcept, OOM => std::terminate is the intended behavior - sched_->post(new rearm_op(this->shared_from_this())); + sched_->post(new rearm_op( + this->shared_from_this(), + arm_generation_.load(std::memory_order_acquire))); } } }; diff --git a/include/boost/corosio/native/detail/io_uring/io_uring_op.hpp b/include/boost/corosio/native/detail/io_uring/io_uring_op.hpp index cfe42c672..5f9e044ac 100644 --- a/include/boost/corosio/native/detail/io_uring/io_uring_op.hpp +++ b/include/boost/corosio/native/detail/io_uring/io_uring_op.hpp @@ -52,6 +52,13 @@ struct io_uring_op : coro_op using prep_func_type = void (*)(io_uring_op*, ::io_uring_sqe*) noexcept; + /// Retired-CQE dispatcher type. Called in place of `cqe_func` once + /// the op is retired, so the op type can release whatever `res` + /// owns (an accepted descriptor, a registered buffer) that no + /// handler will now take delivery of. + using retire_func_type = + void (*)(io_uring_op*, int res, unsigned flags) noexcept; + explicit io_uring_op( func_type post_func, cqe_func_type cqe_fn, @@ -76,6 +83,17 @@ struct io_uring_op : coro_op /// Scheduler reference for submitting cancel SQEs on stop_token. io_uring_scheduler* sched_ = nullptr; + /// Set when the op's owner went away while the kernel still held + /// its user_data (see `io_uring_scheduler::retire_op`). A retired + /// op belongs to the scheduler: the run loop routes its CQEs to + /// `retire_func` instead of `cqe_func` and frees the op on the + /// terminal CQE. + bool retired = false; + + /// Disposal hook used while `retired` is set. May be null when the + /// op's result owns nothing. + retire_func_type retire_func = nullptr; + /// Bridge virtual dispatch to func-pointer dispatch. Lets the run /// loop dispatch any scheduler_op via `(*op)()` — both reactor-style /// services posted into the queue and proactor-style io_uring ops. diff --git a/include/boost/corosio/native/detail/io_uring/io_uring_scheduler.hpp b/include/boost/corosio/native/detail/io_uring/io_uring_scheduler.hpp index e892abd59..ba2eaf2b5 100644 --- a/include/boost/corosio/native/detail/io_uring/io_uring_scheduler.hpp +++ b/include/boost/corosio/native/detail/io_uring/io_uring_scheduler.hpp @@ -39,6 +39,9 @@ #include #include #include +#include +#include +#include #include #include @@ -223,6 +226,65 @@ class BOOST_COROSIO_DECL io_uring_scheduler final */ void cancel_and_flush(int fd) noexcept; + /** Take ownership of an op the kernel may still complete. + + For member-owned ops (e.g. a multishot accept arming) whose + owner goes away on a *non-terminal* path — the owning acceptor + adopting a different descriptor, say. Draining the op's CQEs + there is not an option: `drain_cqes_for` consumes without + dispatching, which is only tolerable while the whole context + is being torn down. + + The op stays allocated, and its `user_data` therefore stays + reserved, until the kernel delivers its terminal CQE. That is + what keeps a freshly submitted op from aliasing the retired + one. `process_completions` routes a retired op's CQEs to its + `retire_func` and frees the op on the terminal CQE; anything + still outstanding is released when the scheduler is destroyed, + after `io_uring_queue_exit`. + + @par Invariant + Retirement state — the `retired` flag, and every owner + back-pointer @p prepare clears — is written and read only + under `ring_mutex_`. `process_completions` runs the whole CQE + dispatch under that mutex, so taking it here is the only thing + that orders this publication against a leader mid-dispatch. + Everything is therefore done inside one critical section: + @p prepare runs, the flag is set, and the op moves into the + retired list before the mutex is released. + + @par Thread Safety + Safe to call from any thread. Takes `ring_mutex_` then + `retired_mutex_`, the same order `release_retired_op` uses. + Callers must hold neither. + + @pre After @p prepare runs, @p slot must hold no reference back + to its owner (`impl_ptr` cleared, back-pointers nulled). + `release_retired_op` deletes the op from inside the CQE + loop with `ring_mutex_` held, so its destructor must not + re-enter the scheduler or touch the owner. + + @param slot The owner's pointer to the op. Emptied on return; + ignored if already empty. + @param prepare Invoked as `prepare(*slot)` under `ring_mutex_` + to clear back-pointers and install a `retire_func`. Must + not take `ring_mutex_` or post work. + */ + template + void retire_op(std::unique_ptr& slot, PrepareFn prepare) noexcept + { + // Interrupt first so a leader parked in the kernel drops + // ring_mutex_ promptly, as cancel_and_flush does. + interrupt_reactor(); + lock_type lock(ring_mutex_); + if (!slot) + return; + prepare(*slot); + slot->retired = true; + std::lock_guard retired_lock(retired_mutex_); + retired_ops_.push_back(std::move(slot)); + } + /** Drain pending CQEs for a specific op's `user_data`. Submits an ASYNC_CANCEL by user_data to short-circuit any @@ -232,6 +294,13 @@ class BOOST_COROSIO_DECL io_uring_scheduler final `uring_multi_accept_op`) whose destructor cannot tolerate outstanding CQEs. + @warning Teardown paths only. Every CQE this walks is consumed + and none are dispatched, so an unrelated op completing inside + the drain window loses its handler and its coroutine never + resumes. That is only tolerable when the owning object is + being destroyed. To retire a still-live op on a non-terminal + path, use @ref retire_op instead. + @par Thread Safety Safe to call from any thread. Internally takes `ring_mutex_` to serialise against the run-loop leader; calls @@ -350,6 +419,20 @@ class BOOST_COROSIO_DECL io_uring_scheduler final int cancel_sentinel_ = 0; mutable std::atomic wakeup_armed_{false}; + // Ops adopted by retire_op, kept alive so the kernel never sees + // their user_data reused. Declared before ring_ is exited only in + // the sense that the destructor body runs io_uring_queue_exit + // first; the vector is then destroyed with the rest of the members, + // by which point the kernel can no longer reference these ops. + // A plain std::mutex (not the conditionally-enabled mutex_type): + // retirement happens on user threads regardless of the threading + // configuration. Leaf lock — nothing else is taken under it. + mutable std::mutex retired_mutex_; + std::vector> retired_ops_; + + /// Free a retired op once its terminal CQE has been consumed. + void release_retired_op(io_uring_op* op) noexcept; + // Signal self-pipe integration. The read end is watched via a multishot // POLL SQE tagged with &signal_pipe_sentinel_ (distinct from nullptr = // wakeup eventfd and &cancel_sentinel_). On its CQE we re-arm the poll if @@ -1256,12 +1339,31 @@ io_uring_scheduler::process_completions() else { auto* iop = static_cast(ud); - (*iop->cqe_func)(iop, cqe->res, cqe->flags, local_ops); - // Decrement inflight on the terminal CQE only — multishot - // ops (acceptor) hold the SQE alive across F_MORE CQEs and - // free it only when F_MORE is cleared. - if ((cqe->flags & IORING_CQE_F_MORE) == 0) - ++inflight_dec; + if (iop->retired) + { + // The owner handed this op to retire_op and is no + // longer listening. Never dispatch cqe_func — the + // handler's output pointers and coroutine are gone. + // retire_func disposes of whatever the result owns + // (an accepted descriptor, say), since only the op + // type knows what `res` means. + if (iop->retire_func) + (*iop->retire_func)(iop, cqe->res, cqe->flags); + if ((cqe->flags & IORING_CQE_F_MORE) == 0) + { + ++inflight_dec; + release_retired_op(iop); + } + } + else + { + (*iop->cqe_func)(iop, cqe->res, cqe->flags, local_ops); + // Decrement inflight on the terminal CQE only — multishot + // ops (acceptor) hold the SQE alive across F_MORE CQEs and + // free it only when F_MORE is cleared. + if ((cqe->flags & IORING_CQE_F_MORE) == 0) + ++inflight_dec; + } } ++consumed; } @@ -1381,6 +1483,29 @@ io_uring_scheduler::cancel_and_flush(int fd) noexcept io_uring_submit(&ring_); } +inline void +io_uring_scheduler::release_retired_op(io_uring_op* op) noexcept +{ + // Called from the CQE loop with ring_mutex_ held; takes + // retired_mutex_ under it, matching retire_op's order. Deleting + // here is safe only because retire_op requires the op to hold no + // reference back to its owner, so ~op cannot re-enter the + // scheduler or touch a destroyed acceptor. + std::unique_ptr owned; + { + std::lock_guard lock(retired_mutex_); + for (auto it = retired_ops_.begin(); it != retired_ops_.end(); ++it) + { + if (it->get() == op) + { + owned = std::move(*it); + retired_ops_.erase(it); + break; + } + } + } +} + inline void io_uring_scheduler::drain_cqes_for(io_uring_op* target) noexcept { diff --git a/include/boost/corosio/native/detail/io_uring/io_uring_types.hpp b/include/boost/corosio/native/detail/io_uring/io_uring_types.hpp index 5a59b9eaf..f6c7fe58a 100644 --- a/include/boost/corosio/native/detail/io_uring/io_uring_types.hpp +++ b/include/boost/corosio/native/detail/io_uring/io_uring_types.hpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -379,6 +380,22 @@ class BOOST_COROSIO_DECL io_uring_tcp_socket final // native_handle() / set_option() / get_option() are inherited from // native_socket_base. + native_handle_type release_socket() noexcept override + { + // Flush while the fd is still open so the kernel resolves + // pending SQEs before the caller can close and recycle the + // number (same reasoning as close_socket). + if (fd_ >= 0) + sched_->cancel_and_flush(fd_); + int fd = fd_; + fd_ = -1; + local_endpoint_ = endpoint{}; + remote_endpoint_ = endpoint{}; + local_endpoint_state_.store( + endpoint_state::unresolved, std::memory_order_release); + return fd; + } + void cancel() noexcept override { if (fd_ >= 0) @@ -507,6 +524,57 @@ class BOOST_COROSIO_DECL io_uring_tcp_service final return {}; } + /** Adopt a pre-created fd into an impl. + + Takes ownership of `fd` on success; the caller retains + ownership on failure. + + @param impl The socket implementation to assign to. + @param fd A valid, open, non-blocking IP stream fd. + @return Error code on failure, empty on success. + */ + std::error_code assign_socket( + tcp_socket::implementation& impl, + native_handle_type fd) override + { + auto& sock = static_cast(impl); + int nfd = static_cast(fd); + if (nfd >= 0 && nfd == sock.fd_) + return std::make_error_code(std::errc::invalid_argument); + if (auto ec = validate_socket_fd(nfd, SOCK_STREAM, true)) + return ec; + + if (sock.fd_ >= 0) + { + sched_->cancel_and_flush(sock.fd_); + ::close(sock.fd_); + } + sock.fd_ = nfd; + + sock.local_endpoint_ = endpoint{}; + sock.remote_endpoint_ = endpoint{}; + + sockaddr_storage local{}; + socklen_t local_len = sizeof(local); + if (::getsockname(sock.fd_, + reinterpret_cast(&local), &local_len) == 0) + { + sock.local_endpoint_ = sockaddr_to_endpoint(local); + sock.family_ = local.ss_family; + } + sock.local_endpoint_state_.store( + io_uring_tcp_socket::endpoint_state::resolved, + std::memory_order_release); + + sockaddr_storage remote{}; + socklen_t remote_len = sizeof(remote); + if (::getpeername(sock.fd_, + reinterpret_cast(&remote), &remote_len) == 0) + sock.remote_endpoint_ = sockaddr_to_endpoint(remote); + + return {}; + } + /** Bind the socket and capture the local endpoint via `getsockname`. @param impl The socket implementation to bind. @@ -618,15 +686,31 @@ class BOOST_COROSIO_DECL io_uring_tcp_acceptor final std::stop_token token, std::error_code* ec) override { - int poll_flags = 0; - switch (w) + // Multishot accepting drains the kernel queue as connections + // arrive, so a poll on the listener never reports it + // readable; read waits complete from the delivery queue. + if (w == wait_type::read) { - case wait_type::read: poll_flags = POLLIN; break; - case wait_type::write: poll_flags = POLLOUT; break; - case wait_type::error: poll_flags = POLLPRI | POLLERR | POLLHUP; break; + this->park_read_wait(h, ex, token, ec); + return std::noop_coroutine(); } + // Writability carries no meaning for a listening socket; + // fail uniformly instead of never completing. + if (w == wait_type::write) + { + // NOLINTNEXTLINE(bugprone-unhandled-exception-at-new) — noexcept-adjacent initiation path: OOM => std::terminate is the intended behavior + auto* op = new uring_accept_op(); + op->h = h; + op->ex = ex; + op->ec_out = ec; + op->err = ENOTSUP; + this->sched_->post(op); + return std::noop_coroutine(); + } + // Errors are not consumed by the accept machinery, so the + // error wait still polls the descriptor. wait_op_.prepare(h, ex, ec, this->fd_, this->sched_, - this->shared_from_this(), poll_flags, token); + this->shared_from_this(), POLLPRI | POLLERR | POLLHUP, token); this->sched_->work_started(); if (wait_op_.cancelled.load(std::memory_order_acquire)) { @@ -772,6 +856,48 @@ class BOOST_COROSIO_DECL io_uring_tcp_acceptor_service final return {}; } + /** Adopt an already-listening descriptor. + + @param impl The acceptor implementation to assign to. + @param fd The native socket to adopt. + @return Error code on failure, empty on success. + */ + std::error_code assign_socket( + tcp_acceptor::implementation& impl, native_handle_type fd) override + { + auto& acc = static_cast(impl); + int nfd = static_cast(fd); + if (nfd >= 0 && nfd == acc.fd_) + return std::make_error_code(std::errc::invalid_argument); + if (auto ec = validate_socket_fd(nfd, SOCK_STREAM, true)) + return ec; + + if (acc.fd_ >= 0) + { + sched_->cancel_and_flush(acc.fd_); + acc.drain_waiters_only(); + ::close(acc.fd_); + acc.fd_ = -1; + } + + // Unconditional: release_socket() also leaves the op in flight, + // and it clears fd_ before returning. + acc.retire_multishot(); + + acc.adopt_listening_fd(nfd); + + acc.local_endpoint_ = endpoint{}; + sockaddr_storage local{}; + socklen_t local_len = sizeof(local); + if (::getsockname( + nfd, reinterpret_cast(&local), &local_len) == 0) + acc.local_endpoint_ = sockaddr_to_endpoint(local); + + if (fd_is_listening(nfd)) + acc.start_multishot(); + return {}; + } + /** Bind an open acceptor and capture the local endpoint. @param impl The acceptor implementation to bind. @@ -813,7 +939,8 @@ class BOOST_COROSIO_DECL io_uring_tcp_acceptor_service final auto& acc = static_cast(impl); if (::listen(acc.fd_, backlog) < 0) return make_err(errno); - acc.start_multishot(); + if (acc.prepare_listen_arm()) + acc.start_multishot(); return {}; } @@ -1130,6 +1257,11 @@ class BOOST_COROSIO_DECL io_uring_local_stream_socket final native_handle_type release_socket() noexcept override { + // Flush while the fd is still open so the kernel resolves + // pending SQEs before the caller can close and recycle the + // number (same reasoning as close_socket). + if (fd_ >= 0) + sched_->cancel_and_flush(fd_); int fd = fd_; fd_ = -1; local_endpoint_ = corosio::local_endpoint{}; @@ -1244,12 +1376,18 @@ class BOOST_COROSIO_DECL io_uring_local_stream_service final native_handle_type fd) override { auto& sock = static_cast(impl); + int nfd = static_cast(fd); + if (nfd >= 0 && nfd == sock.fd_) + return std::make_error_code(std::errc::invalid_argument); + if (auto ec = validate_socket_fd(nfd, SOCK_STREAM, false)) + return ec; + if (sock.fd_ >= 0) { sched_->cancel_and_flush(sock.fd_); ::close(sock.fd_); } - sock.fd_ = static_cast(fd); + sock.fd_ = nfd; sockaddr_storage local{}; socklen_t local_len = sizeof(local); @@ -1295,11 +1433,10 @@ class BOOST_COROSIO_DECL io_uring_local_stream_service final /** Local-stream (Unix domain) acceptor for io_uring. Inherits all multishot machinery (parked-fd queue, waiter queue, - CQE drain on destruction) from `io_uring_multishot_acceptor_base`. - Adds only the `accept()` override, the `adopt_thunk` static that - wraps an accepted fd via `io_uring_local_stream_service::adopt_fd`, - and `release_socket()` (a pure virtual in - `local_stream_acceptor::implementation` absent from the base). + descriptor release, CQE drain on destruction) from + `io_uring_multishot_acceptor_base`. Adds only the `accept()` + override and the `adopt_thunk` static that wraps an accepted fd + via `io_uring_local_stream_service::adopt_fd`. */ class BOOST_COROSIO_DECL io_uring_local_stream_acceptor final : public io_uring_multishot_acceptor_base< @@ -1345,15 +1482,31 @@ class BOOST_COROSIO_DECL io_uring_local_stream_acceptor final std::stop_token token, std::error_code* ec) override { - int poll_flags = 0; - switch (w) + // Multishot accepting drains the kernel queue as connections + // arrive, so a poll on the listener never reports it + // readable; read waits complete from the delivery queue. + if (w == wait_type::read) { - case wait_type::read: poll_flags = POLLIN; break; - case wait_type::write: poll_flags = POLLOUT; break; - case wait_type::error: poll_flags = POLLPRI | POLLERR | POLLHUP; break; + this->park_read_wait(h, ex, token, ec); + return std::noop_coroutine(); } + // Writability carries no meaning for a listening socket; + // fail uniformly instead of never completing. + if (w == wait_type::write) + { + // NOLINTNEXTLINE(bugprone-unhandled-exception-at-new) — noexcept-adjacent initiation path: OOM => std::terminate is the intended behavior + auto* op = new uring_accept_op(); + op->h = h; + op->ex = ex; + op->ec_out = ec; + op->err = ENOTSUP; + this->sched_->post(op); + return std::noop_coroutine(); + } + // Errors are not consumed by the accept machinery, so the + // error wait still polls the descriptor. wait_op_.prepare(h, ex, ec, this->fd_, this->sched_, - this->shared_from_this(), poll_flags, token); + this->shared_from_this(), POLLPRI | POLLERR | POLLHUP, token); this->sched_->work_started(); if (wait_op_.cancelled.load(std::memory_order_acquire)) { @@ -1365,29 +1518,6 @@ class BOOST_COROSIO_DECL io_uring_local_stream_acceptor final return std::noop_coroutine(); } - // release_socket() is pure virtual in local_stream_acceptor::implementation - // but not in tcp_acceptor::implementation, so the base does not cover it. - native_handle_type release_socket() noexcept override - { - // Mirror the service close() path: cancel the multishot SQE and - // break the multi_op_ -> impl_ptr (shared_ptr) cycle that - // start_multishot established. Without this, the cycle keeps the - // acceptor and its multi_op_ alive after the caller takes the fd, - // which LeakSanitizer reports on process exit. Caller still owns - // the returned fd, so we do NOT ::close it here. - if (this->fd_ >= 0) - { - this->sched_->cancel_and_flush(this->fd_); - this->drain_waiters_only(); - if (this->multi_op_) - this->multi_op_->impl_ptr.reset(); - } - int fd = this->fd_; - this->fd_ = -1; - this->local_endpoint_ = corosio::local_endpoint{}; - return fd; - } - static io_object::implementation* adopt_thunk( void* peer_service, int fd, sockaddr_storage const& peer, socklen_t peer_len) noexcept @@ -1511,6 +1641,49 @@ class BOOST_COROSIO_DECL io_uring_local_stream_acceptor_service final return {}; } + /** Adopt an already-listening descriptor. + + @param impl The acceptor implementation to assign to. + @param fd The native socket to adopt. + @return Error code on failure, empty on success. + */ + std::error_code assign_socket( + local_stream_acceptor::implementation& impl, + native_handle_type fd) override + { + auto& acc = static_cast(impl); + int nfd = static_cast(fd); + if (nfd >= 0 && nfd == acc.fd_) + return std::make_error_code(std::errc::invalid_argument); + if (auto ec = validate_socket_fd(nfd, SOCK_STREAM, false)) + return ec; + + if (acc.fd_ >= 0) + { + sched_->cancel_and_flush(acc.fd_); + acc.drain_waiters_only(); + ::close(acc.fd_); + acc.fd_ = -1; + } + + // Unconditional: release_socket() also leaves the op in flight, + // and it clears fd_ before returning. + acc.retire_multishot(); + + acc.adopt_listening_fd(nfd); + + acc.local_endpoint_ = corosio::local_endpoint{}; + sockaddr_storage local{}; + socklen_t local_len = sizeof(local); + if (::getsockname( + nfd, reinterpret_cast(&local), &local_len) == 0) + acc.local_endpoint_ = sockaddr_to_local_endpoint(local, local_len); + + if (fd_is_listening(nfd)) + acc.start_multishot(); + return {}; + } + /** Bind an open acceptor and capture the local endpoint. @param impl The acceptor implementation to bind. @@ -1552,7 +1725,8 @@ class BOOST_COROSIO_DECL io_uring_local_stream_acceptor_service final auto& acc = static_cast(impl); if (::listen(acc.fd_, backlog) < 0) return make_err(errno); - acc.start_multishot(); + if (acc.prepare_listen_arm()) + acc.start_multishot(); return {}; } @@ -1762,6 +1936,20 @@ class BOOST_COROSIO_DECL io_uring_udp_socket final // native_handle / is_open / set_option / get_option / local_endpoint // are inherited from native_socket_base. + native_handle_type release_socket() noexcept override + { + // Flush while the fd is still open so the kernel resolves + // pending SQEs before the caller can close and recycle the + // number (same reasoning as close_socket). + if (fd_ >= 0) + sched_->cancel_and_flush(fd_); + int fd = fd_; + fd_ = -1; + local_endpoint_ = endpoint{}; + remote_endpoint_ = endpoint{}; + return fd; + } + void cancel() noexcept override { if (fd_ >= 0) @@ -2054,6 +2242,54 @@ class BOOST_COROSIO_DECL io_uring_udp_service final return {}; } + /** Adopt a pre-created fd into an impl. + + Takes ownership of `fd` on success; the caller retains + ownership on failure. + + @param impl The socket implementation to assign to. + @param fd A valid, open, non-blocking IP datagram fd. + @return Error code on failure, empty on success. + */ + std::error_code assign_socket( + udp_socket::implementation& impl, + native_handle_type fd) override + { + auto& sock = static_cast(impl); + int nfd = static_cast(fd); + if (nfd >= 0 && nfd == sock.fd_) + return std::make_error_code(std::errc::invalid_argument); + if (auto ec = validate_socket_fd(nfd, SOCK_DGRAM, true)) + return ec; + + if (sock.fd_ >= 0) + { + sched_->cancel_and_flush(sock.fd_); + ::close(sock.fd_); + } + sock.fd_ = nfd; + + sock.local_endpoint_ = endpoint{}; + sock.remote_endpoint_ = endpoint{}; + + sockaddr_storage local{}; + socklen_t local_len = sizeof(local); + if (::getsockname(sock.fd_, + reinterpret_cast(&local), &local_len) == 0) + { + sock.local_endpoint_ = sockaddr_to_endpoint(local); + sock.family_ = local.ss_family; + } + + sockaddr_storage remote{}; + socklen_t remote_len = sizeof(remote); + if (::getpeername(sock.fd_, + reinterpret_cast(&remote), &remote_len) == 0) + sock.remote_endpoint_ = sockaddr_to_endpoint(remote); + + return {}; + } + /** Bind the socket and capture the local endpoint via `getsockname`. @param impl The socket implementation to bind. @@ -2287,6 +2523,11 @@ class BOOST_COROSIO_DECL io_uring_local_datagram_socket final native_handle_type release_socket() noexcept override { + // Flush while the fd is still open so the kernel resolves + // pending SQEs before the caller can close and recycle the + // number (same reasoning as close_socket). + if (fd_ >= 0) + sched_->cancel_and_flush(fd_); int fd = fd_; fd_ = -1; local_endpoint_ = corosio::local_endpoint{}; @@ -2612,12 +2853,18 @@ class BOOST_COROSIO_DECL io_uring_local_datagram_service final native_handle_type fd) override { auto& sock = static_cast(impl); + int nfd = static_cast(fd); + if (nfd >= 0 && nfd == sock.fd_) + return std::make_error_code(std::errc::invalid_argument); + if (auto ec = validate_socket_fd(nfd, SOCK_DGRAM, false)) + return ec; + if (sock.fd_ >= 0) { sched_->cancel_and_flush(sock.fd_); ::close(sock.fd_); } - sock.fd_ = static_cast(fd); + sock.fd_ = nfd; sockaddr_storage local{}; socklen_t local_len = sizeof(local); diff --git a/include/boost/corosio/native/detail/iocp/win_dissociate.hpp b/include/boost/corosio/native/detail/iocp/win_dissociate.hpp new file mode 100644 index 000000000..92f79eed9 --- /dev/null +++ b/include/boost/corosio/native/detail/iocp/win_dissociate.hpp @@ -0,0 +1,68 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#ifndef BOOST_COROSIO_NATIVE_DETAIL_IOCP_WIN_DISSOCIATE_HPP +#define BOOST_COROSIO_NATIVE_DETAIL_IOCP_WIN_DISSOCIATE_HPP + +#include + +#if BOOST_COROSIO_HAS_IOCP + +#include + +namespace boost::corosio::detail { + +/** Detach a socket from its I/O completion port. + + The documented API keeps a handle bound to its completion port for + the handle's lifetime; `NtSetInformationFile` with the + `FileReplaceCompletionInformation` class is the only way to sever + the association. Without this, a released socket can never be + adopted into an io_context again: re-association fails with + `ERROR_INVALID_PARAMETER`. + + @param s The socket to detach. + + @return `true` if the association was removed. +*/ +inline bool +dissociate_from_iocp(SOCKET s) noexcept +{ + using nt_set_information_file_fn = + LONG(NTAPI*)(HANDLE, ULONG_PTR*, void*, ULONG, ULONG); + + static nt_set_information_file_fn const fn = + []() noexcept -> nt_set_information_file_fn { + if (HMODULE h = ::GetModuleHandleW(L"ntdll.dll")) + { + // The two-step cast through void(*)() is the sanctioned + // FARPROC conversion; a direct cast trips + // -Wcast-function-type. + return reinterpret_cast( + reinterpret_cast( + ::GetProcAddress(h, "NtSetInformationFile"))); + } + return nullptr; + }(); + if (!fn) + return false; + + // FILE_COMPLETION_INFORMATION{ nullptr, nullptr } under info + // class FileReplaceCompletionInformation (61). + ULONG_PTR iosb[2] = {0, 0}; + void* info[2] = {nullptr, nullptr}; + return fn(reinterpret_cast(s), iosb, &info, sizeof(info), 61) == + 0; +} + +} // namespace boost::corosio::detail + +#endif // BOOST_COROSIO_HAS_IOCP + +#endif // BOOST_COROSIO_NATIVE_DETAIL_IOCP_WIN_DISSOCIATE_HPP diff --git a/include/boost/corosio/native/detail/iocp/win_local_stream_acceptor.hpp b/include/boost/corosio/native/detail/iocp/win_local_stream_acceptor.hpp index d7ced2ea9..9ea82001e 100644 --- a/include/boost/corosio/native/detail/iocp/win_local_stream_acceptor.hpp +++ b/include/boost/corosio/native/detail/iocp/win_local_stream_acceptor.hpp @@ -151,6 +151,7 @@ class win_local_stream_acceptor final bool is_open() const noexcept override; void cancel() noexcept override; + native_handle_type native_handle() const noexcept override; native_handle_type release_socket() noexcept override; std::error_code set_option( diff --git a/include/boost/corosio/native/detail/iocp/win_local_stream_acceptor_service.hpp b/include/boost/corosio/native/detail/iocp/win_local_stream_acceptor_service.hpp index e2394b478..0d83183b0 100644 --- a/include/boost/corosio/native/detail/iocp/win_local_stream_acceptor_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_local_stream_acceptor_service.hpp @@ -19,6 +19,7 @@ #include #include +#include #include #include @@ -55,6 +56,10 @@ class BOOST_COROSIO_DECL win_local_stream_acceptor_service final local_stream_acceptor::implementation& impl, int family, int type, int protocol) override; + std::error_code assign_socket( + local_stream_acceptor::implementation& impl, + native_handle_type fd) override; + std::error_code bind_acceptor( local_stream_acceptor::implementation& impl, @@ -310,9 +315,11 @@ win_local_stream_acceptor_internal::wait( svc_.work_started(); + // Writability carries no meaning for a listening socket; the + // wait fails the same way on every backend. if (w == wait_type::write) { - svc_.on_completion(&op, 0, 0); + svc_.on_completion(&op, WSAEOPNOTSUPP, 0); return std::noop_coroutine(); } @@ -501,6 +508,14 @@ win_local_stream_acceptor::cancel() noexcept internal_->cancel(); } +inline native_handle_type +win_local_stream_acceptor::native_handle() const noexcept +{ + if (!internal_) + return static_cast(INVALID_SOCKET); + return static_cast(internal_->native_handle()); +} + inline native_handle_type win_local_stream_acceptor::release_socket() noexcept { @@ -510,6 +525,7 @@ win_local_stream_acceptor::release_socket() noexcept if (s != INVALID_SOCKET) { internal_->cancel(); + dissociate_from_iocp(s); internal_->socket_ = INVALID_SOCKET; internal_->local_endpoint_ = corosio::local_endpoint{}; } @@ -608,6 +624,15 @@ win_local_stream_acceptor_service::open_acceptor_socket( return svc_.open_acceptor_socket(*internal, family, type, protocol); } +inline std::error_code +win_local_stream_acceptor_service::assign_socket( + local_stream_acceptor::implementation& impl, native_handle_type fd) +{ + auto* internal = + static_cast(impl).get_internal(); + return svc_.assign_acceptor_socket(*internal, fd); +} + inline std::error_code win_local_stream_acceptor_service::bind_acceptor( local_stream_acceptor::implementation& impl, diff --git a/include/boost/corosio/native/detail/iocp/win_local_stream_service.hpp b/include/boost/corosio/native/detail/iocp/win_local_stream_service.hpp index 7be8cf549..c1598a380 100644 --- a/include/boost/corosio/native/detail/iocp/win_local_stream_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_local_stream_service.hpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -88,6 +89,10 @@ class BOOST_COROSIO_DECL win_local_stream_service final win_local_stream_acceptor_internal& impl, int family, int type, int protocol); + std::error_code assign_acceptor_socket( + win_local_stream_acceptor_internal& impl, + native_handle_type fd); + std::error_code bind_acceptor( win_local_stream_acceptor_internal& impl, corosio::local_endpoint ep); @@ -602,12 +607,6 @@ win_local_stream_socket_internal::wait( svc_.work_started(); - if (w == wait_type::write) - { - svc_.on_completion(&op, 0, 0); - return std::noop_coroutine(); - } - if (w == wait_type::read) { // Zero-byte WSARecv — completes when data is available @@ -636,7 +635,10 @@ win_local_stream_socket_internal::wait( return std::noop_coroutine(); } - // wait_type::error: route through the auxiliary select reactor. + // wait_type::write and wait_type::error: route through the + // auxiliary poll reactor. There is no overlapped primitive for + // "the send buffer has room" that does not also transfer bytes, + // and a write wait must report real writability. svc_.scheduler().wait_reactor().register_wait(socket_, w, &op); return std::noop_coroutine(); } @@ -782,6 +784,10 @@ win_local_stream_socket::release_socket() noexcept if (s != INVALID_SOCKET) { internal_->cancel(); + // Sever the port association so the descriptor can be + // adopted again; best-effort, the caller keeps a working + // socket either way. + dissociate_from_iocp(s); internal_->socket_ = INVALID_SOCKET; internal_->local_endpoint_ = corosio::local_endpoint{}; internal_->remote_endpoint_ = corosio::local_endpoint{}; @@ -950,20 +956,50 @@ win_local_stream_service::assign_socket( auto& wrapper = static_cast(impl); auto& internal = *wrapper.get_internal(); - internal.close_socket(); - SOCKET sock = static_cast(fd); - + if (sock == INVALID_SOCKET) + return make_err(WSAENOTSOCK); + if (sock == internal.socket_) + return std::make_error_code(std::errc::invalid_argument); + + // SO_PROTOCOL_INFOW works on an unbound socket, unlike getsockname + // (WSAEINVAL until bind/connect names it) -- connect_pair hands in + // a socket that reached connected state without an explicit bind. + WSAPROTOCOL_INFOW proto_info{}; + int proto_len = sizeof(proto_info); + if (::getsockopt(sock, SOL_SOCKET, SO_PROTOCOL_INFOW, + reinterpret_cast(&proto_info), &proto_len) != 0) + return make_err(::WSAGetLastError()); + if (proto_info.iAddressFamily != AF_UNIX) + return make_err(WSAEAFNOSUPPORT); + if (proto_info.iSocketType != SOCK_STREAM) + return make_err(WSAEPROTOTYPE); + + // Associate before releasing the held socket: on IOCP nothing + // shares descriptor state the way the reactor path does, so a + // failed association must not cost the caller their old socket. HANDLE result = ::CreateIoCompletionPort( - reinterpret_cast(sock), static_cast(iocp_), key_io, 0); - + reinterpret_cast(sock), + static_cast(iocp_), key_io, 0); if (result == nullptr) - { - DWORD dwError = ::GetLastError(); - return make_err(dwError); - } + return make_err(::GetLastError()); + internal.close_socket(); internal.socket_ = sock; + + sockaddr_storage local{}; + int local_len = sizeof(local); + corosio::local_endpoint lep{}, rep{}; + if (::getsockname(sock, + reinterpret_cast(&local), &local_len) == 0) + lep = from_sockaddr_local(local, static_cast(local_len)); + sockaddr_storage remote{}; + int remote_len = sizeof(remote); + if (::getpeername(sock, + reinterpret_cast(&remote), &remote_len) == 0) + rep = from_sockaddr_local(remote, static_cast(remote_len)); + internal.local_endpoint_ = lep; + internal.remote_endpoint_ = rep; return {}; } @@ -1091,6 +1127,50 @@ win_local_stream_service::open_acceptor_socket( return {}; } +inline std::error_code +win_local_stream_service::assign_acceptor_socket( + win_local_stream_acceptor_internal& impl, native_handle_type fd) +{ + SOCKET sock = static_cast(fd); + if (sock == INVALID_SOCKET) + return make_err(WSAENOTSOCK); + if (sock == impl.socket_) + return std::make_error_code(std::errc::invalid_argument); + + // SO_PROTOCOL_INFOW works on an unbound socket, unlike getsockname + // (WSAEINVAL until bind names it). + WSAPROTOCOL_INFOW proto_info{}; + int proto_len = sizeof(proto_info); + if (::getsockopt(sock, SOL_SOCKET, SO_PROTOCOL_INFOW, + reinterpret_cast(&proto_info), &proto_len) != 0) + return make_err(::WSAGetLastError()); + if (proto_info.iAddressFamily != AF_UNIX) + return make_err(WSAEAFNOSUPPORT); + if (proto_info.iSocketType != SOCK_STREAM) + return make_err(WSAEPROTOTYPE); + + // Associate before releasing the held socket: on IOCP nothing + // shares descriptor state the way the reactor path does, so a + // failed association must not cost the caller their old socket. + HANDLE result = ::CreateIoCompletionPort( + reinterpret_cast(sock), static_cast(iocp_), key_io, 0); + if (result == nullptr) + return make_err(::GetLastError()); + + impl.close_socket(); + impl.socket_ = sock; + + sockaddr_storage local{}; + int local_len = sizeof(local); + corosio::local_endpoint lep{}; + if (::getsockname( + sock, reinterpret_cast(&local), &local_len) == 0) + lep = from_sockaddr_local(local, static_cast(local_len)); + impl.set_local_endpoint(lep); + + return {}; +} + inline std::error_code win_local_stream_service::bind_acceptor( win_local_stream_acceptor_internal& impl, diff --git a/include/boost/corosio/native/detail/iocp/win_tcp_acceptor.hpp b/include/boost/corosio/native/detail/iocp/win_tcp_acceptor.hpp index ff8162095..0acdb5cbc 100644 --- a/include/boost/corosio/native/detail/iocp/win_tcp_acceptor.hpp +++ b/include/boost/corosio/native/detail/iocp/win_tcp_acceptor.hpp @@ -159,6 +159,9 @@ class win_tcp_acceptor final bool is_open() const noexcept override; void cancel() noexcept override; + native_handle_type native_handle() const noexcept override; + native_handle_type release_socket() noexcept override; + std::error_code set_option( int level, int optname, diff --git a/include/boost/corosio/native/detail/iocp/win_tcp_acceptor_service.hpp b/include/boost/corosio/native/detail/iocp/win_tcp_acceptor_service.hpp index 2a0d8053a..0c70035db 100644 --- a/include/boost/corosio/native/detail/iocp/win_tcp_acceptor_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_tcp_acceptor_service.hpp @@ -20,6 +20,7 @@ #include #include +#include #include #include @@ -58,6 +59,10 @@ class BOOST_COROSIO_DECL win_tcp_acceptor_service final std::error_code open_acceptor_socket( tcp_acceptor::implementation& impl, int family, int type, int protocol); + /** Adopt an existing listening socket. */ + std::error_code assign_socket( + tcp_acceptor::implementation& impl, native_handle_type fd); + /** Bind an open acceptor to a local endpoint. */ std::error_code bind_acceptor(tcp_acceptor::implementation& impl, endpoint ep); @@ -709,14 +714,6 @@ win_tcp_socket_internal::wait( svc_.work_started(); - if (w == wait_type::write) - { - // Match asio's IOCP behavior and corosio's reactor contract: - // wait_type::write completes immediately on a connected socket. - svc_.on_completion(&op, 0, 0); - return std::noop_coroutine(); - } - if (w == wait_type::read) { // Zero-byte WSARecv: kernel signals completion when data is @@ -748,7 +745,10 @@ win_tcp_socket_internal::wait( return std::noop_coroutine(); } - // wait_type::error: route through the auxiliary select reactor. + // wait_type::write and wait_type::error: route through the + // auxiliary poll reactor. There is no overlapped primitive for + // "the send buffer has room" that does not also transfer bytes, + // and a write wait must report real writability. svc_.scheduler().wait_reactor().register_wait(socket_, w, &op); return std::noop_coroutine(); } @@ -898,6 +898,25 @@ win_tcp_socket::native_handle() const noexcept return static_cast(internal_->native_handle()); } +inline native_handle_type +win_tcp_socket::release_socket() noexcept +{ + SOCKET s = internal_->socket_; + if (s != INVALID_SOCKET) + { + internal_->cancel(); + // Sever the port association so the descriptor can be + // adopted again; best-effort, the caller keeps a working + // socket either way. + dissociate_from_iocp(s); + internal_->socket_ = INVALID_SOCKET; + internal_->family_ = AF_UNSPEC; + internal_->local_endpoint_ = endpoint{}; + internal_->remote_endpoint_ = endpoint{}; + } + return static_cast(s); +} + inline std::error_code win_tcp_socket::set_option( int level, int optname, void const* data, std::size_t size) noexcept @@ -1082,6 +1101,60 @@ win_tcp_service::open_socket( return {}; } +inline std::error_code +win_tcp_service::assign_socket( + win_tcp_socket_internal& impl, native_handle_type fd) +{ + SOCKET sock = static_cast(fd); + if (sock == INVALID_SOCKET) + return make_err(WSAENOTSOCK); + if (sock == impl.socket_) + return std::make_error_code(std::errc::invalid_argument); + + // SO_PROTOCOL_INFOW works on an unbound socket, unlike getsockname + // (WSAEINVAL until bind/connect names it) -- an adopted socket may + // have reached connected state without an explicit bind. + WSAPROTOCOL_INFOW proto_info{}; + int proto_len = sizeof(proto_info); + if (::getsockopt( + sock, SOL_SOCKET, SO_PROTOCOL_INFOW, + reinterpret_cast(&proto_info), &proto_len) != 0) + return make_err(::WSAGetLastError()); + if (proto_info.iAddressFamily != AF_INET && + proto_info.iAddressFamily != AF_INET6) + return make_err(WSAEAFNOSUPPORT); + if (proto_info.iSocketType != SOCK_STREAM) + return make_err(WSAEPROTOTYPE); + + // Associate before releasing the held socket: on IOCP nothing + // shares descriptor state the way the reactor path does, so a + // failed association must not cost the caller their old socket. + HANDLE result = ::CreateIoCompletionPort( + reinterpret_cast(sock), static_cast(iocp_), key_io, 0); + if (result == nullptr) + return make_err(::GetLastError()); + + impl.close_socket(); + impl.socket_ = sock; + impl.family_ = proto_info.iAddressFamily; + + endpoint local_ep, remote_ep; + sockaddr_storage local_storage{}; + int local_len = sizeof(local_storage); + if (::getsockname( + sock, reinterpret_cast(&local_storage), &local_len) == 0) + local_ep = detail::from_sockaddr(local_storage); + sockaddr_storage remote_storage{}; + int remote_len = sizeof(remote_storage); + if (::getpeername( + sock, reinterpret_cast(&remote_storage), + &remote_len) == 0) + remote_ep = detail::from_sockaddr(remote_storage); + impl.set_endpoints(local_ep, remote_ep); + + return {}; +} + inline std::error_code win_tcp_service::bind_socket(win_tcp_socket_internal& impl, endpoint ep) { @@ -1217,6 +1290,52 @@ win_tcp_service::open_acceptor_socket( return {}; } +inline std::error_code +win_tcp_service::assign_acceptor_socket( + win_tcp_acceptor_internal& impl, native_handle_type fd) +{ + SOCKET sock = static_cast(fd); + if (sock == INVALID_SOCKET) + return make_err(WSAENOTSOCK); + if (sock == impl.socket_) + return std::make_error_code(std::errc::invalid_argument); + + // SO_PROTOCOL_INFOW works on an unbound socket, unlike getsockname + // (WSAEINVAL until bind names it). + WSAPROTOCOL_INFOW proto_info{}; + int proto_len = sizeof(proto_info); + if (::getsockopt( + sock, SOL_SOCKET, SO_PROTOCOL_INFOW, + reinterpret_cast(&proto_info), &proto_len) != 0) + return make_err(::WSAGetLastError()); + if (proto_info.iAddressFamily != AF_INET && + proto_info.iAddressFamily != AF_INET6) + return make_err(WSAEAFNOSUPPORT); + if (proto_info.iSocketType != SOCK_STREAM) + return make_err(WSAEPROTOTYPE); + + // Associate before releasing the held socket: on IOCP nothing + // shares descriptor state the way the reactor path does, so a + // failed association must not cost the caller their old socket. + HANDLE result = ::CreateIoCompletionPort( + reinterpret_cast(sock), static_cast(iocp_), key_io, 0); + if (result == nullptr) + return make_err(::GetLastError()); + + impl.close_socket(); + impl.socket_ = sock; + + // AcceptEx sizes its address buffers from this cache, so an + // unseeded endpoint breaks accepts on an adopted v6 listener. + sockaddr_storage local_storage{}; + int local_len = sizeof(local_storage); + if (::getsockname( + sock, reinterpret_cast(&local_storage), &local_len) == 0) + impl.set_local_endpoint(detail::from_sockaddr(local_storage)); + + return {}; +} + inline std::error_code win_tcp_service::bind_acceptor(win_tcp_acceptor_internal& impl, endpoint ep) { @@ -1414,9 +1533,11 @@ win_tcp_acceptor_internal::wait( svc_.work_started(); + // Writability carries no meaning for a listening socket; the + // wait fails the same way on every backend. if (w == wait_type::write) { - svc_.on_completion(&op, 0, 0); + svc_.on_completion(&op, WSAEOPNOTSUPP, 0); return std::noop_coroutine(); } @@ -1519,6 +1640,30 @@ win_tcp_acceptor::cancel() noexcept internal_->cancel(); } +inline native_handle_type +win_tcp_acceptor::native_handle() const noexcept +{ + if (!internal_) + return static_cast(INVALID_SOCKET); + return static_cast(internal_->native_handle()); +} + +inline native_handle_type +win_tcp_acceptor::release_socket() noexcept +{ + if (!internal_) + return static_cast(INVALID_SOCKET); + SOCKET s = internal_->socket_; + if (s != INVALID_SOCKET) + { + internal_->cancel(); + dissociate_from_iocp(s); + internal_->socket_ = INVALID_SOCKET; + internal_->local_endpoint_ = endpoint{}; + } + return static_cast(s); +} + inline std::error_code win_tcp_acceptor::set_option( int level, int optname, void const* data, std::size_t size) noexcept @@ -1605,6 +1750,14 @@ win_tcp_acceptor_service::open_acceptor_socket( *wrapper.get_internal(), family, type, protocol); } +inline std::error_code +win_tcp_acceptor_service::assign_socket( + tcp_acceptor::implementation& impl, native_handle_type fd) +{ + auto& wrapper = static_cast(impl); + return svc_.assign_acceptor_socket(*wrapper.get_internal(), fd); +} + inline std::error_code win_tcp_acceptor_service::bind_acceptor( tcp_acceptor::implementation& impl, endpoint ep) diff --git a/include/boost/corosio/native/detail/iocp/win_tcp_service.hpp b/include/boost/corosio/native/detail/iocp/win_tcp_service.hpp index 83b963d78..e01455da5 100644 --- a/include/boost/corosio/native/detail/iocp/win_tcp_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_tcp_service.hpp @@ -98,6 +98,20 @@ class BOOST_COROSIO_DECL win_tcp_service final std::error_code open_socket(win_tcp_socket_internal& impl, int family, int type, int protocol); + /** Adopt an existing socket handle into an implementation. + + Validates family and type before touching the held socket, + then associates the new socket with the IOCP. On success the + impl takes ownership and will close the handle; on failure + the caller retains ownership. + + @param impl The socket implementation internal to assign to. + @param fd The native socket handle to adopt. + @return Error code, or success. + */ + std::error_code + assign_socket(win_tcp_socket_internal& impl, native_handle_type fd); + /** Bind a stream socket to a local endpoint. @param impl The socket implementation internal to bind. @@ -132,6 +146,20 @@ class BOOST_COROSIO_DECL win_tcp_service final std::error_code open_acceptor_socket( win_tcp_acceptor_internal& impl, int family, int type, int protocol); + /** Adopt an existing listening socket into an acceptor. + + Validates the socket, associates it with the IOCP, and only + then releases the socket the acceptor already held. Listen + state is not verified. + + @param impl The acceptor implementation internal. + @param fd The native socket to adopt. Ownership transfers only + on success. + @return Error code, or success. + */ + std::error_code assign_acceptor_socket( + win_tcp_acceptor_internal& impl, native_handle_type fd); + /** Bind an open acceptor to a local endpoint. @param impl The acceptor implementation internal. diff --git a/include/boost/corosio/native/detail/iocp/win_tcp_socket.hpp b/include/boost/corosio/native/detail/iocp/win_tcp_socket.hpp index b7075036b..6099c819a 100644 --- a/include/boost/corosio/native/detail/iocp/win_tcp_socket.hpp +++ b/include/boost/corosio/native/detail/iocp/win_tcp_socket.hpp @@ -94,9 +94,9 @@ struct write_op : overlapped_op Completion conveys an error_code only (no bytes_transferred). wait_type::read posts a zero-byte WSARecv: the kernel signals completion when data arrives without consuming it. - wait_type::write short-circuits through the scheduler queue. - wait_type::error parks the op in the auxiliary select reactor - until the kernel reports an error condition. + wait_type::write and wait_type::error park the op in the + auxiliary poll reactor until the socket becomes writable or the + kernel reports an error condition. */ struct wait_op : overlapped_op { @@ -242,6 +242,8 @@ class win_tcp_socket final native_handle_type native_handle() const noexcept override; + native_handle_type release_socket() noexcept override; + std::error_code set_option( int level, int optname, diff --git a/include/boost/corosio/native/detail/iocp/win_udp_service.hpp b/include/boost/corosio/native/detail/iocp/win_udp_service.hpp index 69e15a87f..71a2a39f4 100644 --- a/include/boost/corosio/native/detail/iocp/win_udp_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_udp_service.hpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -63,6 +64,9 @@ class BOOST_COROSIO_DECL win_udp_service final int family, int type, int protocol) override; + std::error_code assign_socket( + udp_socket::implementation& impl, + native_handle_type fd) override; std::error_code bind_datagram(udp_socket::implementation& impl, endpoint ep) override; @@ -710,17 +714,12 @@ win_udp_socket_internal::wait( svc_.work_started(); - if (w == wait_type::write) - { - svc_.on_completion(&op, 0, 0); - return std::noop_coroutine(); - } - - // Datagram wait_read and wait_error route through the auxiliary - // select reactor: there's no IOCP-native primitive for "datagram + // Every datagram wait routes through the auxiliary select + // reactor: there's no IOCP-native primitive for "datagram // readable without dequeuing the message" (zero-byte WSARecvFrom - // would discard the next datagram), and wait_error needs the - // reactor for the kernel-error signal in any case. + // would discard the next datagram), wait_write must report real + // writability rather than transfer bytes, and wait_error needs + // the reactor for the kernel-error signal in any case. svc_.scheduler().wait_reactor().register_wait(socket_, w, &op); return std::noop_coroutine(); } @@ -869,6 +868,22 @@ win_udp_socket::native_handle() const noexcept return static_cast(internal_->native_handle()); } +inline native_handle_type +win_udp_socket::release_socket() noexcept +{ + SOCKET s = internal_->socket_; + if (s != INVALID_SOCKET) + { + internal_->cancel(); + dissociate_from_iocp(s); + internal_->socket_ = INVALID_SOCKET; + internal_->family_ = AF_UNSPEC; + internal_->local_endpoint_ = endpoint{}; + internal_->remote_endpoint_ = endpoint{}; + } + return static_cast(s); +} + inline std::error_code win_udp_socket::set_option( int level, int optname, void const* data, std::size_t size) noexcept @@ -1042,6 +1057,63 @@ win_udp_service::open_datagram_socket( return open_socket(*wrapper.get_internal(), family, type, protocol); } +inline std::error_code +win_udp_service::assign_socket( + udp_socket::implementation& impl, native_handle_type fd) +{ + auto& wrapper = static_cast(impl); + auto* internal = wrapper.get_internal(); + + SOCKET sock = static_cast(fd); + if (sock == INVALID_SOCKET) + return make_err(WSAENOTSOCK); + if (sock == internal->socket_) + return std::make_error_code(std::errc::invalid_argument); + + // SO_PROTOCOL_INFOW works on an unbound socket, unlike getsockname + // (WSAEINVAL until bind names it). + WSAPROTOCOL_INFOW proto_info{}; + int proto_len = sizeof(proto_info); + if (::getsockopt( + sock, SOL_SOCKET, SO_PROTOCOL_INFOW, + reinterpret_cast(&proto_info), &proto_len) != 0) + return make_err(::WSAGetLastError()); + if (proto_info.iAddressFamily != AF_INET && + proto_info.iAddressFamily != AF_INET6) + return make_err(WSAEAFNOSUPPORT); + if (proto_info.iSocketType != SOCK_DGRAM) + return make_err(WSAEPROTOTYPE); + + // Associate before releasing the held socket: on IOCP nothing + // shares descriptor state the way the reactor path does, so a + // failed association must not cost the caller their old socket. + HANDLE result = ::CreateIoCompletionPort( + reinterpret_cast(sock), static_cast(iocp_), key_io, 0); + if (result == nullptr) + return make_err(::GetLastError()); + + internal->close_socket(); + internal->socket_ = sock; + internal->family_ = proto_info.iAddressFamily; + + endpoint local_ep, remote_ep; + sockaddr_storage local_storage{}; + int local_len = sizeof(local_storage); + if (::getsockname( + sock, reinterpret_cast(&local_storage), &local_len) == 0) + local_ep = detail::from_sockaddr(local_storage); + sockaddr_storage remote_storage{}; + int remote_len = sizeof(remote_storage); + if (::getpeername( + sock, reinterpret_cast(&remote_storage), + &remote_len) == 0) + remote_ep = detail::from_sockaddr(remote_storage); + internal->local_endpoint_ = local_ep; + internal->remote_endpoint_ = remote_ep; + + return {}; +} + inline std::error_code win_udp_service::bind_datagram(udp_socket::implementation& impl, endpoint ep) { diff --git a/include/boost/corosio/native/detail/iocp/win_udp_socket.hpp b/include/boost/corosio/native/detail/iocp/win_udp_socket.hpp index 0c3c8b7c0..b76fd9cd5 100644 --- a/include/boost/corosio/native/detail/iocp/win_udp_socket.hpp +++ b/include/boost/corosio/native/detail/iocp/win_udp_socket.hpp @@ -320,6 +320,8 @@ class win_udp_socket final native_handle_type native_handle() const noexcept override; + native_handle_type release_socket() noexcept override; + std::error_code set_option( int level, int optname, diff --git a/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp b/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp index 79d1607e8..82418e8d4 100644 --- a/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp +++ b/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp @@ -44,9 +44,10 @@ namespace boost::corosio::detail { IOCP has no native primitive for socket readiness without I/O. For cases where a zero-byte WSARecv won't work (datagram-read, - acceptor-read, error-wait), this reactor runs a dedicated thread - using WSAPoll to detect readiness and posts a synthetic completion - to the owning IOCP scheduler via win_scheduler::on_completion(). + acceptor-read, write-wait, error-wait), this reactor runs a + dedicated thread using WSAPoll to detect readiness and posts a + synthetic completion to the owning IOCP scheduler via + win_scheduler::on_completion(). The same dispatch path used by overlapped I/O then delivers the completion to the user's coroutine, so the public API is uniform diff --git a/include/boost/corosio/native/detail/kqueue/kqueue_scheduler.hpp b/include/boost/corosio/native/detail/kqueue/kqueue_scheduler.hpp index 3f7e9789f..95885750d 100644 --- a/include/boost/corosio/native/detail/kqueue/kqueue_scheduler.hpp +++ b/include/boost/corosio/native/detail/kqueue/kqueue_scheduler.hpp @@ -131,9 +131,11 @@ class BOOST_COROSIO_DECL kqueue_scheduler final : public reactor_scheduler @param fd The file descriptor to register. @param desc Pointer to the caller-owned reactor_descriptor_state. - @throws std::system_error if kevent(EV_ADD) fails. + @return The error if kevent(EV_ADD) fails, otherwise a default + constructed error code. */ - void register_descriptor(int fd, reactor_descriptor_state* desc) const; + std::error_code + register_descriptor(int fd, reactor_descriptor_state* desc) const; /** Deregister a persistently registered descriptor. @@ -148,7 +150,8 @@ class BOOST_COROSIO_DECL kqueue_scheduler final : public reactor_scheduler /// Watch the read end of the POSIX signal self-pipe (see scheduler.hpp). void register_signal_reader(int read_fd) override { - register_descriptor(read_fd, signal_pipe_reader_.arm()); + if (auto ec = register_descriptor(read_fd, signal_pipe_reader_.arm())) + detail::throw_system_error(ec, "kevent (register)"); } private: @@ -236,7 +239,7 @@ kqueue_scheduler::configure_reactor( event_buffer_.resize(max_events_per_poll_); } -inline void +inline std::error_code kqueue_scheduler::register_descriptor(int fd, reactor_descriptor_state* desc) const { struct kevent changes[2]; @@ -248,7 +251,7 @@ kqueue_scheduler::register_descriptor(int fd, reactor_descriptor_state* desc) co EV_ADD | EV_CLEAR, 0, 0, desc); if (::kevent(kq_fd_, changes, 2, nullptr, 0, nullptr) < 0) - detail::throw_system_error(make_err(errno), "kevent (register)"); + return make_err(errno); desc->registered_events = reactor_event_read | reactor_event_write; desc->fd = fd; @@ -260,6 +263,7 @@ kqueue_scheduler::register_descriptor(int fd, reactor_descriptor_state* desc) co desc->impl_ref_.reset(); desc->read_ready = false; desc->write_ready = false; + return {}; } inline void diff --git a/include/boost/corosio/native/detail/kqueue/kqueue_types.hpp b/include/boost/corosio/native/detail/kqueue/kqueue_types.hpp index 3c4caf51e..1cce4bedd 100644 --- a/include/boost/corosio/native/detail/kqueue/kqueue_types.hpp +++ b/include/boost/corosio/native/detail/kqueue/kqueue_types.hpp @@ -56,6 +56,12 @@ class kqueue_tcp_socket final public: explicit kqueue_tcp_socket(kqueue_tcp_service& svc) noexcept : base_type(svc) {} + + native_handle_type release_socket() noexcept override + { + hook_ = {}; + return this->do_release_socket(); + } }; class kqueue_local_stream_socket final @@ -94,6 +100,11 @@ class kqueue_udp_socket final public: explicit kqueue_udp_socket(kqueue_udp_service& svc) noexcept : base_type(svc) {} + + native_handle_type release_socket() noexcept override + { + return this->do_release_socket(); + } }; class kqueue_local_datagram_socket final @@ -162,11 +173,6 @@ class kqueue_local_stream_acceptor final explicit kqueue_local_stream_acceptor( kqueue_local_stream_acceptor_service& svc) noexcept : base_type(svc) {} - - native_handle_type release_socket() noexcept override - { - return this->do_release_socket(); - } }; // --- Services --- diff --git a/include/boost/corosio/native/detail/make_err.hpp b/include/boost/corosio/native/detail/make_err.hpp index bc92055ae..68c45a952 100644 --- a/include/boost/corosio/native/detail/make_err.hpp +++ b/include/boost/corosio/native/detail/make_err.hpp @@ -46,6 +46,11 @@ make_err(int errn) noexcept if (errn == ECANCELED) return capy::error::canceled; + // Part of the portable wait contract; system_category's condition + // mapping varies by C++ runtime. + if (errn == ENOTSUP) + return std::make_error_code(std::errc::operation_not_supported); + return std::error_code(errn, std::system_category()); } @@ -77,6 +82,19 @@ make_err(unsigned long dwError) noexcept if (dwError == ERROR_HANDLE_EOF) return capy::error::eof; + // Part of the portable wait and adoption contracts; + // system_category's condition mapping for WSA codes varies by + // toolchain. + if (dwError == WSAEOPNOTSUPP) + return std::make_error_code(std::errc::operation_not_supported); + if (dwError == WSAENOTSOCK) + return std::make_error_code(std::errc::not_a_socket); + if (dwError == WSAEAFNOSUPPORT) + return std::make_error_code( + std::errc::address_family_not_supported); + if (dwError == WSAEPROTOTYPE) + return std::make_error_code(std::errc::wrong_protocol_type); + return std::error_code(static_cast(dwError), std::system_category()); } diff --git a/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp b/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp index cf3a60350..096fc8bee 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp @@ -91,11 +91,17 @@ class reactor_acceptor ~reactor_acceptor() override = default; /// Return the underlying file descriptor. - int native_handle() const noexcept + native_handle_type native_handle() const noexcept override { return fd_; } + /// Release and return the native handle without closing it. + native_handle_type release_socket() noexcept override + { + return do_release_socket(); + } + /// Return the cached local endpoint. Endpoint local_endpoint() const noexcept override { @@ -153,6 +159,32 @@ class reactor_acceptor } } + /** Assign the fd, initialize descriptor state, and register with + the reactor. + + Adoption skips `do_listen`, so the registration it performs + has to happen here instead. + + @param fd The already-listening descriptor to adopt. + + @return The error if the reactor rejects the descriptor, in + which case the implementation is left closed and the caller + retains ownership of @a fd; otherwise a default constructed + error code. + */ + std::error_code init_and_register(int fd) noexcept + { + init_acceptor_fd(fd); + if (auto ec = svc_.scheduler().register_descriptor(fd, &desc_state_)) + { + fd_ = -1; + desc_state_.fd = -1; + desc_state_.registered_events = 0; + return ec; + } + return {}; + } + /// Return a reference to the owning service. Service& service() noexcept { @@ -176,10 +208,14 @@ class reactor_acceptor /** Wait for readiness on the listen socket. - Registers a wait op on the matching event slot. For - `wait_type::read`, completion signals that an incoming - connection is pending and a subsequent accept will - succeed without blocking. + For `wait_type::read`, completion signals that an incoming + connection is pending and a subsequent accept will succeed + without blocking; a connection already queued when the wait + begins completes it immediately via an initiation probe. + + `wait_type::write` fails with `operation_not_supported` on + every backend: writability carries no meaning for a + listening socket. */ std::coroutine_handle<> do_wait( std::coroutine_handle<>, @@ -227,7 +263,8 @@ class reactor_acceptor a successful listen() call. @param backlog The listen backlog. - @return The error code from listen(), or success. + @return The error code from listen() or from reactor + registration, or success. */ std::error_code do_listen(int backlog); }; @@ -469,8 +506,12 @@ reactor_acceptorfd_; op.start(token, static_cast(this)); op.impl_ptr = this->shared_from_this(); - op.complete(0, 0); + op.complete(ENOTSUP, 0); svc_.post(&op); return std::noop_coroutine(); } @@ -535,6 +578,18 @@ reactor_acceptor(this)); op.impl_ptr = this->shared_from_this(); + // A listener's readiness can predate the wait: an adopted or + // shared descriptor has history the reactor never saw, and an + // edge already dispatched will not be re-announced. Probe before + // parking. + int perr = 0; + if (WaitOp::probe(this->fd_, event, perr)) + { + op.complete(perr, 0); + svc_.post(&op); + return std::noop_coroutine(); + } + svc_.work_started(); std::lock_guard lock(desc_state_.mutex); @@ -543,6 +598,15 @@ reactor_acceptorfd_, event, perr)) + { + // Close the probe-to-park window: an edge that landed after + // the first probe was consumed, so re-check under the mutex + // the dispatch path holds. + op.complete(perr, 0); + svc_.post(&op); + svc_.work_finished(); + } else { *desc_slot_ptr = &op; diff --git a/include/boost/corosio/native/detail/reactor/reactor_backend.hpp b/include/boost/corosio/native/detail/reactor/reactor_backend.hpp index 2378a9294..16588e811 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_backend.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_backend.hpp @@ -79,17 +79,27 @@ reactor_acceptor_implscheduler().register_descriptor( + auto reg_ec = socket_svc->scheduler().register_descriptor( accepted, &impl.desc_state_); - - impl.set_endpoints( - this->local_endpoint_, - from_sockaddr_as( - peer_storage, peer_addrlen, Endpoint{})); - - *ec = {}; - if (impl_out) - *impl_out = &impl; + if (reg_ec) + { + // destroy() closes the fd the impl already owns. + socket_svc->destroy(&impl); + *ec = reg_ec; + if (impl_out) + *impl_out = nullptr; + } + else + { + impl.set_endpoints( + this->local_endpoint_, + from_sockaddr_as( + peer_storage, peer_addrlen, Endpoint{})); + + *ec = {}; + if (impl_out) + *impl_out = &impl; + } } else { diff --git a/include/boost/corosio/native/detail/reactor/reactor_basic_socket.hpp b/include/boost/corosio/native/detail/reactor/reactor_basic_socket.hpp index 304123daf..388dbd83d 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_basic_socket.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_basic_socket.hpp @@ -83,8 +83,17 @@ class reactor_basic_socket ~reactor_basic_socket() override = default; - /// Assign the fd, initialize descriptor state, and register with the reactor. - void init_and_register(int fd) noexcept + /** Assign the fd, initialize descriptor state, and register with + the reactor. + + @param fd The descriptor to adopt. + + @return The error if the reactor rejects the descriptor, in + which case the implementation is left closed and the caller + retains ownership of @a fd; otherwise a default constructed + error code. + */ + std::error_code init_and_register(int fd) noexcept { fd_ = fd; desc_state_.fd = fd; @@ -94,7 +103,16 @@ class reactor_basic_socket desc_state_.write_op = nullptr; desc_state_.connect_op = nullptr; } - svc_.scheduler().register_descriptor(fd, &desc_state_); + if (auto ec = svc_.scheduler().register_descriptor(fd, &desc_state_)) + { + // Undo the partial state so a failed adopt is + // indistinguishable from a closed implementation. + fd_ = -1; + desc_state_.fd = -1; + desc_state_.registered_events = 0; + return ec; + } + return {}; } /** Register an op with the reactor. @@ -224,12 +242,11 @@ reactor_basic_socket::cancel_si std::lock_guard lock(desc_state_.mutex); if (*desc_op_ptr == &op) claimed = std::exchange(*desc_op_ptr, nullptr); - else - { - bool* cflag = d->op_to_cancel_flag(op); - if (cflag) - *cflag = true; - } + // Not in the slot: request_cancel() above already set + // op.cancelled, which register_op consults before parking + // and the completion decode consults on delivery. Latching + // a descriptor flag here instead would outlive this op and + // cancel the next wait in the same direction. } if (claimed) { diff --git a/include/boost/corosio/native/detail/reactor/reactor_datagram_socket.hpp b/include/boost/corosio/native/detail/reactor/reactor_datagram_socket.hpp index ceef4b98e..cac7fe000 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_datagram_socket.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_datagram_socket.hpp @@ -287,12 +287,12 @@ class reactor_datagram_socket /** Shared readiness-wait dispatch. - `wait_type::write` completes immediately. Read and error - waits probe the descriptor with a zero-timeout `poll()` and - complete at once if the condition already holds; otherwise - the op re-probes under the descriptor mutex and parks, - completing when a reactor event arrives and a fresh probe - confirms the condition. + Every wait type probes the descriptor with a zero-timeout + `poll()` and completes at once if the condition already + holds; otherwise the op re-probes under the descriptor mutex + and parks, completing when a reactor event arrives and a + fresh probe confirms the condition. A write wait therefore + completes only while a non-blocking write can make progress. */ std::coroutine_handle<> do_wait( std::coroutine_handle<>, @@ -1002,29 +1002,6 @@ reactor_datagram_socket< std::stop_token const& token, std::error_code* ec) { - // wait_type::write completes immediately (see reactor_stream_socket::do_wait). - if (w == wait_type::write) - { - auto& op = wait_wr_; - if (this->svc_.scheduler().try_consume_inline_budget()) - { - *ec = std::error_code{}; - op.cont.h = h; - return dispatch_coro(ex, op.cont); - } - op.reset(); - op.wait_event = reactor_event_write; - op.h = h; - op.ex = ex; - op.ec_out = ec; - op.fd = this->fd_; - op.start(token, static_cast(this)); - op.impl_ptr = this->shared_from_this(); - op.complete(0, 0); - this->svc_.post(&op); - return std::noop_coroutine(); - } - WaitOp* op_ptr; reactor_op_base** desc_slot_ptr; bool* cancel_flag_ptr; @@ -1037,6 +1014,13 @@ reactor_datagram_socket< cancel_flag_ptr = &this->desc_state_.wait_read_cancel_pending; event = reactor_event_read; } + else if (w == wait_type::write) + { + op_ptr = &wait_wr_; + desc_slot_ptr = &this->desc_state_.wait_write_op; + cancel_flag_ptr = &this->desc_state_.wait_write_cancel_pending; + event = reactor_event_write; + } else // wait_type::error { op_ptr = &wait_er_; @@ -1090,7 +1074,7 @@ reactor_datagram_socket< bool force_probe = true; this->register_op( op, *desc_slot_ptr, force_probe, *cancel_flag_ptr, - false); + event == reactor_event_write); return std::noop_coroutine(); } diff --git a/include/boost/corosio/native/detail/reactor/reactor_op_complete.hpp b/include/boost/corosio/native/detail/reactor/reactor_op_complete.hpp index 44fc704f0..8e93bed24 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_op_complete.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_op_complete.hpp @@ -172,7 +172,9 @@ complete_connect_op(Op& op) @tparam SocketImpl The concrete socket implementation type. @tparam AcceptorImpl The concrete acceptor implementation type. @param acceptor_impl The acceptor that accepted the connection. - @param accepted_fd The accepted file descriptor (set to -1 on success). + @param accepted_fd The accepted file descriptor. Cleared to -1 + once the socket impl owns it, which includes the registration + failure that destroys the impl and closes the fd with it. @param peer_storage The peer address from accept(). @param impl_out Output pointer for the new socket impl. @param ec_out Output pointer for any error. @@ -205,7 +207,15 @@ setup_accepted_socket( impl.desc_state_.write_op = nullptr; impl.desc_state_.connect_op = nullptr; } - socket_svc->scheduler().register_descriptor(accepted_fd, &impl.desc_state_); + if (auto ec = socket_svc->scheduler().register_descriptor( + accepted_fd, &impl.desc_state_)) + { + // destroy() closes the fd the impl already owns. + accepted_fd = -1; + socket_svc->destroy(&impl); + *ec_out = ec; + return false; + } using ep_type = decltype(acceptor_impl->local_endpoint()); impl.set_endpoints( diff --git a/include/boost/corosio/native/detail/reactor/reactor_service_finals.hpp b/include/boost/corosio/native/detail/reactor/reactor_service_finals.hpp index 2581550a4..7180a7f87 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_service_finals.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_service_finals.hpp @@ -30,6 +30,7 @@ #include #include +#include #include #include @@ -66,7 +67,11 @@ do_open_socket( return ec; } - socket_impl->init_and_register(fd); + if (auto ec = socket_impl->init_and_register(fd)) + { + ::close(fd); + return ec; + } return {}; } @@ -75,31 +80,18 @@ std::error_code do_assign_fd( SocketFinal* socket_impl, int fd, - int expected_type) noexcept + int expected_type, + bool is_ip) noexcept { - if (fd < 0) - return make_err(EBADF); - - socket_impl->close_socket(); - - // Validate that fd is actually an AF_UNIX socket of the expected type. - { - sockaddr_storage st{}; - socklen_t st_len = sizeof(st); - if (::getsockname( - fd, reinterpret_cast(&st), &st_len) != 0) - return make_err(errno); - if (st.ss_family != AF_UNIX) - return make_err(EAFNOSUPPORT); - - int sock_type = 0; - socklen_t opt_len = sizeof(sock_type); - if (::getsockopt( - fd, SOL_SOCKET, SO_TYPE, &sock_type, &opt_len) != 0) - return make_err(errno); - if (sock_type != expected_type) - return make_err(EPROTOTYPE); - } + // fd >= 0 guard: an unset socket_impl reports native_handle() == -1, + // and a caller-supplied -1 must fail as a bad fd, not a self-assign. + if (fd >= 0 && fd == socket_impl->native_handle()) + return std::make_error_code(std::errc::invalid_argument); + + // Validate before touching the held socket: a failed assign must + // leave the object unchanged and the caller owning the fd. + if (auto ec = validate_socket_fd(fd, expected_type, is_ip)) + return ec; // Adopt-only: do not mutate the caller's fd flags. Callers // pass fds they have already configured (e.g., from socketpair @@ -107,7 +99,10 @@ do_assign_fd( if (auto ec = Traits::validate_assigned_fd(fd)) return ec; - socket_impl->init_and_register(fd); + socket_impl->close_socket(); + + if (auto ec = socket_impl->init_and_register(fd)) + return ec; // Best-effort: refresh endpoint caches. using endpoint_type = std::remove_cvref_t< @@ -159,6 +154,43 @@ do_open_acceptor( return {}; } +// Acceptor twin of do_assign_fd: always SOCK_STREAM, and refreshes +// only the local endpoint because listeners have no peer. Listen +// state is not verified; accept() surfaces the error naturally if +// the descriptor is not listening. +template +std::error_code +do_assign_acceptor_fd(AccFinal* acc_impl, int fd, bool is_ip) noexcept +{ + if (fd >= 0 && fd == acc_impl->native_handle()) + return std::make_error_code(std::errc::invalid_argument); + + if (auto ec = validate_socket_fd(fd, SOCK_STREAM, is_ip)) + return ec; + + if (auto ec = Traits::validate_assigned_fd(fd)) + return ec; + + acc_impl->close_socket(); + + if (auto ec = acc_impl->init_and_register(fd)) + return ec; + + using endpoint_type = std::remove_cvref_t< + decltype(acc_impl->local_endpoint())>; + + endpoint_type local_ep{}; + sockaddr_storage local_storage{}; + socklen_t local_len = sizeof(local_storage); + if (::getsockname( + fd, reinterpret_cast(&local_storage), &local_len) == 0) + local_ep = from_sockaddr_as(local_storage, local_len, endpoint_type{}); + + acc_impl->set_local_endpoint(local_ep); + + return {}; +} + // ============================================================ // TCP service // ============================================================ @@ -193,6 +225,13 @@ class reactor_tcp_service_impl family, type, protocol, true); } + std::error_code assign_socket( + tcp_socket::implementation& impl, native_handle_type fd) override + { + return do_assign_fd( + static_cast(&impl), fd, SOCK_STREAM, true); + } + std::error_code bind_socket( tcp_socket::implementation& impl, endpoint ep) override { @@ -245,10 +284,11 @@ class reactor_local_stream_service_impl } std::error_code assign_socket( - local_stream_socket::implementation& impl, int fd) override + local_stream_socket::implementation& impl, + native_handle_type fd) override { return do_assign_fd( - static_cast(&impl), fd, SOCK_STREAM); + static_cast(&impl), fd, SOCK_STREAM, false); } }; @@ -286,6 +326,13 @@ class reactor_udp_service_impl family, type, protocol, true); } + std::error_code assign_socket( + udp_socket::implementation& impl, native_handle_type fd) override + { + return do_assign_fd( + static_cast(&impl), fd, SOCK_DGRAM, true); + } + std::error_code bind_datagram( udp_socket::implementation& impl, endpoint ep) override { @@ -328,10 +375,11 @@ class reactor_local_dgram_service_impl } std::error_code assign_socket( - local_datagram_socket::implementation& impl, int fd) override + local_datagram_socket::implementation& impl, + native_handle_type fd) override { return do_assign_fd( - static_cast(&impl), fd, SOCK_DGRAM); + static_cast(&impl), fd, SOCK_DGRAM, false); } std::error_code bind_socket( @@ -384,6 +432,15 @@ class reactor_acceptor_service_impl std::is_same_v); } + std::error_code assign_socket( + typename AccFinal::impl_base_type& impl, + native_handle_type fd) override + { + return do_assign_acceptor_fd( + static_cast(&impl), fd, + std::is_same_v); + } + std::error_code bind_acceptor( typename AccFinal::impl_base_type& impl, Endpoint ep) override diff --git a/include/boost/corosio/native/detail/reactor/reactor_stream_socket.hpp b/include/boost/corosio/native/detail/reactor/reactor_stream_socket.hpp index f5796ae15..26fbeb58a 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_stream_socket.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_stream_socket.hpp @@ -247,12 +247,12 @@ class reactor_stream_socket /** Shared readiness-wait dispatch. - `wait_type::write` completes immediately. Read and error - waits probe the descriptor with a zero-timeout `poll()` and - complete at once if the condition already holds; otherwise - the op re-probes under the descriptor mutex and parks, - completing when a reactor event arrives and a fresh probe - confirms the condition. + Every wait type probes the descriptor with a zero-timeout + `poll()` and completes at once if the condition already + holds; otherwise the op re-probes under the descriptor mutex + and parks, completing when a reactor event arrives and a + fresh probe confirms the condition. A write wait therefore + completes only while a non-blocking write can make progress. */ std::coroutine_handle<> do_wait( std::coroutine_handle<>, @@ -272,6 +272,14 @@ class reactor_stream_socket remote_endpoint_ = Endpoint{}; } + /// Release ownership of the descriptor and drop the cached peer. + native_handle_type do_release_socket() noexcept + { + auto fd = base_type::do_release_socket(); + remote_endpoint_ = Endpoint{}; + return fd; + } + private: // CRTP callbacks for reactor_basic_socket cancel/close @@ -637,33 +645,6 @@ reactor_stream_socket writable transition. - if (w == wait_type::write) - { - auto& op = wait_wr_; - if (this->svc_.scheduler().try_consume_inline_budget()) - { - *ec = std::error_code{}; - op.cont.h = h; - return dispatch_coro(ex, op.cont); - } - op.reset(); - op.wait_event = reactor_event_write; - op.h = h; - op.ex = ex; - op.ec_out = ec; - op.fd = this->fd_; - op.start(token, static_cast(this)); - op.impl_ptr = this->shared_from_this(); - op.complete(0, 0); - this->svc_.post(&op); - return std::noop_coroutine(); - } - // Pick refs up-front to avoid duplicating the register_op call. WaitOp* op_ptr; reactor_op_base** desc_slot_ptr; @@ -677,6 +658,13 @@ reactor_stream_socketdesc_state_.wait_read_cancel_pending; event = reactor_event_read; } + else if (w == wait_type::write) + { + op_ptr = &wait_wr_; + desc_slot_ptr = &this->desc_state_.wait_write_op; + cancel_flag_ptr = &this->desc_state_.wait_write_cancel_pending; + event = reactor_event_write; + } else // wait_type::error { op_ptr = &wait_er_; @@ -729,7 +717,7 @@ reactor_stream_socketregister_op(op, *desc_slot_ptr, force_probe, *cancel_flag_ptr, - false); + event == reactor_event_write); return std::noop_coroutine(); } diff --git a/include/boost/corosio/native/detail/select/select_scheduler.hpp b/include/boost/corosio/native/detail/select/select_scheduler.hpp index b0489c6b4..6390e5dc8 100644 --- a/include/boost/corosio/native/detail/select/select_scheduler.hpp +++ b/include/boost/corosio/native/detail/select/select_scheduler.hpp @@ -41,6 +41,7 @@ #include #include #include +#include #include namespace boost::corosio::detail { @@ -109,8 +110,12 @@ class BOOST_COROSIO_DECL select_scheduler final : public reactor_scheduler @param fd The file descriptor to register. @param desc Pointer to descriptor state for this fd. + + @return The error if the fd cannot be tracked, otherwise a + default constructed error code. */ - void register_descriptor(int fd, reactor_descriptor_state* desc) const; + std::error_code + register_descriptor(int fd, reactor_descriptor_state* desc) const; /** Deregister a persistently registered descriptor. @@ -120,16 +125,17 @@ class BOOST_COROSIO_DECL select_scheduler final : public reactor_scheduler /** Interrupt the reactor so it rebuilds its fd_sets. - Called when a write or connect op is registered after - the reactor's snapshot was taken. Without this, select() - may block not watching for writability on the fd. + Called when a write, connect, or write-wait op is registered + after the reactor's snapshot was taken. Without this, + select() may block not watching for writability on the fd. */ void notify_reactor() const; /// Watch the read end of the POSIX signal self-pipe (see scheduler.hpp). void register_signal_reader(int read_fd) override { - register_descriptor(read_fd, signal_pipe_reader_.arm()); + if (auto ec = register_descriptor(read_fd, signal_pipe_reader_.arm())) + detail::throw_system_error(ec, "select: register"); } private: @@ -215,12 +221,12 @@ select_scheduler::shutdown() interrupt_reactor(); } -inline void +inline std::error_code select_scheduler::register_descriptor( int fd, reactor_descriptor_state* desc) const { if (fd < 0 || fd >= FD_SETSIZE) - detail::throw_system_error(make_err(EINVAL), "select: fd out of range"); + return make_err(EMFILE); desc->registered_events = reactor_event_read | reactor_event_write; desc->fd = fd; @@ -237,12 +243,20 @@ select_scheduler::register_descriptor( { mutex_type::scoped_lock lock(mutex_); - registered_descs_[fd] = desc; + try + { + registered_descs_[fd] = desc; + } + catch (std::bad_alloc const&) + { + return make_err(ENOMEM); + } if (fd > max_fd_) max_fd_ = fd; } interrupt_reactor(); + return {}; } inline void @@ -324,7 +338,9 @@ select_scheduler::run_task( // Record which fds need write monitoring to avoid a hot loop: // select is level-triggered so writable sockets (nearly always // writable) would cause select() to return immediately every - // iteration if unconditionally added to write_fds. + // iteration if unconditionally added to write_fds. Membership + // stays opt-in: a parked write wait opts in the same way a + // parked write or connect op does. struct fd_entry { int fd; @@ -342,7 +358,8 @@ select_scheduler::run_task( snapshot[snapshot_count].fd = fd; snapshot[snapshot_count].desc = desc; snapshot[snapshot_count].needs_write = - (desc->write_op || desc->connect_op); + (desc->write_op || desc->connect_op || + desc->wait_write_op); ++snapshot_count; } } diff --git a/include/boost/corosio/native/detail/select/select_types.hpp b/include/boost/corosio/native/detail/select/select_types.hpp index 941b23969..4651dd86d 100644 --- a/include/boost/corosio/native/detail/select/select_types.hpp +++ b/include/boost/corosio/native/detail/select/select_types.hpp @@ -56,6 +56,12 @@ class select_tcp_socket final public: explicit select_tcp_socket(select_tcp_service& svc) noexcept : base_type(svc) {} + + native_handle_type release_socket() noexcept override + { + hook_ = {}; + return this->do_release_socket(); + } }; class select_local_stream_socket final @@ -94,6 +100,11 @@ class select_udp_socket final public: explicit select_udp_socket(select_udp_service& svc) noexcept : base_type(svc) {} + + native_handle_type release_socket() noexcept override + { + return this->do_release_socket(); + } }; class select_local_datagram_socket final @@ -162,11 +173,6 @@ class select_local_stream_acceptor final explicit select_local_stream_acceptor( select_local_stream_acceptor_service& svc) noexcept : base_type(svc) {} - - native_handle_type release_socket() noexcept override - { - return this->do_release_socket(); - } }; // --- Services --- diff --git a/include/boost/corosio/native/detail/validate_fd.hpp b/include/boost/corosio/native/detail/validate_fd.hpp new file mode 100644 index 000000000..243fdc8f3 --- /dev/null +++ b/include/boost/corosio/native/detail/validate_fd.hpp @@ -0,0 +1,73 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#ifndef BOOST_COROSIO_NATIVE_DETAIL_VALIDATE_FD_HPP +#define BOOST_COROSIO_NATIVE_DETAIL_VALIDATE_FD_HPP + +#include + +#if BOOST_COROSIO_POSIX + +#include + +#include +#include + +#include + +namespace boost::corosio::detail { + +/** Validate a caller-supplied socket fd for adoption. + + Non-mutating: interrogates the fd without changing any of its + flags, so a rejected fd goes back to the caller untouched. + + @param fd The descriptor to validate. + @param expected_type `SOCK_STREAM` or `SOCK_DGRAM`. + @param is_ip Accept `AF_INET`/`AF_INET6` when true, `AF_UNIX` + when false. + @return Empty on success; `EBADF`, `EAFNOSUPPORT`, `EPROTOTYPE`, + or the `errno` reported by the interrogating call. +*/ +inline std::error_code +validate_socket_fd(int fd, int expected_type, bool is_ip) noexcept +{ + if (fd < 0) + return make_err(EBADF); + + sockaddr_storage st{}; + socklen_t st_len = sizeof(st); + if (::getsockname(fd, reinterpret_cast(&st), &st_len) != 0) + return make_err(errno); + if (is_ip) + { + if (st.ss_family != AF_INET && st.ss_family != AF_INET6) + return make_err(EAFNOSUPPORT); + } + else if (st.ss_family != AF_UNIX) + { + return make_err(EAFNOSUPPORT); + } + + int sock_type = 0; + socklen_t opt_len = sizeof(sock_type); + if (::getsockopt(fd, SOL_SOCKET, SO_TYPE, + &sock_type, &opt_len) != 0) + return make_err(errno); + if (sock_type != expected_type) + return make_err(EPROTOTYPE); + + return {}; +} + +} // namespace boost::corosio::detail + +#endif // BOOST_COROSIO_POSIX + +#endif // BOOST_COROSIO_NATIVE_DETAIL_VALIDATE_FD_HPP diff --git a/include/boost/corosio/tcp_acceptor.hpp b/include/boost/corosio/tcp_acceptor.hpp index 2c419e2a3..7dde39951 100644 --- a/include/boost/corosio/tcp_acceptor.hpp +++ b/include/boost/corosio/tcp_acceptor.hpp @@ -13,6 +13,7 @@ #include #include +#include #include #include #include @@ -437,8 +438,14 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object Suspends until the listen socket is ready in the requested direction, or an error condition is reported. For `wait_type::read`, completion signals that a - subsequent @ref accept will succeed without blocking. - No connection is consumed. + subsequent @ref accept will succeed without blocking; a + connection already queued when the wait begins completes + it immediately. No connection is consumed. + + @note `wait_type::write` is not usable on an acceptor: + writability carries no meaning for a listening socket, so + the wait fails with `errc::operation_not_supported` on + every backend. @param w The wait direction. @@ -462,6 +469,65 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object */ void cancel(); + /** Get the native socket handle. + + Returns the underlying platform-specific socket descriptor. + On POSIX systems this is an `int` file descriptor. + On Windows this is a `SOCKET` handle. + + @return The native socket handle, or -1/INVALID_SOCKET if not open. + + @par Preconditions + None. May be called on closed acceptors. + */ + native_handle_type native_handle() const noexcept; + + /** Assign an existing native socket to this acceptor. + + Adopts a listening socket created outside the library — + received from a service manager, inherited, or made natively — + and registers it with the backend. The socket must be a + listening stream socket in the `AF_INET` or `AF_INET6` family. + Adoption never alters the descriptor's flags or options: on + POSIX the fd must already be non-blocking, and on Windows the + socket must be overlapped-capable. + + Adoption does not verify listen state; @ref accept reports the + error if the socket is not listening. + + If this object is already open, pending operations complete + with `errc::operation_canceled` and the held socket is + closed before the new one is adopted. + + @par Exception Safety + Strong guarantee on validation failure: the object is + unchanged. If backend registration fails, the object either + retains its previous socket or is left closed, depending on + the backend. In all failure cases the caller retains + ownership of `fd`. + + @param fd The native socket to adopt. On success the object + owns it and will close it. + + @throws std::system_error On validation or registration + failure. + */ + void assign(native_handle_type fd); + + /** Release ownership of the native socket handle. + + Deregisters the socket from the backend and cancels pending + operations without closing the descriptor. The caller takes + ownership of the returned handle. + + @return The native handle. + + @throws std::logic_error if the acceptor is not open. + + @post is_open() == false + */ + native_handle_type release(); + /** Get the local endpoint of the acceptor. Returns the local address and port to which the acceptor is bound. @@ -576,6 +642,12 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object /// Return true if the acceptor has a kernel resource open. virtual bool is_open() const noexcept = 0; + /// Return the native handle, or the platform sentinel if closed. + virtual native_handle_type native_handle() const noexcept = 0; + + /// Release and return the native handle without closing. + virtual native_handle_type release_socket() noexcept = 0; + /** Cancel any pending asynchronous operations. All outstanding operations complete with operation_canceled error. diff --git a/include/boost/corosio/tcp_socket.hpp b/include/boost/corosio/tcp_socket.hpp index d5e6d51fc..349eaca15 100644 --- a/include/boost/corosio/tcp_socket.hpp +++ b/include/boost/corosio/tcp_socket.hpp @@ -141,6 +141,16 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream /// Return the platform socket descriptor. virtual native_handle_type native_handle() const noexcept = 0; + /** Release ownership of the native socket handle. + + Deregisters the socket from the backend and cancels + pending operations without closing the descriptor. The + caller takes ownership. + + @return The native handle. + */ + virtual native_handle_type release_socket() noexcept = 0; + /** Request cancellation of pending asynchronous operations. All outstanding operations complete with operation_canceled error. @@ -432,6 +442,49 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream */ native_handle_type native_handle() const noexcept; + /** Assign an existing native socket to this object. + + Adopts a TCP socket created outside the library — received + from another process, inherited, or made natively — and + registers it with the backend. The socket must be a stream + socket in the `AF_INET` or `AF_INET6` family. Adoption never + alters the descriptor's flags or options: on POSIX the fd + must already be non-blocking, and on Windows the socket must + be overlapped-capable. + + If this object is already open, pending operations complete + with `errc::operation_canceled` and the held socket is + closed before the new one is adopted. + + @par Exception Safety + Strong guarantee on validation failure: the object is + unchanged. If backend registration fails, the object either + retains its previous socket or is left closed, depending on + the backend. In all failure cases the caller retains + ownership of `fd`. + + @param fd The native socket to adopt. On success the object + owns it and will close it. + + @throws std::system_error On validation or registration + failure. + */ + void assign(native_handle_type fd); + + /** Release ownership of the native socket handle. + + Deregisters the socket from the backend and cancels pending + operations without closing the descriptor. The caller takes + ownership of the returned handle. + + @return The native handle. + + @throws std::logic_error if the socket is not open. + + @post is_open() == false + */ + native_handle_type release(); + /** Disable sends or receives on the socket. TCP connections are full-duplex: each direction (send and receive) diff --git a/include/boost/corosio/udp_socket.hpp b/include/boost/corosio/udp_socket.hpp index 0fd68396a..87e702aba 100644 --- a/include/boost/corosio/udp_socket.hpp +++ b/include/boost/corosio/udp_socket.hpp @@ -145,6 +145,16 @@ class BOOST_COROSIO_DECL udp_socket : public io_object /// Return the platform socket descriptor. virtual native_handle_type native_handle() const noexcept = 0; + /** Release ownership of the native socket handle. + + Deregisters the socket from the backend and cancels + pending operations without closing the descriptor. The + caller takes ownership. + + @return The native handle. + */ + virtual native_handle_type release_socket() noexcept = 0; + /** Request cancellation of pending asynchronous operations. All outstanding operations complete with operation_canceled @@ -507,6 +517,49 @@ class BOOST_COROSIO_DECL udp_socket : public io_object */ native_handle_type native_handle() const noexcept; + /** Assign an existing native socket to this object. + + Adopts a UDP socket created outside the library — received + from another process, inherited, or made natively — and + registers it with the backend. The socket must be a datagram + socket in the `AF_INET` or `AF_INET6` family. Adoption never + alters the descriptor's flags or options: on POSIX the fd + must already be non-blocking, and on Windows the socket must + be overlapped-capable. + + If this object is already open, pending operations complete + with `errc::operation_canceled` and the held socket is + closed before the new one is adopted. + + @par Exception Safety + Strong guarantee on validation failure: the object is + unchanged. If backend registration fails, the object either + retains its previous socket or is left closed, depending on + the backend. In all failure cases the caller retains + ownership of `fd`. + + @param fd The native socket to adopt. On success the object + owns it and will close it. + + @throws std::system_error On validation or registration + failure. + */ + void assign(native_handle_type fd); + + /** Release ownership of the native socket handle. + + Deregisters the socket from the backend and cancels pending + operations without closing the descriptor. The caller takes + ownership of the returned handle. + + @return The native handle. + + @throws std::logic_error if the socket is not open. + + @post is_open() == false + */ + native_handle_type release(); + /** Set a socket option. @param opt The option to set. diff --git a/src/corosio/src/local_datagram_socket.cpp b/src/corosio/src/local_datagram_socket.cpp index ea2be7d71..2b3cb435f 100644 --- a/src/corosio/src/local_datagram_socket.cpp +++ b/src/corosio/src/local_datagram_socket.cpp @@ -96,8 +96,6 @@ local_datagram_socket::shutdown(shutdown_type what, std::error_code& ec) noexcep void local_datagram_socket::assign(native_handle_type fd) { - if (is_open()) - detail::throw_logic_error("assign: socket already open"); auto& svc = static_cast(h_.service()); std::error_code ec = svc.assign_socket( static_cast(*h_.get()), fd); diff --git a/src/corosio/src/local_stream_acceptor.cpp b/src/corosio/src/local_stream_acceptor.cpp index 0378e58c9..39ea54614 100644 --- a/src/corosio/src/local_stream_acceptor.cpp +++ b/src/corosio/src/local_stream_acceptor.cpp @@ -53,6 +53,31 @@ local_stream_acceptor::open(local_stream proto) detail::throw_system_error(ec, "local_stream_acceptor::open"); } +void +local_stream_acceptor::assign(native_handle_type fd) +{ + auto& svc = + static_cast(h_.service()); + auto ec = svc.assign_socket( + static_cast(*h_.get()), fd); + if (ec) + detail::throw_system_error(ec, "local_stream_acceptor::assign"); +} + +native_handle_type +local_stream_acceptor::native_handle() const noexcept +{ + if (!is_open()) + { +#if BOOST_COROSIO_HAS_IOCP + return static_cast(~0ull); // INVALID_SOCKET +#else + return -1; +#endif + } + return get().native_handle(); +} + std::error_code local_stream_acceptor::bind(corosio::local_endpoint ep, bind_option opt) { diff --git a/src/corosio/src/local_stream_socket.cpp b/src/corosio/src/local_stream_socket.cpp index 2ea544cb6..4579c4db0 100644 --- a/src/corosio/src/local_stream_socket.cpp +++ b/src/corosio/src/local_stream_socket.cpp @@ -89,8 +89,6 @@ local_stream_socket::shutdown(shutdown_type what, std::error_code& ec) noexcept void local_stream_socket::assign(native_handle_type fd) { - if (is_open()) - detail::throw_logic_error("assign: socket already open"); auto& svc = static_cast(h_.service()); std::error_code ec = svc.assign_socket( static_cast(*h_.get()), fd); diff --git a/src/corosio/src/tcp_acceptor.cpp b/src/corosio/src/tcp_acceptor.cpp index cace8ca9f..db0cb15d0 100644 --- a/src/corosio/src/tcp_acceptor.cpp +++ b/src/corosio/src/tcp_acceptor.cpp @@ -66,6 +66,42 @@ tcp_acceptor::open(tcp proto) detail::throw_system_error(ec, "tcp_acceptor::open"); } +void +tcp_acceptor::assign(native_handle_type fd) +{ +#if BOOST_COROSIO_HAS_IOCP + auto& svc = static_cast(h_.service()); +#else + auto& svc = static_cast(h_.service()); +#endif + std::error_code ec = svc.assign_socket( + *static_cast(h_.get()), fd); + if (ec) + detail::throw_system_error(ec, "tcp_acceptor::assign"); +} + +native_handle_type +tcp_acceptor::release() +{ + if (!is_open()) + detail::throw_logic_error("release: acceptor not open"); + return get().release_socket(); +} + +native_handle_type +tcp_acceptor::native_handle() const noexcept +{ + if (!is_open()) + { +#if BOOST_COROSIO_HAS_IOCP + return static_cast(~0ull); // INVALID_SOCKET +#else + return -1; +#endif + } + return get().native_handle(); +} + std::error_code tcp_acceptor::bind(endpoint ep) { diff --git a/src/corosio/src/tcp_socket.cpp b/src/corosio/src/tcp_socket.cpp index 34c351ee7..f93947d9d 100644 --- a/src/corosio/src/tcp_socket.cpp +++ b/src/corosio/src/tcp_socket.cpp @@ -61,6 +61,31 @@ tcp_socket::open_for_family(int family, int type, int protocol) detail::throw_system_error(ec, "tcp_socket::open"); } +void +tcp_socket::assign(native_handle_type fd) +{ +#if BOOST_COROSIO_HAS_IOCP + auto& svc = static_cast(h_.service()); + auto& wrapper = static_cast(*h_.get()); + std::error_code ec = svc.assign_socket( + *static_cast(wrapper).get_internal(), fd); +#else + auto& svc = static_cast(h_.service()); + std::error_code ec = svc.assign_socket( + static_cast(*h_.get()), fd); +#endif + if (ec) + detail::throw_system_error(ec, "tcp_socket::assign"); +} + +native_handle_type +tcp_socket::release() +{ + if (!is_open()) + detail::throw_logic_error("release: socket not open"); + return get().release_socket(); +} + std::error_code tcp_socket::bind(endpoint ep) { diff --git a/src/corosio/src/udp_socket.cpp b/src/corosio/src/udp_socket.cpp index 1881a0106..2a624a42d 100644 --- a/src/corosio/src/udp_socket.cpp +++ b/src/corosio/src/udp_socket.cpp @@ -44,6 +44,24 @@ udp_socket::open_for_family(int family, int type, int protocol) detail::throw_system_error(ec, "udp_socket::open"); } +void +udp_socket::assign(native_handle_type fd) +{ + auto& svc = static_cast(h_.service()); + std::error_code ec = svc.assign_socket( + static_cast(*h_.get()), fd); + if (ec) + detail::throw_system_error(ec, "udp_socket::assign"); +} + +native_handle_type +udp_socket::release() +{ + if (!is_open()) + detail::throw_logic_error("release: socket not open"); + return get().release_socket(); +} + void udp_socket::close() { diff --git a/test/doc/snippets/4r_wait.cpp b/test/doc/snippets/4r_wait.cpp index 53bd450f9..5e50e91eb 100644 --- a/test/doc/snippets/4r_wait.cpp +++ b/test/doc/snippets/4r_wait.cpp @@ -50,9 +50,14 @@ using namespace std::chrono_literals; #include #include #include +#include #include #include +#if BOOST_COROSIO_POSIX +#include +#endif + #include #include @@ -81,6 +86,45 @@ wait_readable(corosio::tcp_socket& sock, std::error_code& ec_out) ec_out = ec; } +#if BOOST_COROSIO_POSIX +// Stand-ins for a C library that owns a nonblocking socket and does +// its own I/O on it (the libpq shape). +struct foreign_conn +{ +}; +int foreign_socket(foreign_conn*) { return -1; } +bool foreign_wants_read(foreign_conn*) { return false; } +int foreign_consume(foreign_conn*) { return 0; } +int foreign_flush(foreign_conn*) { return 0; } + +capy::task +drive_foreign(corosio::io_context& ioc, foreign_conn* conn) +{ + // tag::foreign_adopt[] + // Adopt a duplicate: assigned means owned, and corosio closing + // the duplicate can never close the library's descriptor. + // Readiness travels through the shared open file description. + corosio::tcp_socket sock(ioc); + sock.assign(::dup(foreign_socket(conn))); + + // Read side: wake, then let the library take the bytes itself. + while (foreign_wants_read(conn)) { + auto [ec] = co_await sock.wait(corosio::wait_type::read); + if (ec) co_return ec; + if (foreign_consume(conn) != 0) + co_return std::make_error_code(std::errc::io_error); + } + + // Write side: retry exactly when the socket can make progress. + while (foreign_flush(conn) == 1) { + auto [ec] = co_await sock.wait(corosio::wait_type::write); + if (ec) co_return ec; + } + // end::foreign_adopt[] + co_return std::error_code{}; +} +#endif + capy::task<> wait_then_accept( corosio::io_context& ioc, corosio::tcp_acceptor& acceptor, diff --git a/test/unit/local_datagram_socket.cpp b/test/unit/local_datagram_socket.cpp index adf32874f..380b10344 100644 --- a/test/unit/local_datagram_socket.cpp +++ b/test/unit/local_datagram_socket.cpp @@ -32,7 +32,10 @@ #include #include #include +#include +#include +#include #include #include "context.hpp" @@ -671,22 +674,102 @@ struct local_datagram_socket_test BOOST_TEST(s2.available() >= std::strlen(msg)); } - void testAssignAlreadyOpenThrows() + // Assign over an open socket cancels its pending operations and + // adopts, matching the internet family. + void testAssignOverOpenAdopts() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + local_datagram_socket d1(ioc), d2(ioc); + connect_pair(d1, d2); + + int fds[2]; + BOOST_TEST(::socketpair(AF_UNIX, SOCK_DGRAM, 0, fds) == 0); + int fl = ::fcntl(fds[0], F_GETFL); + BOOST_TEST(::fcntl(fds[0], F_SETFL, fl | O_NONBLOCK) == 0); + + bool recv_done = false; + std::error_code recv_ec; + auto reader = [&]() -> capy::task<> { + char buf[8]; + auto [ec, n] = co_await d1.recv( + capy::mutable_buffer(buf, sizeof(buf))); + (void)n; + recv_ec = ec; + recv_done = true; + }; + auto assigner = [&]() -> capy::task<> { + d1.assign(static_cast(fds[0])); + co_return; + }; + capy::run_async(ex)(reader()); + capy::run_async(ex)(assigner()); + ioc.run(); + ioc.restart(); + + BOOST_TEST(recv_done); + BOOST_TEST(recv_ec == capy::cond::canceled); + BOOST_TEST(d1.is_open()); + BOOST_TEST( + d1.native_handle() == static_cast(fds[0])); + + // The adopted descriptor reaches its new peer. + BOOST_TEST(::send(fds[1], "go", 2, 0) == 2); + bool got = false; + auto reread = [&]() -> capy::task<> { + char buf[8]; + auto [ec, n] = co_await d1.recv( + capy::mutable_buffer(buf, sizeof(buf))); + got = !ec && n == 2; + }; + capy::run_async(ex)(reread()); + ioc.run(); + BOOST_TEST(got); + + ::close(fds[1]); + } + + // Backend validation, not just the front-end guard: a bad fd must + // be rejected on every backend. + void testAssignBadFdThrows() { io_context ioc(Backend); local_datagram_socket sock(ioc); - sock.open(); + bool threw = false; + try + { + sock.assign((native_handle_type)-1); + } + catch (std::system_error const&) + { + threw = true; + } + BOOST_TEST(threw); + BOOST_TEST(!sock.is_open()); + } - bool caught = false; + // A rejected fd must remain owned and usable by the caller + // (validation happens before any state is touched). + void testAssignRejectedFdStaysOpen() + { + io_context ioc(Backend); + local_datagram_socket sock(ioc); + int fds[2]; + BOOST_TEST(::socketpair(AF_UNIX, SOCK_STREAM, 0, fds) == 0); + bool threw = false; try { - sock.assign(-1); + sock.assign((native_handle_type)fds[0]); } - catch (std::logic_error const&) + catch (std::system_error const&) { - caught = true; + threw = true; } - BOOST_TEST(caught); + BOOST_TEST(threw); + BOOST_TEST(::fcntl(fds[0], F_GETFD) >= 0); + BOOST_TEST(!sock.is_open()); + ::close(fds[0]); + ::close(fds[1]); } void testRelease() @@ -808,7 +891,9 @@ struct local_datagram_socket_test testReleaseClosedThrows(); testAvailableClosedThrows(); testAvailable(); - testAssignAlreadyOpenThrows(); + testAssignOverOpenAdopts(); + testAssignBadFdThrows(); + testAssignRejectedFdStaysOpen(); testRelease(); testSendOnClosedThrows(); testCancelPendingRecv(); diff --git a/test/unit/local_stream_socket.cpp b/test/unit/local_stream_socket.cpp index fb81f655f..5403fb50f 100644 --- a/test/unit/local_stream_socket.cpp +++ b/test/unit/local_stream_socket.cpp @@ -29,6 +29,7 @@ #include #if BOOST_COROSIO_POSIX +#include #include #include #include @@ -451,24 +452,190 @@ struct local_stream_socket_test BOOST_TEST_EQ(!ec2, true); } - void testAssignAlreadyOpenThrows() +#if BOOST_COROSIO_POSIX + // Assign over an open socket cancels its pending operations and + // adopts, matching the internet family. + void testAssignOverOpenAdopts() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + local_stream_socket s1(ioc), s2(ioc); + connect_pair(s1, s2); + + int fds[2]; + BOOST_TEST(::socketpair(AF_UNIX, SOCK_STREAM, 0, fds) == 0); + int fl = ::fcntl(fds[0], F_GETFL); + BOOST_TEST(::fcntl(fds[0], F_SETFL, fl | O_NONBLOCK) == 0); + + bool read_done = false; + std::error_code read_ec; + auto reader = [&]() -> capy::task<> { + char buf[4]; + auto [ec, n] = co_await s1.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + (void)n; + read_ec = ec; + read_done = true; + }; + auto assigner = [&]() -> capy::task<> { + s1.assign(static_cast(fds[0])); + co_return; + }; + capy::run_async(ex)(reader()); + capy::run_async(ex)(assigner()); + ioc.run(); + ioc.restart(); + + BOOST_TEST(read_done); + BOOST_TEST(read_ec == capy::cond::canceled); + BOOST_TEST(s1.is_open()); + BOOST_TEST( + s1.native_handle() == static_cast(fds[0])); + + // The adopted descriptor reaches its new peer. + BOOST_TEST(::send(fds[1], "go", 2, 0) == 2); + bool got = false; + auto reread = [&]() -> capy::task<> { + char buf[4]; + auto [ec, n] = co_await s1.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + got = !ec && n == 2; + }; + capy::run_async(ex)(reread()); + ioc.run(); + BOOST_TEST(got); + + ::close(fds[1]); + } + + // A failed assign over an open socket leaves it untouched and + // functional. + void testAssignOverOpenRejectedKeepsSocket() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + local_stream_socket s1(ioc), s2(ioc); + connect_pair(s1, s2); + + int fds[2]; + BOOST_TEST(::socketpair(AF_UNIX, SOCK_DGRAM, 0, fds) == 0); + + bool threw = false; + try + { + s1.assign(static_cast(fds[0])); + } + catch (std::system_error const&) + { + threw = true; + } + BOOST_TEST(threw); + BOOST_TEST(::fcntl(fds[0], F_GETFD) >= 0); // caller keeps it + BOOST_TEST(s1.is_open()); + + // The rejected assign disturbed nothing: the pair still moves + // bytes. + bool got = false; + auto writer = [&]() -> capy::task<> { + char const out[] = "ok"; + auto [wec, wn] = co_await s2.write_some( + capy::const_buffer(out, 2)); + (void)wn; + BOOST_TEST(!wec); + }; + auto reader = [&]() -> capy::task<> { + char buf[4]; + auto [ec, n] = co_await s1.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + got = !ec && n == 2; + }; + capy::run_async(ex)(reader()); + capy::run_async(ex)(writer()); + ioc.run(); + BOOST_TEST(got); + + ::close(fds[0]); + ::close(fds[1]); + } +#endif + +#if BOOST_COROSIO_POSIX + // Backend validation, not just the front-end guard: a bad fd must + // be rejected on every backend. + void testAssignBadFdThrows() { io_context ioc(Backend); local_stream_socket sock(ioc); - sock.open(); - BOOST_TEST_EQ(sock.is_open(), true); + bool threw = false; + try + { + sock.assign((native_handle_type)-1); + } + catch (std::system_error const&) + { + threw = true; + } + BOOST_TEST(threw); + BOOST_TEST(!sock.is_open()); + } - bool caught = false; + // A rejected fd must remain owned and usable by the caller + // (validation happens before any state is touched). + void testAssignRejectedFdStaysOpen() + { + io_context ioc(Backend); + local_stream_socket sock(ioc); + int fds[2]; + BOOST_TEST(::socketpair(AF_UNIX, SOCK_DGRAM, 0, fds) == 0); + bool threw = false; try { - sock.assign(static_cast(-1)); + sock.assign((native_handle_type)fds[0]); } - catch (std::logic_error const&) + catch (std::system_error const&) { - caught = true; + threw = true; } - BOOST_TEST(caught); + BOOST_TEST(threw); + // fd still valid: fcntl succeeds + BOOST_TEST(::fcntl(fds[0], F_GETFD) >= 0); + BOOST_TEST(!sock.is_open()); + ::close(fds[0]); + ::close(fds[1]); } +#endif + +#if BOOST_COROSIO_HAS_EPOLL + // Adopting an fd the reactor already tracks must fail with an + // error, not terminate. epoll reports EEXIST; kqueue and select + // accept re-registration, so only epoll is asserted. + void testAssignDuplicateFdErrors() + { + if constexpr (std::is_same_v< + std::remove_const_t, epoll_t>) + { + io_context ioc(Backend); + local_stream_socket a(ioc); + local_stream_socket b(ioc); + int fds[2]; + BOOST_TEST(::socketpair( + AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK, 0, fds) == 0); + a.assign((native_handle_type)fds[0]); + bool threw = false; + try + { + b.assign((native_handle_type)fds[0]); + } + catch (std::system_error const&) + { + threw = true; + } + BOOST_TEST(threw); + BOOST_TEST(a.is_open()); + ::close(fds[1]); + } + } +#endif void testReleaseClosedThrows() { @@ -819,18 +986,10 @@ struct local_stream_socket_test std::logic_error); } - // Acceptor wait(wait_type::write) completes immediately: a listener - // is always writable by convention. + // Acceptor wait(wait_type::write) fails uniformly on every + // backend: writability carries no meaning for a listener. void testAcceptorWaitWrite() { -#if BOOST_COROSIO_HAS_IO_URING - // The immediate-writable convention is a reactor/IOCP behavior; - // io_uring's poll never reports a listener writable, so the - // wait would park forever. - if constexpr (std::is_same_v< - std::remove_const_t, io_uring_t>) - return; -#endif io_context ioc(Backend); auto ex = ioc.get_executor(); test::temp_socket_dir tmp; @@ -855,7 +1014,7 @@ struct local_stream_socket_test ioc.run(); BOOST_TEST(wait_done); - BOOST_TEST(!wait_ec); + BOOST_TEST(wait_ec == std::errc::operation_not_supported); } // Acceptor wait(wait_type::read) parks until a cancel retracts it. @@ -1129,15 +1288,197 @@ struct local_stream_socket_test BOOST_TEST_EQ(acc.is_open(), true); auto h = acc.release(); - (void)h; BOOST_TEST_EQ(acc.is_open(), false); -#if BOOST_COROSIO_POSIX +#if BOOST_COROSIO_HAS_IOCP + ::closesocket(static_cast(h)); +#else if (static_cast(h) >= 0) ::close(static_cast(h)); #endif } + void testAcceptorNativeHandle() + { + io_context ioc(Backend); + local_stream_acceptor acc(ioc); + +#if BOOST_COROSIO_HAS_IOCP + auto const invalid = static_cast(~0ull); +#else + auto const invalid = static_cast(-1); +#endif + BOOST_TEST(acc.native_handle() == invalid); + + acc.open(); + BOOST_TEST(acc.native_handle() != invalid); + acc.close(); + BOOST_TEST(acc.native_handle() == invalid); + } + + // Drive one connect + accept + byte exchange through `acc`, which + // must already be listening on `path`. Runs `ioc` to completion; + // the connecting peer is spawned after the acceptor so the accept + // is parked before the connect lands. + bool acceptOneThroughLocal( + io_context& ioc, local_stream_acceptor& acc, std::string const& path) + { + local_stream_socket peer(ioc); + local_stream_socket client(ioc); + bool done = false; + + auto server = [&]() -> capy::task<> { + auto [aec] = co_await acc.accept(peer); + BOOST_TEST(!aec); + char in[8]; + auto [rec, rn] = + co_await peer.read_some(capy::mutable_buffer(in, sizeof(in))); + BOOST_TEST(!rec); + done = (rn == 4); + }; + auto sender = [&]() -> capy::task<> { + auto [cec] = co_await client.connect(local_endpoint(path)); + BOOST_TEST(!cec); + char const out[] = "ping"; + auto [wec, wn] = + co_await client.write_some(capy::const_buffer(out, 4)); + BOOST_TEST(!wec); + (void)wn; + }; + + auto ex = ioc.get_executor(); + capy::run_async(ex)(server()); + capy::run_async(ex)(sender()); + ioc.run(); + return done; + } + + // Adopting over a listening acceptor must retire the accept + // machinery for the descriptor being replaced, not leave it + // aliased onto the newly adopted one. + void testAcceptorAssignOverListening() + { + io_context ioc(Backend); + test::temp_socket_dir held_dir; + test::temp_socket_dir adopted_dir; + + local_stream_acceptor acc(ioc); + acc.open(); + auto ec = acc.bind(local_endpoint(held_dir.path())); + BOOST_TEST_EQ(!ec, true); + ec = acc.listen(); + BOOST_TEST_EQ(!ec, true); + + // Source the replacement descriptor from a second acceptor. + local_stream_acceptor donor(ioc); + donor.open(); + ec = donor.bind(local_endpoint(adopted_dir.path())); + BOOST_TEST_EQ(!ec, true); + ec = donor.listen(); + BOOST_TEST_EQ(!ec, true); + auto h = donor.release(); + + acc.assign(h); + BOOST_TEST_EQ(acc.is_open(), true); + BOOST_TEST(acc.native_handle() == h); + BOOST_TEST_EQ(acc.local_endpoint().path(), adopted_dir.path()); + + BOOST_TEST(acceptOneThroughLocal(ioc, acc, adopted_dir.path())); + } + + // release() then assign() on the SAME object: the released + // descriptor's accept machinery must be retired before the adopted + // one is armed. + void testAcceptorAssignAfterRelease() + { + io_context ioc(Backend); + test::temp_socket_dir first_dir; + test::temp_socket_dir second_dir; + + local_stream_acceptor acc(ioc); + acc.open(); + auto ec = acc.bind(local_endpoint(first_dir.path())); + BOOST_TEST_EQ(!ec, true); + ec = acc.listen(); + BOOST_TEST_EQ(!ec, true); + + auto released = acc.release(); + BOOST_TEST_EQ(acc.is_open(), false); +#if BOOST_COROSIO_HAS_IOCP + ::closesocket(static_cast(released)); +#else + if (static_cast(released) >= 0) + ::close(static_cast(released)); +#endif + + local_stream_acceptor donor(ioc); + donor.open(); + ec = donor.bind(local_endpoint(second_dir.path())); + BOOST_TEST_EQ(!ec, true); + ec = donor.listen(); + BOOST_TEST_EQ(!ec, true); + + acc.assign(donor.release()); + BOOST_TEST_EQ(acc.is_open(), true); + BOOST_TEST_EQ(acc.local_endpoint().path(), second_dir.path()); + + BOOST_TEST(acceptOneThroughLocal(ioc, acc, second_dir.path())); + } + + // A released listener round-trips into a fresh acceptor and keeps + // serving connections. + void testAcceptorAssignFromRelease() + { + io_context ioc(Backend); + test::temp_socket_dir tmp; + auto path = tmp.path(); + + local_stream_acceptor first(ioc); + first.open(); + auto ec = first.bind(local_endpoint(path)); + BOOST_TEST_EQ(!ec, true); + ec = first.listen(); + BOOST_TEST_EQ(!ec, true); + + auto h = first.release(); + BOOST_TEST_EQ(first.is_open(), false); + + local_stream_acceptor acc(ioc); + acc.assign(h); + BOOST_TEST_EQ(acc.is_open(), true); + BOOST_TEST(acc.native_handle() == h); + BOOST_TEST_EQ(acc.local_endpoint().path(), path); + + local_stream_socket peer(ioc); + local_stream_socket client(ioc); + auto ex = ioc.get_executor(); + bool done = false; + + auto server = [&]() -> capy::task<> { + auto [aec] = co_await acc.accept(peer); + BOOST_TEST(!aec); + char in[8]; + auto [rec, rn] = + co_await peer.read_some(capy::mutable_buffer(in, sizeof(in))); + BOOST_TEST(!rec); + done = (rn == 4); + }; + auto sender = [&]() -> capy::task<> { + auto [cec] = co_await client.connect(local_endpoint(path)); + BOOST_TEST(!cec); + char const out[] = "ping"; + auto [wec, wn] = + co_await client.write_some(capy::const_buffer(out, 4)); + BOOST_TEST(!wec); + (void)wn; + }; + + capy::run_async(ex)(server()); + capy::run_async(ex)(sender()); + ioc.run(); + BOOST_TEST(done); + } + void testAcceptorLocalEndpoint() { io_context ioc(Backend); @@ -1235,7 +1576,15 @@ struct local_stream_socket_test testSocketPair(); testEndpointsConnected(); testShutdown(); - testAssignAlreadyOpenThrows(); +#if BOOST_COROSIO_POSIX + testAssignOverOpenAdopts(); + testAssignOverOpenRejectedKeepsSocket(); + testAssignBadFdThrows(); + testAssignRejectedFdStaysOpen(); +#endif +#if BOOST_COROSIO_HAS_EPOLL + testAssignDuplicateFdErrors(); +#endif testReleaseClosedThrows(); testAvailableClosedThrows(); testConnectToNonexistent(); @@ -1264,6 +1613,10 @@ struct local_stream_socket_test testAcceptorAcceptClosedThrows(); testAcceptorReleaseClosedThrows(); testAcceptorReleaseOpen(); + testAcceptorNativeHandle(); + testAcceptorAssignFromRelease(); + testAcceptorAssignOverListening(); + testAcceptorAssignAfterRelease(); testAcceptorLocalEndpoint(); testEndpointTooLongThrows(); testEndpointTooLongNoThrow(); @@ -1278,6 +1631,7 @@ struct local_stream_socket_test testEndpointStreamOutput(); testAvailable(); testRelease(); + testReleaseCancelsPendingRead(); } void testAvailable() @@ -1337,6 +1691,57 @@ struct local_stream_socket_test #endif } + // release() hands ownership to the caller only after pending + // operations have been cancelled, so no completion can resolve + // against the descriptor number once the caller recycles it. + void testReleaseCancelsPendingRead() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + local_stream_socket s1(ioc), s2(ioc); + if (auto ec = connect_pair(s1, s2)) + throw std::system_error(ec, "connect_pair"); + + std::error_code read_ec; + bool read_done = false; + char buf[16]; +#if BOOST_COROSIO_HAS_IOCP + auto released = static_cast(~0ull); +#else + auto released = static_cast(-1); +#endif + + auto reader = [&]() -> capy::task<> { + auto [ec, n] = co_await s1.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + (void)n; + read_ec = ec; + read_done = true; + }; + auto releaser = [&]() -> capy::task<> { + released = s1.release(); + co_return; + }; + + // run_async runs inline to the first suspend, so spawning the + // reader first is what guarantees the read is parked when + // release() runs. + capy::run_async(ex)(reader()); + capy::run_async(ex)(releaser()); + ioc.run(); + + BOOST_TEST(read_done); + BOOST_TEST(read_ec == capy::cond::canceled); + BOOST_TEST_EQ(s1.is_open(), false); + +#if BOOST_COROSIO_HAS_IOCP + ::closesocket(static_cast(released)); +#else + BOOST_TEST_EQ(released >= 0, true); + ::close(released); +#endif + } + void testEndpointStreamOutput() { // Non-abstract path diff --git a/test/unit/native/native_local_stream_socket.cpp b/test/unit/native/native_local_stream_socket.cpp index 334b1cb0b..7a38a27b2 100644 --- a/test/unit/native/native_local_stream_socket.cpp +++ b/test/unit/native/native_local_stream_socket.cpp @@ -248,8 +248,8 @@ struct native_local_stream_socket_test } // Exercise the shadowed wait() awaitable on the socket: a - // connected stream is always writable, so wait_type::write - // resolves immediately on every backend. + // connected stream with an empty send buffer probes writable, so + // wait_type::write resolves without parking on every backend. void testSocketWait() { io_context ioc(Backend); diff --git a/test/unit/native/native_tcp_socket.cpp b/test/unit/native/native_tcp_socket.cpp index 446e1d142..9808981d1 100644 --- a/test/unit/native/native_tcp_socket.cpp +++ b/test/unit/native/native_tcp_socket.cpp @@ -100,9 +100,9 @@ struct native_tcp_socket_test BOOST_TEST_PASS(); } - // Exercise the shadowed wait() awaitable. On a connected socket - // wait_type::write resolves immediately on every backend (IOCP - // matches asio's "writable is always ready" semantics). + // Exercise the shadowed wait() awaitable. A connected socket with + // an empty send buffer probes writable, so wait_type::write + // resolves without parking on every backend. void testWait() { io_context ioc(Backend); diff --git a/test/unit/reactor_paths.cpp b/test/unit/reactor_paths.cpp index 063b415d1..ff2afa20a 100644 --- a/test/unit/reactor_paths.cpp +++ b/test/unit/reactor_paths.cpp @@ -46,6 +46,7 @@ #include #include +#include #include #include #include @@ -414,8 +415,9 @@ struct reactor_paths_test BOOST_TEST_EQ(total_read, part1.size() + part2.size() + part3.size()); } - // Acceptor wait_type::write completes immediately. Exercises the early - // return path in reactor_acceptor::do_wait. + // Acceptor wait_type::write fails uniformly: writability carries + // no meaning for a listener. Exercises the early return path in + // reactor_acceptor::do_wait. void testAcceptorWaitWrite() { io_context ioc(Backend); @@ -442,7 +444,7 @@ struct reactor_paths_test ioc.run(); BOOST_TEST(wait_done); - BOOST_TEST(!wait_ec); + BOOST_TEST(wait_ec == std::errc::operation_not_supported); } // Cancel a parked acceptor wait_type::error. Exercises the @@ -481,7 +483,8 @@ struct reactor_paths_test BOOST_TEST(wait_ec == capy::cond::canceled); } - // UDP wait_type::write completes immediately. + // An unbackpressured UDP socket probes writable, so wait_type::write + // completes without parking. void testUdpWaitWrite() { io_context ioc(Backend); @@ -1164,12 +1167,43 @@ struct reactor_paths_test bool false_success = false; if (!conn_ec) { +#if defined(TCP_INFO) + // The bug under test is uniquely a success reported while + // the handshake is still in flight, so ask the kernel for + // the transport state directly. Post-hoc witnesses are + // unreliable here: a connection the overflowing listener + // established and then reset loses its peer, and the + // reactor's own error dispatch may have consumed the + // recorded reset from SO_ERROR already. + tcp_info ti{}; + socklen_t tlen = sizeof(ti); + if (::getsockopt( + sock.native_handle(), IPPROTO_TCP, TCP_INFO, &ti, + &tlen) == 0) + { + // TCP_SYN_SENT / TCPS_SYN_SENT on every target; the + // enum and macro spellings differ, the value does not. + false_success = ti.tcpi_state == 2; + } +#else + // No transport-state query on this platform: a success + // with no peer and nothing recorded is the bug's shape, + // while an established-then-reset connection records the + // reset. sockaddr_storage peer{}; socklen_t plen = sizeof(peer); - false_success = - ::getpeername( + if (::getpeername( sock.native_handle(), - reinterpret_cast(&peer), &plen) != 0; + reinterpret_cast(&peer), &plen) != 0) + { + int soerr = 0; + socklen_t sslen = sizeof(soerr); + (void)::getsockopt( + sock.native_handle(), SOL_SOCKET, SO_ERROR, &soerr, + &sslen); + false_success = soerr == 0; + } +#endif } BOOST_TEST(cancel_sent); BOOST_TEST(conn_done); @@ -1391,7 +1425,8 @@ struct reactor_paths_test BOOST_TEST(wait_ec == capy::cond::canceled); } - // Local stream socket wait_type::write completes immediately. + // A connected local stream probes writable, so wait_type::write + // completes without parking. void testLocalStreamWaitWrite() { io_context ioc(Backend); @@ -1531,7 +1566,8 @@ struct reactor_paths_test BOOST_TEST(wait_ec == capy::cond::canceled); } - // Local datagram wait_type::write immediate completion. + // An unbackpressured local datagram socket probes writable, so + // wait_type::write completes without parking. void testLocalDgramWaitWrite() { io_context ioc(Backend); diff --git a/test/unit/tcp_acceptor.cpp b/test/unit/tcp_acceptor.cpp index b0ad18671..cd8675760 100644 --- a/test/unit/tcp_acceptor.cpp +++ b/test/unit/tcp_acceptor.cpp @@ -22,26 +22,144 @@ #include #include +#include #include #include +#include #ifndef _WIN32 // For the SO_REUSEPORT guard around testReusePort. The corosio public // option header is platform-agnostic and does not expose this macro. // netinet/in.h and unistd.h support the raw-socket backlog setup in -// testAcceptPendingConnection. +// testAcceptPendingConnection; fcntl.h supports the adoption tests. +#include #include #include #include #else // Raw-socket backlog setup in testAcceptPendingConnection. #include +#include // sockaddr_in6, in6addr_loopback #endif #include "context.hpp" #include "test_suite.hpp" +#include "test_utils.hpp" namespace boost::corosio { +namespace { + +using test::close_native_socket; +using test::invalid_native_socket; +using test::make_native_adoptable; +using test::make_native_socket; +using test::native_socket_valid; + +// Fill a sockaddr_storage with a loopback address for `port`. +std::size_t +fill_loopback(sockaddr_storage& storage, std::uint16_t port, bool v6) +{ + storage = sockaddr_storage{}; + if (v6) + { + auto* sa6 = reinterpret_cast(&storage); + sa6->sin6_family = AF_INET6; + sa6->sin6_port = htons(port); + sa6->sin6_addr = in6addr_loopback; + return sizeof(sockaddr_in6); + } + auto* sa4 = reinterpret_cast(&storage); + sa4->sin_family = AF_INET; + sa4->sin_port = htons(port); + sa4->sin_addr.s_addr = htonl(INADDR_LOOPBACK); + return sizeof(sockaddr_in); +} + +// Build the listening descriptor a socket-activation supervisor would +// hand over: bound, listening, and already in the mode the backend +// needs. Returns invalid_native_socket when the family is unusable. +native_handle_type +make_native_listener(bool v6, std::uint16_t& port) +{ + auto h = make_native_socket(v6 ? AF_INET6 : AF_INET, SOCK_STREAM); + if (h == invalid_native_socket) + return h; + + sockaddr_storage storage{}; + auto len = fill_loopback(storage, 0, v6); +#if BOOST_COROSIO_HAS_IOCP + SOCKET s = static_cast(h); + if (::bind(s, reinterpret_cast(&storage), + static_cast(len)) != 0 || + ::listen(s, 4) != 0) + { + close_native_socket(h); + return invalid_native_socket; + } + int name_len = static_cast(sizeof(storage)); +#else + int s = static_cast(h); + if (::bind(s, reinterpret_cast(&storage), + static_cast(len)) != 0 || + ::listen(s, 4) != 0) + { + close_native_socket(h); + return invalid_native_socket; + } + socklen_t name_len = sizeof(storage); +#endif + storage = sockaddr_storage{}; + if (::getsockname( + s, reinterpret_cast(&storage), &name_len) != 0) + { + close_native_socket(h); + return invalid_native_socket; + } + port = v6 + ? ntohs(reinterpret_cast(&storage)->sin6_port) + : ntohs(reinterpret_cast(&storage)->sin_port); + + make_native_adoptable(h); + return h; +} + +// Blocking connect: on loopback the handshake completes against the +// listen backlog without the io_context running. +bool +native_connect_loopback(native_handle_type h, std::uint16_t port, bool v6) +{ + sockaddr_storage storage{}; + auto len = fill_loopback(storage, port, v6); +#if BOOST_COROSIO_HAS_IOCP + return ::connect( + static_cast(h), + reinterpret_cast(&storage), + static_cast(len)) == 0; +#else + return ::connect( + static_cast(h), + reinterpret_cast(&storage), + static_cast(len)) == 0; +#endif +} + +// Take ownership of a released listener the way a caller would: clear +// the non-blocking flag the library set, then accept. +native_handle_type +native_accept_blocking(native_handle_type h) +{ +#if BOOST_COROSIO_HAS_IOCP + return static_cast( + ::accept(static_cast(h), nullptr, nullptr)); +#else + int fd = static_cast(h); + int flags = ::fcntl(fd, F_GETFL); + ::fcntl(fd, F_SETFL, flags & ~O_NONBLOCK); + return static_cast(::accept(fd, nullptr, nullptr)); +#endif +} + +} // namespace // Acceptor-specific tests // Focus: acceptor construction, basic interface, and cancellation @@ -967,6 +1085,601 @@ struct tcp_acceptor_test BOOST_TEST_PASS(); } + void testNativeHandle() + { + io_context ioc(Backend); + tcp_acceptor acc(ioc); + + // Closed: returns the platform sentinel. + BOOST_TEST(acc.native_handle() == invalid_native_socket); + + acc.open(); + BOOST_TEST(acc.native_handle() != invalid_native_socket); + acc.close(); + BOOST_TEST(acc.native_handle() == invalid_native_socket); + } + + // Drive one connect + accept + byte exchange through `acc`, which + // must already be listening on the loopback `port`. Runs `ioc` to + // completion; the connecting peer is spawned after the acceptor so + // the accept is parked before the connect lands. + bool acceptOneThrough( + io_context& ioc, tcp_acceptor& acc, std::uint16_t port, bool v6) + { + tcp_socket client(ioc); + bool done = false; + + auto server = [&]() -> capy::task<> { + auto [aec, peer] = co_await acc.accept(); + BOOST_TEST(!aec); + char in[8]; + auto [rec, rn] = + co_await peer.read_some(capy::mutable_buffer(in, sizeof(in))); + BOOST_TEST(!rec); + done = (rn == 4); + }; + auto sender = [&]() -> capy::task<> { + auto [cec] = co_await client.connect( + v6 ? endpoint(ipv6_address::loopback(), port) + : endpoint(ipv4_address::loopback(), port)); + BOOST_TEST(!cec); + char const out[] = "ping"; + auto [wec, wn] = + co_await client.write_some(capy::const_buffer(out, 4)); + BOOST_TEST(!wec); + (void)wn; + }; + + auto ex = ioc.get_executor(); + capy::run_async(ex)(server()); + capy::run_async(ex)(sender()); + ioc.run(); + return done; + } + + // Socket activation: adopt a natively created listening descriptor + // and accept a corosio connection through it. + void testAssignListeningSocket() + { + io_context ioc(Backend); + + std::uint16_t port = 0; + auto lfd = make_native_listener(false, port); + BOOST_TEST(lfd != invalid_native_socket); + BOOST_TEST(port != 0); + + tcp_acceptor acc(ioc); + acc.assign(lfd); + BOOST_TEST(acc.is_open()); + BOOST_TEST(acc.native_handle() == lfd); + BOOST_TEST_EQ(acc.local_endpoint().port(), port); + + BOOST_TEST(acceptOneThrough(ioc, acc, port, false)); + } + + // A wait for readability must observe a connection that was + // already queued when the wait began: a shared or adopted + // listener has history the reactor never saw. + void testWaitReadPreexistingBacklog() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + tcp_acceptor acc(ioc); + acc.open(); + acc.set_option(socket_option::reuse_address(true)); + auto ec = acc.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + ec = acc.listen(); + BOOST_TEST(!ec); + auto port = acc.local_endpoint().port(); + + // Queue a connection before any wait exists, then pump once + // with nothing parked so an edge-triggered reactor has + // already dispatched — and dropped — the readiness edge. + auto client = make_native_socket(AF_INET, SOCK_STREAM); + BOOST_TEST(client != invalid_native_socket); + BOOST_TEST(native_connect_loopback(client, port, false)); + (void)ioc.poll(); + ioc.restart(); + + std::error_code wait_ec; + bool wait_done = false; + bool watchdog_fired = false; + + auto waiter = [&]() -> capy::task<> { + auto [wec] = co_await acc.wait(wait_type::read); + wait_ec = wec; + wait_done = true; + }; + // Watchdog: a reactor that misses pre-existing readiness + // parks forever; retract the wait so the miss is reported + // instead of hanging the suite. + auto watchdog = [&]() -> capy::task<> { + (void)co_await corosio::delay(std::chrono::milliseconds(250)); + if (!wait_done) + { + watchdog_fired = true; + acc.cancel(); + } + }; + capy::run_async(ex)(waiter()); + capy::run_async(ex)(watchdog()); + ioc.run(); + ioc.restart(); + + BOOST_TEST(wait_done); + BOOST_TEST(!watchdog_fired); + BOOST_TEST(!wait_ec); + + // The signalled connection is genuinely acceptable. + bool accepted = false; + auto server = [&]() -> capy::task<> { + auto [aec, peer] = co_await acc.accept(); + BOOST_TEST(!aec); + accepted = !aec; + }; + capy::run_async(ex)(server()); + ioc.run(); + BOOST_TEST(accepted); + + close_native_socket(client); + } + + // The socket-activation shape of the same guarantee: the queued + // connection predates the adoption itself. + void testWaitReadAdoptedBacklog() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + std::uint16_t port = 0; + auto lfd = make_native_listener(false, port); + BOOST_TEST(lfd != invalid_native_socket); + + auto client = make_native_socket(AF_INET, SOCK_STREAM); + BOOST_TEST(client != invalid_native_socket); + BOOST_TEST(native_connect_loopback(client, port, false)); + + tcp_acceptor acc(ioc); + acc.assign(lfd); + BOOST_TEST(acc.is_open()); + + // Pump once with nothing parked so the registration-time + // readiness edge has already been dispatched and dropped. + (void)ioc.poll(); + ioc.restart(); + + std::error_code wait_ec; + bool wait_done = false; + bool watchdog_fired = false; + + auto waiter = [&]() -> capy::task<> { + auto [wec] = co_await acc.wait(wait_type::read); + wait_ec = wec; + wait_done = true; + }; + auto watchdog = [&]() -> capy::task<> { + (void)co_await corosio::delay(std::chrono::milliseconds(250)); + if (!wait_done) + { + watchdog_fired = true; + acc.cancel(); + } + }; + capy::run_async(ex)(waiter()); + capy::run_async(ex)(watchdog()); + ioc.run(); + ioc.restart(); + + BOOST_TEST(wait_done); + BOOST_TEST(!watchdog_fired); + BOOST_TEST(!wait_ec); + + bool accepted = false; + auto server = [&]() -> capy::task<> { + auto [aec, peer] = co_await acc.accept(); + BOOST_TEST(!aec); + accepted = !aec; + }; + capy::run_async(ex)(server()); + ioc.run(); + BOOST_TEST(accepted); + + close_native_socket(client); + } + + // Writability carries no meaning for a listener; the wait must + // fail the same way on every backend instead of completing + // immediately on some and never on others. + void testWaitWriteUnsupported() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + tcp_acceptor acc(ioc); + acc.open(); + acc.set_option(socket_option::reuse_address(true)); + auto ec = acc.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + ec = acc.listen(); + BOOST_TEST(!ec); + + std::error_code wait_ec; + bool wait_done = false; + bool watchdog_fired = false; + + auto waiter = [&]() -> capy::task<> { + auto [wec] = co_await acc.wait(wait_type::write); + wait_ec = wec; + wait_done = true; + }; + // Watchdog: a backend that parks the meaningless wait would + // hang the suite; retract it so the miss is reported. + auto watchdog = [&]() -> capy::task<> { + (void)co_await corosio::delay(std::chrono::milliseconds(250)); + if (!wait_done) + { + watchdog_fired = true; + acc.cancel(); + } + }; + capy::run_async(ex)(waiter()); + capy::run_async(ex)(watchdog()); + ioc.run(); + + BOOST_TEST(wait_done); + BOOST_TEST(!watchdog_fired); + BOOST_TEST(wait_ec == std::errc::operation_not_supported); + } + + // Adopting over an acceptor that is already listening must retire + // the in-flight accept machinery for the descriptor being replaced, + // not leave it aliased onto the newly adopted one. + void testAssignOverListeningAcceptor() + { + io_context ioc(Backend); + + tcp_acceptor acc(ioc); + acc.open(); + acc.set_option(socket_option::reuse_address(true)); + auto ec = acc.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + ec = acc.listen(); + BOOST_TEST(!ec); + auto old_port = acc.local_endpoint().port(); + + // Pump once so the listen-time accept arming is live in the + // kernel before the descriptor is swapped underneath it. + (void)ioc.poll(); + ioc.restart(); + + std::uint16_t port = 0; + auto lfd = make_native_listener(false, port); + BOOST_TEST(lfd != invalid_native_socket); + BOOST_TEST(port != old_port); + + acc.assign(lfd); + BOOST_TEST(acc.is_open()); + BOOST_TEST(acc.native_handle() == lfd); + BOOST_TEST_EQ(acc.local_endpoint().port(), port); + + BOOST_TEST(acceptOneThrough(ioc, acc, port, false)); + } + + // release() then assign() on the SAME object: the released + // descriptor's accept machinery must be retired before the adopted + // one is armed. + void testAssignAfterRelease() + { + io_context ioc(Backend); + + tcp_acceptor acc(ioc); + acc.open(); + acc.set_option(socket_option::reuse_address(true)); + auto ec = acc.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + ec = acc.listen(); + BOOST_TEST(!ec); + + auto released = acc.release(); + BOOST_TEST(!acc.is_open()); + BOOST_TEST(released != invalid_native_socket); + close_native_socket(released); + + std::uint16_t port = 0; + auto lfd = make_native_listener(false, port); + BOOST_TEST(lfd != invalid_native_socket); + + acc.assign(lfd); + BOOST_TEST(acc.is_open()); + BOOST_TEST(acc.native_handle() == lfd); + BOOST_TEST_EQ(acc.local_endpoint().port(), port); + + BOOST_TEST(acceptOneThrough(ioc, acc, port, false)); + } + + // release() then open()/bind()/listen() on the SAME object: the + // shutdown state release() leaves behind must not follow the + // acceptor onto its next descriptor, or every connection the + // kernel hands back is dropped on arrival. + void testListenAfterRelease() + { + io_context ioc(Backend); + + tcp_acceptor acc(ioc); + acc.open(); + acc.set_option(socket_option::reuse_address(true)); + auto ec = acc.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + ec = acc.listen(); + BOOST_TEST(!ec); + + // Pump once so the listen-time accept arming is live in the + // kernel before release() retires it. + (void)ioc.poll(); + ioc.restart(); + + auto released = acc.release(); + BOOST_TEST(!acc.is_open()); + BOOST_TEST(released != invalid_native_socket); + close_native_socket(released); + + acc.open(); + acc.set_option(socket_option::reuse_address(true)); + ec = acc.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + ec = acc.listen(); + BOOST_TEST(!ec); + BOOST_TEST(acc.is_open()); + auto port = acc.local_endpoint().port(); + BOOST_TEST(port != 0); + + BOOST_TEST(acceptOneThrough(ioc, acc, port, false)); + } + + // A second listen() on a live acceptor (backlog change) must not + // fork the accept machinery: connections arriving afterwards + // belong to the caller, not to a retired arming. + void testListenTwiceKeepsAccepting() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + tcp_acceptor acc(ioc); + acc.open(); + acc.set_option(socket_option::reuse_address(true)); + auto ec = acc.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + ec = acc.listen(); + BOOST_TEST(!ec); + auto port = acc.local_endpoint().port(); + + // Pump once so the first arming is live before the re-listen. + (void)ioc.poll(); + ioc.restart(); + + ec = acc.listen(256); + BOOST_TEST(!ec); + + auto client = make_native_socket(AF_INET, SOCK_STREAM); + BOOST_TEST(client != invalid_native_socket); + BOOST_TEST(native_connect_loopback(client, port, false)); + + std::error_code accept_ec; + bool accept_done = false; + bool watchdog_fired = false; + + auto server = [&]() -> capy::task<> { + auto [aec, peer] = co_await acc.accept(); + accept_ec = aec; + accept_done = true; + }; + // Watchdog: a retired arming stealing the connection parks the + // accept forever; retract it so the theft is reported instead + // of hanging the suite. + auto watchdog = [&]() -> capy::task<> { + (void)co_await corosio::delay(std::chrono::milliseconds(250)); + if (!accept_done) + { + watchdog_fired = true; + acc.cancel(); + } + }; + capy::run_async(ex)(server()); + capy::run_async(ex)(watchdog()); + ioc.run(); + + BOOST_TEST(accept_done); + BOOST_TEST(!watchdog_fired); + BOOST_TEST(!accept_ec); + + close_native_socket(client); + } + + // Connections a released listener had already delivered internally + // must not surface from a later listener on the same object. + void testReleaseDropsPreacceptedConnections() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + tcp_acceptor acc(ioc); + acc.open(); + acc.set_option(socket_option::reuse_address(true)); + auto ec = acc.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + ec = acc.listen(); + BOOST_TEST(!ec); + auto port_a = acc.local_endpoint().port(); + + // Queue a connection and pump so backends that accept ahead of + // the user have taken it internally. + auto stale = make_native_socket(AF_INET, SOCK_STREAM); + BOOST_TEST(stale != invalid_native_socket); + BOOST_TEST(native_connect_loopback(stale, port_a, false)); + (void)ioc.poll(); + ioc.restart(); + + auto released = acc.release(); + BOOST_TEST(released != invalid_native_socket); + close_native_socket(released); + close_native_socket(stale); + + acc.open(); + acc.set_option(socket_option::reuse_address(true)); + ec = acc.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + ec = acc.listen(); + BOOST_TEST(!ec); + auto port_b = acc.local_endpoint().port(); + BOOST_TEST(port_b != port_a); + + auto client = make_native_socket(AF_INET, SOCK_STREAM); + BOOST_TEST(client != invalid_native_socket); + BOOST_TEST(native_connect_loopback(client, port_b, false)); + + std::error_code accept_ec; + std::uint16_t accepted_port = 0; + bool accept_done = false; + bool watchdog_fired = false; + + auto server = [&]() -> capy::task<> { + auto [aec, peer] = co_await acc.accept(); + accept_ec = aec; + accept_done = true; + if (!aec) + accepted_port = peer.local_endpoint().port(); + }; + auto watchdog = [&]() -> capy::task<> { + (void)co_await corosio::delay(std::chrono::milliseconds(250)); + if (!accept_done) + { + watchdog_fired = true; + acc.cancel(); + } + }; + capy::run_async(ex)(server()); + capy::run_async(ex)(watchdog()); + ioc.run(); + + BOOST_TEST(accept_done); + BOOST_TEST(!watchdog_fired); + BOOST_TEST(!accept_ec); + // The accepted connection belongs to the new listener. + BOOST_TEST_EQ(accepted_port, port_b); + + close_native_socket(client); + } + + // Rejection matrix: bad handle, wrong type, wrong family, self. + void testAssignRejections() + { + io_context ioc(Backend); + tcp_acceptor acc(ioc); + + auto expect_throw = [&](native_handle_type h) { + bool threw = false; + try + { + acc.assign(h); + } + catch (std::system_error const&) + { + threw = true; + } + BOOST_TEST(threw); + }; + + expect_throw(invalid_native_socket); + BOOST_TEST(!acc.is_open()); + + auto dg = make_native_socket(AF_INET, SOCK_DGRAM); + BOOST_TEST(dg != invalid_native_socket); + expect_throw(dg); + BOOST_TEST(native_socket_valid(dg)); // caller keeps it + close_native_socket(dg); + +#if BOOST_COROSIO_POSIX + auto un = make_native_socket(AF_UNIX, SOCK_STREAM); + BOOST_TEST(un != invalid_native_socket); + expect_throw(un); + BOOST_TEST(native_socket_valid(un)); + close_native_socket(un); +#endif + + acc.open(); + expect_throw(acc.native_handle()); + BOOST_TEST(acc.is_open()); + acc.close(); + } + + // release() hands the listening descriptor back; it still accepts. + void testRelease() + { + io_context ioc(Backend); + tcp_acceptor acc(ioc); + acc.open(); + acc.set_option(socket_option::reuse_address(true)); + auto ec = acc.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + ec = acc.listen(); + BOOST_TEST(!ec); + auto port = acc.local_endpoint().port(); + + auto released = acc.release(); + BOOST_TEST(!acc.is_open()); + BOOST_TEST(released != invalid_native_socket); + + auto client = make_native_socket(AF_INET, SOCK_STREAM); + BOOST_TEST(client != invalid_native_socket); + BOOST_TEST(native_connect_loopback(client, port, false)); + + auto peer = native_accept_blocking(released); + BOOST_TEST(peer != invalid_native_socket); + + close_native_socket(peer); + close_native_socket(client); + close_native_socket(released); + } + + void testReleaseClosedThrows() + { + io_context ioc(Backend); + tcp_acceptor acc(ioc); + + bool caught = false; + try + { + (void)acc.release(); + } + catch (std::logic_error const&) + { + caught = true; + } + BOOST_TEST(caught); + } + + // Adopting a v6 listener must seed the endpoint cache as v6: the + // IOCP accept path sizes its address buffers from that cache. + void testAssignV6Listener() + { + io_context ioc(Backend); + + std::uint16_t port = 0; + auto lfd = make_native_listener(true, port); + if (lfd == invalid_native_socket) + return; // no IPv6 loopback on this host + + tcp_acceptor acc(ioc); + acc.assign(lfd); + BOOST_TEST(acc.is_open()); + BOOST_TEST(acc.local_endpoint().is_v6()); + BOOST_TEST_EQ(acc.local_endpoint().port(), port); + + BOOST_TEST(acceptOneThrough(ioc, acc, port, true)); + } + void run() { testConstruction(); @@ -1017,6 +1730,22 @@ struct tcp_acceptor_test testStopTokenAccept(); testAcceptPendingConnection(); testAcceptWithoutListen(); + + // Descriptor adoption + testNativeHandle(); + testAssignListeningSocket(); + testWaitReadPreexistingBacklog(); + testWaitReadAdoptedBacklog(); + testWaitWriteUnsupported(); + testAssignOverListeningAcceptor(); + testAssignAfterRelease(); + testListenAfterRelease(); + testListenTwiceKeepsAccepting(); + testReleaseDropsPreacceptedConnections(); + testAssignRejections(); + testRelease(); + testReleaseClosedThrows(); + testAssignV6Listener(); } }; diff --git a/test/unit/tcp_socket.cpp b/test/unit/tcp_socket.cpp index 63cb36f52..1f87d3cb8 100644 --- a/test/unit/tcp_socket.cpp +++ b/test/unit/tcp_socket.cpp @@ -33,19 +33,83 @@ #include #include #include +#include #if BOOST_COROSIO_POSIX #include // getpid() +// Raw socket creation for the assign()/release() adoption tests. +#include +#include +#include #else #include // _getpid() +#include +#include // sockaddr_in6, in6addr_loopback #endif #include "context.hpp" #include "test_suite.hpp" +#include "test_utils.hpp" namespace boost::corosio { namespace { +using test::close_native_socket; +using test::invalid_native_socket; +using test::make_native_adoptable; +using test::make_native_socket; +using test::native_socket_valid; + +// Blocking connect: on loopback the handshake completes against the +// listen backlog without the io_context running. +bool +native_connect_loopback(native_handle_type h, std::uint16_t port, bool v6) +{ + sockaddr_storage storage{}; + std::size_t len = 0; + if (v6) + { + auto* sa6 = reinterpret_cast(&storage); + sa6->sin6_family = AF_INET6; + sa6->sin6_port = htons(port); + sa6->sin6_addr = in6addr_loopback; + len = sizeof(sockaddr_in6); + } + else + { + auto* sa4 = reinterpret_cast(&storage); + sa4->sin_family = AF_INET; + sa4->sin_port = htons(port); + sa4->sin_addr.s_addr = htonl(INADDR_LOOPBACK); + len = sizeof(sockaddr_in); + } +#if BOOST_COROSIO_HAS_IOCP + return ::connect( + static_cast(h), + reinterpret_cast(&storage), + static_cast(len)) == 0; +#else + return ::connect( + static_cast(h), + reinterpret_cast(&storage), + static_cast(len)) == 0; +#endif +} + +// Send through a descriptor the library no longer owns. +bool +native_send(native_handle_type h, char const* data, std::size_t len) +{ +#if BOOST_COROSIO_HAS_IOCP + return ::send( + static_cast(h), data, + static_cast(len), 0) == static_cast(len); +#else + return ::send(static_cast(h), data, len, 0) == + static_cast(len); +#endif +} + } // namespace // Verify tcp_socket satisfies stream concepts @@ -1699,6 +1763,15 @@ struct tcp_socket_test // v6_only socket option testV6OnlySocketOption(); testDualStackConnect(); + + // Adoption and release + testAssignConnectedSocket(); + testAssignRejections(); + testAssignFailureKeepsSocket(); + testAssignOverOpenCancelsPending(); + testRelease(); + testReleaseClosedThrows(); + testAssignV6(); } void testConnectV6() @@ -2035,6 +2108,352 @@ struct tcp_socket_test s2.close(); acc.close(); } + + // Adopt a natively created connected socket; both directions work. + void testAssignConnectedSocket() + { + io_context ioc(Backend); + + tcp_acceptor acc(ioc); + acc.open(); + acc.set_option(socket_option::reuse_address(true)); + auto ec = acc.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + ec = acc.listen(); + BOOST_TEST(!ec); + auto port = acc.local_endpoint().port(); + + auto nfd = make_native_socket(AF_INET, SOCK_STREAM); + BOOST_TEST(nfd != invalid_native_socket); + BOOST_TEST(native_connect_loopback(nfd, port, false)); + make_native_adoptable(nfd); + + tcp_socket adopted(ioc); + adopted.assign(nfd); + BOOST_TEST(adopted.is_open()); + BOOST_TEST(adopted.native_handle() == nfd); + BOOST_TEST_EQ(adopted.remote_endpoint().port(), port); + BOOST_TEST(adopted.local_endpoint().port() != 0); + + bool done = false; + auto task = [&]() -> capy::task<> { + auto [aec, peer] = co_await acc.accept(); + BOOST_TEST(!aec); + + char const out[] = "ping"; + auto [wec, wn] = co_await adopted.write_some( + capy::const_buffer(out, 4)); + BOOST_TEST(!wec); + BOOST_TEST_EQ(wn, std::size_t(4)); + + char in[8]; + auto [rec1, rn1] = + co_await peer.read_some(capy::mutable_buffer(in, sizeof(in))); + BOOST_TEST(!rec1); + BOOST_TEST_EQ(rn1, std::size_t(4)); + + auto [wec2, wn2] = + co_await peer.write_some(capy::const_buffer(out, 4)); + BOOST_TEST(!wec2); + auto [rec2, rn2] = co_await adopted.read_some( + capy::mutable_buffer(in, sizeof(in))); + BOOST_TEST(!rec2); + BOOST_TEST_EQ(rn2, std::size_t(4)); + done = true; + }; + capy::run_async(ioc.get_executor())(task()); + ioc.run(); + BOOST_TEST(done); + } + + // Rejection matrix: bad handle, wrong type, wrong family, self. + void testAssignRejections() + { + io_context ioc(Backend); + tcp_socket sock(ioc); + + auto expect_throw = [&](native_handle_type h) { + bool threw = false; + try + { + sock.assign(h); + } + catch (std::system_error const&) + { + threw = true; + } + BOOST_TEST(threw); + }; + + expect_throw(invalid_native_socket); + BOOST_TEST(!sock.is_open()); + + auto dg = make_native_socket(AF_INET, SOCK_DGRAM); + BOOST_TEST(dg != invalid_native_socket); + { + // The rejection code is part of the portable contract. + std::error_code rejected; + try + { + sock.assign(dg); + } + catch (std::system_error const& e) + { + rejected = e.code(); + } + BOOST_TEST(rejected == std::errc::wrong_protocol_type); + } + BOOST_TEST(native_socket_valid(dg)); // caller keeps it + close_native_socket(dg); + +#if BOOST_COROSIO_POSIX + auto un = make_native_socket(AF_UNIX, SOCK_STREAM); + BOOST_TEST(un != invalid_native_socket); + expect_throw(un); + BOOST_TEST(native_socket_valid(un)); + close_native_socket(un); +#endif + + sock.open(tcp::v4()); + expect_throw(sock.native_handle()); + BOOST_TEST(sock.is_open()); + sock.close(); + } + + // A failed assign over an open socket leaves it functional. + void testAssignFailureKeepsSocket() + { + io_context ioc(Backend); + auto pair = + test::make_socket_pair(ioc); + tcp_socket& s1 = pair.first; + tcp_socket& s2 = pair.second; + + auto dg = make_native_socket(AF_INET, SOCK_DGRAM); + BOOST_TEST(dg != invalid_native_socket); + bool threw = false; + try + { + s1.assign(dg); + } + catch (std::system_error const&) + { + threw = true; + } + BOOST_TEST(threw); + BOOST_TEST(native_socket_valid(dg)); + close_native_socket(dg); + + BOOST_TEST(s1.is_open()); + + bool done = false; + auto task = [&]() -> capy::task<> { + char const out[] = "still here"; + auto [wec, wn] = + co_await s1.write_some(capy::const_buffer(out, 10)); + BOOST_TEST(!wec); + char in[16]; + auto [rec, rn] = + co_await s2.read_some(capy::mutable_buffer(in, sizeof(in))); + BOOST_TEST(!rec); + done = (rn == wn); + }; + capy::run_async(ioc.get_executor())(task()); + ioc.run(); + BOOST_TEST(done); + } + + // Assign over an open socket cancels its pending operations and + // leaves the adopted descriptor usable. + void testAssignOverOpenCancelsPending() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto pair = + test::make_socket_pair(ioc); + tcp_socket& s1 = pair.first; + + tcp_acceptor acc(ioc); + acc.open(); + acc.set_option(socket_option::reuse_address(true)); + auto ec = acc.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + ec = acc.listen(); + BOOST_TEST(!ec); + + auto nfd = make_native_socket(AF_INET, SOCK_STREAM); + BOOST_TEST(nfd != invalid_native_socket); + BOOST_TEST(native_connect_loopback( + nfd, acc.local_endpoint().port(), false)); + make_native_adoptable(nfd); + + std::error_code read_ec; + bool read_done = false; + bool exchanged = false; + char buf[16]; + + auto reader = [&]() -> capy::task<> { + auto [rec, rn] = + co_await s1.read_some(capy::mutable_buffer(buf, sizeof(buf))); + (void)rn; + read_ec = rec; + read_done = true; + }; + auto adopter = [&]() -> capy::task<> { + s1.assign(nfd); + auto [aec, peer] = co_await acc.accept(); + BOOST_TEST(!aec); + char const out[] = "ping"; + auto [wec, wn] = co_await s1.write_some( + capy::const_buffer(out, 4)); + BOOST_TEST(!wec); + (void)wn; + char in[8]; + auto [rec, rn] = + co_await peer.read_some(capy::mutable_buffer(in, sizeof(in))); + BOOST_TEST(!rec); + exchanged = (rn == 4); + }; + + // run_async runs inline to the first suspend, so spawning the + // reader first is what guarantees the read is parked when + // assign() runs. + capy::run_async(ex)(reader()); + capy::run_async(ex)(adopter()); + ioc.run(); + + BOOST_TEST(read_done); + BOOST_TEST(read_ec == capy::cond::canceled); + BOOST_TEST(s1.is_open()); + BOOST_TEST(s1.native_handle() == nfd); + BOOST_TEST(exchanged); + } + + // release() hands ownership to the caller only after pending + // operations have been cancelled, and the descriptor is still + // connected to the peer. + void testRelease() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto pair = + test::make_socket_pair(ioc); + tcp_socket& s1 = pair.first; + tcp_socket& s2 = pair.second; + + std::error_code read_ec; + bool read_done = false; + char buf[16]; + auto released = invalid_native_socket; + + auto reader = [&]() -> capy::task<> { + auto [rec, rn] = + co_await s1.read_some(capy::mutable_buffer(buf, sizeof(buf))); + (void)rn; + read_ec = rec; + read_done = true; + }; + auto releaser = [&]() -> capy::task<> { + released = s1.release(); + co_return; + }; + + capy::run_async(ex)(reader()); + capy::run_async(ex)(releaser()); + ioc.run(); + ioc.restart(); + + BOOST_TEST(read_done); + BOOST_TEST(read_ec == capy::cond::canceled); + BOOST_TEST(!s1.is_open()); + BOOST_TEST(released != invalid_native_socket); + + char const msg[] = "released"; + BOOST_TEST(native_send(released, msg, 8)); + + bool got = false; + auto peeker = [&]() -> capy::task<> { + char in[16]; + auto [rec, rn] = + co_await s2.read_some(capy::mutable_buffer(in, sizeof(in))); + BOOST_TEST(!rec); + got = (rn == 8); + }; + capy::run_async(ex)(peeker()); + ioc.run(); + BOOST_TEST(got); + + close_native_socket(released); + } + + void testReleaseClosedThrows() + { + io_context ioc(Backend); + tcp_socket sock(ioc); + + bool caught = false; + try + { + (void)sock.release(); + } + catch (std::logic_error const&) + { + caught = true; + } + BOOST_TEST(caught); + } + + // v6 adoption: the cached endpoints must report v6. + void testAssignV6() + { + io_context ioc(Backend); + + tcp_acceptor acc(ioc); + acc.open(tcp::v6()); + acc.set_option(socket_option::reuse_address(true)); + auto ec = acc.bind(endpoint(ipv6_address::loopback(), 0)); + if (ec) + return; // no IPv6 loopback on this host + ec = acc.listen(); + BOOST_TEST(!ec); + auto port = acc.local_endpoint().port(); + + auto nfd = make_native_socket(AF_INET6, SOCK_STREAM); + if (nfd == invalid_native_socket) + return; + if (!native_connect_loopback(nfd, port, true)) + { + close_native_socket(nfd); + return; + } + make_native_adoptable(nfd); + + tcp_socket adopted(ioc); + adopted.assign(nfd); + BOOST_TEST(adopted.is_open()); + BOOST_TEST(adopted.local_endpoint().is_v6()); + BOOST_TEST(adopted.remote_endpoint().is_v6()); + BOOST_TEST_EQ(adopted.remote_endpoint().port(), port); + + bool done = false; + auto task = [&]() -> capy::task<> { + auto [aec, peer] = co_await acc.accept(); + BOOST_TEST(!aec); + char const out[] = "v6"; + auto [wec, wn] = co_await adopted.write_some( + capy::const_buffer(out, 2)); + BOOST_TEST(!wec); + (void)wn; + char in[8]; + auto [rec, rn] = + co_await peer.read_some(capy::mutable_buffer(in, sizeof(in))); + BOOST_TEST(!rec); + done = (rn == 2); + }; + capy::run_async(ioc.get_executor())(task()); + ioc.run(); + BOOST_TEST(done); + } }; COROSIO_BACKEND_TESTS(tcp_socket_test, "boost.corosio.tcp_socket") diff --git a/test/unit/test_utils.hpp b/test/unit/test_utils.hpp index 63802cdd2..1b58aaa99 100644 --- a/test/unit/test_utils.hpp +++ b/test/unit/test_utils.hpp @@ -16,6 +16,8 @@ #include #include #include +#include +#include #include #include #include @@ -29,6 +31,15 @@ #include #include +#if BOOST_COROSIO_POSIX +#include +#include +#include +#else +#include +#include +#endif + // Valgrind slows execution ~10-20x; scale failsafe timeouts to avoid // false failures when BOOST_NO_STRESS_TEST is defined. #ifdef BOOST_NO_STRESS_TEST @@ -39,6 +50,87 @@ inline constexpr int failsafe_scale = 1; namespace boost::corosio::test { +// +// Raw native sockets for the assign()/release() adoption tests +// + +#if BOOST_COROSIO_HAS_IOCP +inline constexpr native_handle_type invalid_native_socket = + static_cast(~0ull); +#else +inline constexpr native_handle_type invalid_native_socket = + static_cast(-1); +#endif + +/** Create a socket the way an adopting caller would: outside the + library, owned by the caller until assign() succeeds. + + @param family Address family. + @param type Socket type. + @return The new descriptor, or @ref invalid_native_socket. +*/ +inline native_handle_type +make_native_socket(int family, int type) +{ +#if BOOST_COROSIO_HAS_IOCP + return static_cast(::WSASocketW( + family, type, 0, nullptr, 0, WSA_FLAG_OVERLAPPED)); +#else + return static_cast(::socket(family, type, 0)); +#endif +} + +/** Put a descriptor in the mode the backend needs before adoption. + + Adoption never touches descriptor flags, so the caller must hand + in a socket that is already configured. + + @param h The descriptor to configure. +*/ +inline void +make_native_adoptable(native_handle_type h) +{ +#if BOOST_COROSIO_HAS_IOCP + (void)h; // WSA_FLAG_OVERLAPPED is set at creation +#else + int fd = static_cast(h); + int flags = ::fcntl(fd, F_GETFL); + ::fcntl(fd, F_SETFL, flags | O_NONBLOCK); +#endif +} + +/// Close a descriptor the library does not own. +inline void +close_native_socket(native_handle_type h) +{ +#if BOOST_COROSIO_HAS_IOCP + ::closesocket(static_cast(h)); +#else + ::close(static_cast(h)); +#endif +} + +/** Check whether a descriptor is still open. + + A rejected assign must leave the descriptor with the caller. + + @param h The descriptor to probe. + @return True while the descriptor is still open. +*/ +inline bool +native_socket_valid(native_handle_type h) +{ +#if BOOST_COROSIO_HAS_IOCP + int type = 0; + int len = static_cast(sizeof(type)); + return ::getsockopt( + static_cast(h), SOL_SOCKET, SO_TYPE, + reinterpret_cast(&type), &len) == 0; +#else + return ::fcntl(static_cast(h), F_GETFD) >= 0; +#endif +} + // // Embedded Test Certificates // diff --git a/test/unit/udp_socket.cpp b/test/unit/udp_socket.cpp index 06b6bea7f..9adcd863d 100644 --- a/test/unit/udp_socket.cpp +++ b/test/unit/udp_socket.cpp @@ -22,14 +22,109 @@ #include #include +#include #include +#include + +#if BOOST_COROSIO_POSIX +// Raw socket creation for the assign()/release() adoption tests. +#include +#include +#include +#include +#else +#include +#include // sockaddr_in6, in6addr_loopback +#endif #include "context.hpp" #include "test_suite.hpp" +#include "test_utils.hpp" namespace boost::corosio { namespace { +using test::close_native_socket; +using test::invalid_native_socket; +using test::make_native_adoptable; +using test::make_native_socket; +using test::native_socket_valid; + +// Fill a sockaddr_storage with a loopback address for `port`. +std::size_t +fill_loopback(sockaddr_storage& storage, std::uint16_t port, bool v6) +{ + storage = sockaddr_storage{}; + if (v6) + { + auto* sa6 = reinterpret_cast(&storage); + sa6->sin6_family = AF_INET6; + sa6->sin6_port = htons(port); + sa6->sin6_addr = in6addr_loopback; + return sizeof(sockaddr_in6); + } + auto* sa4 = reinterpret_cast(&storage); + sa4->sin_family = AF_INET; + sa4->sin_port = htons(port); + sa4->sin_addr.s_addr = htonl(INADDR_LOOPBACK); + return sizeof(sockaddr_in); +} + +// Bind to an ephemeral loopback port and report it. +bool +native_bind_loopback(native_handle_type h, bool v6, std::uint16_t& port_out) +{ + sockaddr_storage storage{}; + std::size_t len = fill_loopback(storage, 0, v6); +#if BOOST_COROSIO_HAS_IOCP + SOCKET s = static_cast(h); + if (::bind( + s, reinterpret_cast(&storage), + static_cast(len)) != 0) + return false; + int name_len = static_cast(sizeof(storage)); +#else + int s = static_cast(h); + if (::bind( + s, reinterpret_cast(&storage), + static_cast(len)) != 0) + return false; + socklen_t name_len = sizeof(storage); +#endif + if (::getsockname( + s, reinterpret_cast(&storage), &name_len) != 0) + return false; + port_out = v6 + ? ntohs(reinterpret_cast(&storage)->sin6_port) + : ntohs(reinterpret_cast(&storage)->sin_port); + return true; +} + +// Send a datagram through a descriptor the library no longer owns. +bool +native_send_to_loopback( + native_handle_type h, + std::uint16_t port, + bool v6, + char const* data, + std::size_t len) +{ + sockaddr_storage storage{}; + std::size_t addr_len = fill_loopback(storage, port, v6); +#if BOOST_COROSIO_HAS_IOCP + return ::sendto( + static_cast(h), data, static_cast(len), 0, + reinterpret_cast(&storage), + static_cast(addr_len)) == static_cast(len); +#else + return ::sendto( + static_cast(h), data, len, 0, + reinterpret_cast(&storage), + static_cast(addr_len)) == + static_cast(len); +#endif +} + template struct udp_socket_test { @@ -1203,6 +1298,374 @@ struct udp_socket_test sock.close(); } + // Adopt a natively created bound datagram socket and round-trip a + // datagram through the library in both directions. + void testAssignBoundSocket() + { + io_context ioc(Backend); + + udp_socket peer(ioc); + peer.open(); + auto ec = peer.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + auto peer_ep = peer.local_endpoint(); + + auto nfd = make_native_socket(AF_INET, SOCK_DGRAM); + BOOST_TEST(nfd != invalid_native_socket); + std::uint16_t nport = 0; + BOOST_TEST(native_bind_loopback(nfd, false, nport)); + make_native_adoptable(nfd); + + udp_socket adopted(ioc); + adopted.assign(nfd); + BOOST_TEST(adopted.is_open()); + BOOST_TEST(adopted.native_handle() == nfd); + BOOST_TEST_EQ(adopted.local_endpoint().port(), nport); + + bool done = false; + auto task = [&]() -> capy::task<> { + char const msg[] = "adopted"; + auto [ec1, n1] = co_await adopted.send_to( + capy::const_buffer(msg, sizeof(msg)), peer_ep); + BOOST_TEST(!ec1); + BOOST_TEST_EQ(n1, sizeof(msg)); + + char buf[64] = {}; + endpoint source; + auto [ec2, n2] = co_await peer.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), source); + BOOST_TEST(!ec2); + BOOST_TEST_EQ(n2, sizeof(msg)); + BOOST_TEST_EQ(source.port(), nport); + + // Reverse direction through the adopted socket. + char const reply[] = "back"; + auto [ec3, n3] = co_await peer.send_to( + capy::const_buffer(reply, sizeof(reply)), source); + BOOST_TEST(!ec3); + (void)n3; + + char buf2[64] = {}; + endpoint from; + auto [ec4, n4] = co_await adopted.recv_from( + capy::mutable_buffer(buf2, sizeof(buf2)), from); + BOOST_TEST(!ec4); + BOOST_TEST_EQ(n4, sizeof(reply)); + done = (std::strcmp(buf2, "back") == 0); + }; + capy::run_async(ioc.get_executor())(task()); + ioc.run(); + BOOST_TEST(done); + } + + // Rejection matrix: bad handle, wrong type, wrong family, self. + void testAssignRejections() + { + io_context ioc(Backend); + udp_socket sock(ioc); + + auto expect_throw = [&](native_handle_type h) { + bool threw = false; + try + { + sock.assign(h); + } + catch (std::system_error const&) + { + threw = true; + } + BOOST_TEST(threw); + }; + + expect_throw(invalid_native_socket); + BOOST_TEST(!sock.is_open()); + + auto st = make_native_socket(AF_INET, SOCK_STREAM); + BOOST_TEST(st != invalid_native_socket); + { + // The rejection code is part of the portable contract. + std::error_code rejected; + try + { + sock.assign(st); + } + catch (std::system_error const& e) + { + rejected = e.code(); + } + BOOST_TEST(rejected == std::errc::wrong_protocol_type); + } + BOOST_TEST(native_socket_valid(st)); // caller keeps it + close_native_socket(st); + +#if BOOST_COROSIO_POSIX + auto un = make_native_socket(AF_UNIX, SOCK_DGRAM); + BOOST_TEST(un != invalid_native_socket); + expect_throw(un); + BOOST_TEST(native_socket_valid(un)); + close_native_socket(un); +#endif + + sock.open(udp::v4()); + expect_throw(sock.native_handle()); + BOOST_TEST(sock.is_open()); + sock.close(); + } + + // A failed assign over an open socket leaves it functional. + void testAssignFailureKeepsSocket() + { + io_context ioc(Backend); + + udp_socket peer(ioc); + peer.open(); + auto ec = peer.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + auto peer_ep = peer.local_endpoint(); + + udp_socket sock(ioc); + sock.open(); + ec = sock.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + auto before = sock.native_handle(); + + auto st = make_native_socket(AF_INET, SOCK_STREAM); + BOOST_TEST(st != invalid_native_socket); + bool threw = false; + try + { + sock.assign(st); + } + catch (std::system_error const&) + { + threw = true; + } + BOOST_TEST(threw); + BOOST_TEST(native_socket_valid(st)); + close_native_socket(st); + + BOOST_TEST(sock.is_open()); + BOOST_TEST(sock.native_handle() == before); + + bool done = false; + auto task = [&]() -> capy::task<> { + char const msg[] = "intact"; + auto [ec1, n1] = co_await sock.send_to( + capy::const_buffer(msg, sizeof(msg)), peer_ep); + BOOST_TEST(!ec1); + + char buf[64] = {}; + endpoint source; + auto [ec2, n2] = co_await peer.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), source); + BOOST_TEST(!ec2); + done = (n2 == n1); + }; + capy::run_async(ioc.get_executor())(task()); + ioc.run(); + BOOST_TEST(done); + } + + // Assign over an open socket cancels its pending operations and + // leaves the adopted descriptor usable. + void testAssignOverOpenCancelsPending() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + udp_socket peer(ioc); + peer.open(); + auto ec = peer.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + auto peer_ep = peer.local_endpoint(); + + udp_socket sock(ioc); + sock.open(); + ec = sock.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + + auto nfd = make_native_socket(AF_INET, SOCK_DGRAM); + BOOST_TEST(nfd != invalid_native_socket); + std::uint16_t nport = 0; + BOOST_TEST(native_bind_loopback(nfd, false, nport)); + make_native_adoptable(nfd); + + std::error_code recv_ec; + bool recv_done = false; + bool delivered = false; + char buf[64]; + endpoint source; + + auto receiver = [&]() -> capy::task<> { + auto [rec, rn] = co_await sock.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), source); + (void)rn; + recv_ec = rec; + recv_done = true; + }; + auto adopter = [&]() -> capy::task<> { + sock.assign(nfd); + char const msg[] = "after"; + auto [ec1, n1] = co_await sock.send_to( + capy::const_buffer(msg, sizeof(msg)), peer_ep); + BOOST_TEST(!ec1); + + char in[64] = {}; + endpoint from; + auto [ec2, n2] = co_await peer.recv_from( + capy::mutable_buffer(in, sizeof(in)), from); + BOOST_TEST(!ec2); + delivered = (n2 == n1 && from.port() == nport); + }; + + // run_async runs inline to the first suspend, so spawning the + // receiver first is what guarantees the recv is parked when + // assign() runs. + capy::run_async(ex)(receiver()); + capy::run_async(ex)(adopter()); + ioc.run(); + + BOOST_TEST(recv_done); + BOOST_TEST(recv_ec == capy::cond::canceled); + BOOST_TEST(sock.is_open()); + BOOST_TEST(sock.native_handle() == nfd); + BOOST_TEST(delivered); + } + + // release() hands ownership to the caller only after pending + // operations have been cancelled, and the descriptor is still + // bound to its port. + void testRelease() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + udp_socket peer(ioc); + peer.open(); + auto ec = peer.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + auto peer_port = peer.local_endpoint().port(); + + udp_socket sock(ioc); + sock.open(); + ec = sock.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + auto sock_port = sock.local_endpoint().port(); + + std::error_code recv_ec; + bool recv_done = false; + char buf[64]; + endpoint source; + auto released = invalid_native_socket; + + auto receiver = [&]() -> capy::task<> { + auto [rec, rn] = co_await sock.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), source); + (void)rn; + recv_ec = rec; + recv_done = true; + }; + auto releaser = [&]() -> capy::task<> { + released = sock.release(); + co_return; + }; + + capy::run_async(ex)(receiver()); + capy::run_async(ex)(releaser()); + ioc.run(); + ioc.restart(); + + BOOST_TEST(recv_done); + BOOST_TEST(recv_ec == capy::cond::canceled); + BOOST_TEST(!sock.is_open()); + BOOST_TEST(released != invalid_native_socket); + + char const msg[] = "released"; + BOOST_TEST(native_send_to_loopback( + released, peer_port, false, msg, sizeof(msg))); + + bool got = false; + auto peeker = [&]() -> capy::task<> { + char in[64] = {}; + endpoint from; + auto [rec, rn] = co_await peer.recv_from( + capy::mutable_buffer(in, sizeof(in)), from); + BOOST_TEST(!rec); + got = (rn == sizeof(msg) && from.port() == sock_port); + }; + capy::run_async(ex)(peeker()); + ioc.run(); + BOOST_TEST(got); + + close_native_socket(released); + } + + void testReleaseClosedThrows() + { + io_context ioc(Backend); + udp_socket sock(ioc); + + bool caught = false; + try + { + (void)sock.release(); + } + catch (std::logic_error const&) + { + caught = true; + } + BOOST_TEST(caught); + } + + // v6 adoption: the cached endpoint must report v6. + void testAssignV6() + { + io_context ioc(Backend); + + udp_socket peer(ioc); + peer.open(udp::v6()); + auto ec = peer.bind(endpoint(ipv6_address::loopback(), 0)); + if (ec) + return; // no IPv6 loopback on this host + auto peer_ep = peer.local_endpoint(); + + auto nfd = make_native_socket(AF_INET6, SOCK_DGRAM); + if (nfd == invalid_native_socket) + return; + std::uint16_t nport = 0; + if (!native_bind_loopback(nfd, true, nport)) + { + close_native_socket(nfd); + return; + } + make_native_adoptable(nfd); + + udp_socket adopted(ioc); + adopted.assign(nfd); + BOOST_TEST(adopted.is_open()); + BOOST_TEST(adopted.local_endpoint().is_v6()); + BOOST_TEST_EQ(adopted.local_endpoint().port(), nport); + + bool done = false; + auto task = [&]() -> capy::task<> { + char const msg[] = "v6adopt"; + auto [ec1, n1] = co_await adopted.send_to( + capy::const_buffer(msg, sizeof(msg)), peer_ep); + BOOST_TEST(!ec1); + + char buf[64] = {}; + endpoint source; + auto [ec2, n2] = co_await peer.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), source); + BOOST_TEST(!ec2); + BOOST_TEST(source.is_v6()); + done = (n2 == n1); + }; + capy::run_async(ioc.get_executor())(task()); + ioc.run(); + BOOST_TEST(done); + } + void run() { testConstruction(); @@ -1248,6 +1711,15 @@ struct udp_socket_test testMulticastInterfaceV6(); testBufferSizeBoundary(); testWrongProtocolNoDelayOnUdp(); + + // Adoption and release + testAssignBoundSocket(); + testAssignRejections(); + testAssignFailureKeepsSocket(); + testAssignOverOpenCancelsPending(); + testRelease(); + testReleaseClosedThrows(); + testAssignV6(); } }; diff --git a/test/unit/wait.cpp b/test/unit/wait.cpp index 216ea6d60..2cc302151 100644 --- a/test/unit/wait.cpp +++ b/test/unit/wait.cpp @@ -30,14 +30,199 @@ #include #include +#include #include +#include #include #include +#if BOOST_COROSIO_POSIX +// Raw descriptor access for the write-backpressure tests. +#include +#include +#else +#include +#include +#endif + #include "context.hpp" #include "test_suite.hpp" namespace boost::corosio { +namespace { + +/// Return true if the descriptor currently accepts a non-blocking write. +bool +socket_writable(native_handle_type fd) noexcept +{ +#if BOOST_COROSIO_POSIX + ::pollfd pfd{}; + pfd.fd = static_cast(fd); + pfd.events = POLLOUT; + return ::poll(&pfd, 1, 0) == 1 && (pfd.revents & POLLOUT) != 0; +#else + WSAPOLLFD pfd{}; + pfd.fd = static_cast(fd); + pfd.events = POLLWRNORM; + return ::WSAPoll(&pfd, 1, 0) == 1 && (pfd.revents & POLLWRNORM) != 0; +#endif +} + +/// Shrink a socket buffer so the peer's window closes after a few writes. +void +shrink_socket_buffer(native_handle_type fd, int optname) noexcept +{ + int size = 4096; +#if BOOST_COROSIO_POSIX + ::setsockopt( + static_cast(fd), SOL_SOCKET, optname, &size, sizeof(size)); +#else + ::setsockopt( + static_cast(fd), SOL_SOCKET, optname, + reinterpret_cast(&size), sizeof(size)); +#endif +} + +/** Fill the descriptor's send buffer until the peer's window closes. + + A single refusal is not proof of a stall: bytes still in flight + can free space again without the peer reading anything, which + would let a write wait complete with no drain. Keep writing until + a poll probe agrees the socket is unwritable. + + @param fd The descriptor to fill. + + @return The number of bytes accepted, or zero if the platform + kept accepting past the cap or never settled into a stall. +*/ +std::size_t +fill_send_buffer(native_handle_type fd) +{ + constexpr std::size_t cap = 1u << 22; + constexpr int spin_max = 1000; + + char blob[4096] = {}; + std::size_t filled = 0; + int spins = 0; + +#if !BOOST_COROSIO_POSIX + // Overlapped sockets are blocking by default; a full send buffer + // would stall the test thread instead of refusing the write. + u_long nonblocking = 1; + ::ioctlsocket(static_cast(fd), FIONBIO, &nonblocking); +#endif + + for (;;) + { +#if BOOST_COROSIO_POSIX +#if defined(MSG_NOSIGNAL) + constexpr int flags = MSG_DONTWAIT | MSG_NOSIGNAL; +#else + constexpr int flags = MSG_DONTWAIT; +#endif + auto n = ::send(static_cast(fd), blob, sizeof(blob), flags); + bool refused = n < 0; + if (refused) + { + BOOST_TEST(errno == EAGAIN || errno == EWOULDBLOCK); + } +#else + auto n = ::send( + static_cast(fd), blob, static_cast(sizeof(blob)), 0); + bool refused = n == SOCKET_ERROR; + if (refused) + { + BOOST_TEST(::WSAGetLastError() == WSAEWOULDBLOCK); + } +#endif + if (refused) + { + if (!socket_writable(fd)) + break; + if (++spins > spin_max) + { + // Refusals the poll keeps disagreeing with never + // establish a stall, so the caller has nothing to + // test: report no fill and let it skip. + filled = 0; + break; + } + continue; + } + spins = 0; + filled += static_cast(n); + if (filled > cap) + { + filled = 0; + break; + } + } + +#if !BOOST_COROSIO_POSIX + nonblocking = 0; + ::ioctlsocket(static_cast(fd), FIONBIO, &nonblocking); +#endif + return filled; +} + +/** Create a connected pair whose socket buffers are pinned small. + + The receive window is negotiated during the handshake, and buffer + options set on an established socket no longer bind it: Darwin in + particular keeps growing the receive side, so a "full" send buffer + drains again without the peer reading anything. Shrinking the + listener and the client before the handshake makes the + backpressure real on every platform. +*/ +std::pair +make_backpressured_pair(io_context& ioc) +{ + auto ex = ioc.get_executor(); + + std::error_code accept_ec; + std::error_code connect_ec; + bool accept_done = false; + bool connect_done = false; + + tcp_acceptor acc(ioc); + acc.open(); + acc.set_option(socket_option::reuse_address(true)); + shrink_socket_buffer(acc.native_handle(), SO_SNDBUF); + shrink_socket_buffer(acc.native_handle(), SO_RCVBUF); + auto bec = acc.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!bec); + auto lec = acc.listen(); + BOOST_TEST(!lec); + auto port = acc.local_endpoint().port(); + + tcp_socket s1(ioc); + tcp_socket s2(ioc); + s2.open(); + shrink_socket_buffer(s2.native_handle(), SO_SNDBUF); + shrink_socket_buffer(s2.native_handle(), SO_RCVBUF); + + auto acceptor_task = [&]() -> capy::task<> { + auto [ec] = co_await acc.accept(s1); + accept_ec = ec; + accept_done = true; + }; + auto connect_task = [&]() -> capy::task<> { + auto [ec] = + co_await s2.connect(endpoint(ipv4_address::loopback(), port)); + connect_ec = ec; + connect_done = true; + }; + capy::run_async(ex)(acceptor_task()); + capy::run_async(ex)(connect_task()); + ioc.run(); + ioc.restart(); + + BOOST_TEST(accept_done && !accept_ec); + BOOST_TEST(connect_done && !connect_ec); + return {std::move(s1), std::move(s2)}; +} + +} // namespace template struct wait_test @@ -85,8 +270,9 @@ struct wait_test BOOST_TEST_EQ(bytes_read, payload.size()); } - // wait_write completes immediately on a freshly connected socket. - void testWaitWriteImmediate() + // A freshly connected socket probes writable, so wait_write + // completes without waiting for anything to drain. + void testWaitWriteReady() { io_context ioc(Backend); auto ex = ioc.get_executor(); @@ -108,6 +294,181 @@ struct wait_test BOOST_TEST(!wait_ec); } + // wait_write must park while the send buffer is full: completing + // immediately turns an external "retry when writable" flush loop + // into a busy spin exactly when the socket is backpressured. + void testWaitWriteParksUntilDrained() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [s1, s2] = make_backpressured_pair(ioc); + + auto filled = fill_send_buffer(s1.native_handle()); + if (filled == 0) + return; // the platform refuses to backpressure + + std::error_code wait_ec; + bool wait_done = false; + bool drained = false; + + auto waiter = [&]() -> capy::task<> { + for (;;) + { + auto [ec] = co_await s1.wait(wait_type::write); + wait_ec = ec; + wait_done = true; + if (ec) + break; + // The contract under test: a completed write wait + // means a non-blocking write can make progress right + // now. Checked on every wake, because the kernel may + // free send space on its own (window probes, buffer + // compaction) — such a wake is a true writability + // report, just not the drain we sequenced. Re-wait + // until the peer has actually taken bytes. + BOOST_TEST(socket_writable(s1.native_handle())); + if (drained) + break; + } + }; + auto drainer = [&]() -> capy::task<> { + std::array sink{}; + std::size_t got = 0; + while (got < filled) + { + auto [ec, n] = co_await s2.read_some( + capy::mutable_buffer(sink.data(), sink.size())); + if (ec) + break; + got += n; + drained = true; + } + }; + + // Spawn order is park order: the wait must be outstanding + // before the drain reopens the window. + capy::run_async(ex)(waiter()); + capy::run_async(ex)(drainer()); + ioc.run(); + + BOOST_TEST(wait_done); + BOOST_TEST(!wait_ec); + BOOST_TEST(drained); + } + + // Cancelling a parked write wait completes it as canceled instead + // of leaving the op in the descriptor's write-wait slot. + void testWaitWriteCancel() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [s1, s2] = make_backpressured_pair(ioc); + + if (fill_send_buffer(s1.native_handle()) == 0) + return; // the platform refuses to backpressure + + std::error_code wait_ec; + bool wait_done = false; + + auto waiter = [&]() -> capy::task<> { + auto [ec] = co_await s1.wait(wait_type::write); + wait_ec = ec; + wait_done = true; + }; + // Spawn order is park order: the waiter's turn parks the wait + // before the canceller's turn runs, and the cancel lands one + // scheduler iteration later — no window for a kernel that + // frees send-buffer space on its own. + auto canceller = [&]() -> capy::task<> { + s1.cancel(); + co_return; + }; + + capy::run_async(ex)(waiter()); + capy::run_async(ex)(canceller()); + ioc.run(); + + BOOST_TEST(wait_done); + BOOST_TEST(wait_ec == capy::cond::canceled); + } + + // A cancel that reaches a wait outside its parked window must not + // leak into the next wait in the same direction: the second wait + // runs under a fresh token and must park until the peer drains. + void testWaitWriteCancelDoesNotLeak() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [s1, s2] = make_backpressured_pair(ioc); + + // First wait: writable socket, pre-stopped token. The stop + // callback fires during initiation, before the op is in any + // descriptor slot, and the wait completes canceled. + std::stop_source ss; + ss.request_stop(); + + std::error_code first_ec; + bool first_done = false; + auto first = [&]() -> capy::task<> { + auto [ec] = co_await s1.wait(wait_type::write); + first_ec = ec; + first_done = true; + }; + capy::run_async(ex, ss.get_token())(first()); + ioc.run(); + ioc.restart(); + + // Whether the first wait reports canceled or the probe's + // success is immaterial here; the subject is what its cancel + // left behind. + BOOST_TEST(first_done); + (void)first_ec; + + // Second wait: full buffer, fresh token. It must park and + // complete on the drain — not absorb the first wait's cancel. + auto filled = fill_send_buffer(s1.native_handle()); + if (filled == 0) + return; // the platform refuses to backpressure + + std::error_code wait_ec; + bool wait_done = false; + bool drained = false; + + auto waiter = [&]() -> capy::task<> { + for (;;) + { + auto [ec] = co_await s1.wait(wait_type::write); + wait_ec = ec; + wait_done = true; + if (ec) + break; + BOOST_TEST(socket_writable(s1.native_handle())); + if (drained) + break; + } + }; + auto drainer = [&]() -> capy::task<> { + std::array sink{}; + std::size_t got = 0; + while (got < filled) + { + auto [ec, n] = co_await s2.read_some( + capy::mutable_buffer(sink.data(), sink.size())); + if (ec) + break; + got += n; + drained = true; + } + }; + capy::run_async(ex)(waiter()); + capy::run_async(ex)(drainer()); + ioc.run(); + + BOOST_TEST(wait_done); + BOOST_TEST(!wait_ec); + BOOST_TEST(drained); + } + // UDP wait_read fires when a datagram arrives. void testWaitOnUdp() { @@ -561,7 +922,10 @@ struct wait_test void run() { testWaitReadAndNoConsume(); - testWaitWriteImmediate(); + testWaitWriteReady(); + testWaitWriteParksUntilDrained(); + testWaitWriteCancel(); + testWaitWriteCancelDoesNotLeak(); testAcceptorWait(); testWaitOnLocalStream(); testWaitOnUdp();