From c6c516e946d2ddcf793073acb460ec9c56f8856c Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Mon, 17 Aug 2026 22:18:55 +0200 Subject: [PATCH 01/14] refactor(reactor): validate adopted fds before closing the held socket Registration errors now propagate as error codes instead of throwing through noexcept frames, a failed assign leaves the object unchanged with the caller owning the rejected fd, self-assign is rejected, and the family check is parameterized for the internet family. Stream release now clears the stale remote endpoint cache. --- .../native/detail/epoll/epoll_scheduler.hpp | 14 ++-- .../native/detail/kqueue/kqueue_scheduler.hpp | 14 ++-- .../detail/reactor/reactor_acceptor.hpp | 6 +- .../native/detail/reactor/reactor_backend.hpp | 30 +++++--- .../detail/reactor/reactor_basic_socket.hpp | 24 ++++++- .../detail/reactor/reactor_op_complete.hpp | 14 +++- .../detail/reactor/reactor_service_finals.hpp | 62 ++++++++++------ .../detail/reactor/reactor_stream_socket.hpp | 8 +++ .../native/detail/select/select_scheduler.hpp | 24 +++++-- test/unit/local_datagram_socket.cpp | 34 +++++++++ test/unit/local_stream_socket.cpp | 72 +++++++++++++++++++ 11 files changed, 247 insertions(+), 55 deletions(-) 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/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/reactor/reactor_acceptor.hpp b/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp index cf3a60350..58488a604 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp @@ -227,7 +227,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 +470,7 @@ reactor_acceptorscheduler().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..7d166f610 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. 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..586f560e3 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_service_finals.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_service_finals.hpp @@ -66,7 +66,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 +79,38 @@ 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(); + if (fd == socket_impl->native_handle()) + return std::make_error_code(std::errc::invalid_argument); - // Validate that fd is actually an AF_UNIX socket of the expected type. + // Validate before touching the held socket: a failed assign must + // leave the object unchanged and the caller owning the fd. + 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) { - 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) + if (st.ss_family != AF_INET && st.ss_family != AF_INET6) 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); } + 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); // Adopt-only: do not mutate the caller's fd flags. Callers // pass fds they have already configured (e.g., from socketpair @@ -107,7 +118,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< @@ -245,10 +259,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); } }; @@ -328,10 +343,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( 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..36637d4df 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_stream_socket.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_stream_socket.hpp @@ -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 diff --git a/include/boost/corosio/native/detail/select/select_scheduler.hpp b/include/boost/corosio/native/detail/select/select_scheduler.hpp index b0489c6b4..31d9147a7 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. @@ -129,7 +134,8 @@ class BOOST_COROSIO_DECL select_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, "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 diff --git a/test/unit/local_datagram_socket.cpp b/test/unit/local_datagram_socket.cpp index adf32874f..c9ba4137c 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" @@ -689,6 +692,36 @@ struct local_datagram_socket_test BOOST_TEST(caught); } + // A rejected fd must remain owned and usable by the caller + // (validation happens before any state is touched). + void testAssignRejectedFdStaysOpen() + { +#if BOOST_COROSIO_HAS_IO_URING + // io_uring's adopt path performs no validation yet. + if constexpr (std::is_same_v< + std::remove_const_t, io_uring_t>) + return; +#endif + 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((native_handle_type)fds[0]); + } + catch (std::system_error const&) + { + threw = true; + } + BOOST_TEST(threw); + BOOST_TEST(::fcntl(fds[0], F_GETFD) >= 0); + BOOST_TEST(!sock.is_open()); + ::close(fds[0]); + ::close(fds[1]); + } + void testRelease() { io_context ioc(Backend); @@ -809,6 +842,7 @@ struct local_datagram_socket_test testAvailableClosedThrows(); testAvailable(); testAssignAlreadyOpenThrows(); + testAssignRejectedFdStaysOpen(); testRelease(); testSendOnClosedThrows(); testCancelPendingRecv(); diff --git a/test/unit/local_stream_socket.cpp b/test/unit/local_stream_socket.cpp index fb81f655f..14369e14f 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 @@ -470,6 +471,71 @@ struct local_stream_socket_test BOOST_TEST(caught); } +#if BOOST_COROSIO_POSIX + // A rejected fd must remain owned and usable by the caller + // (validation happens before any state is touched). + void testAssignRejectedFdStaysOpen() + { +#if BOOST_COROSIO_HAS_IO_URING + // io_uring's adopt path performs no validation yet. + if constexpr (std::is_same_v< + std::remove_const_t, io_uring_t>) + return; +#endif + 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((native_handle_type)fds[0]); + } + catch (std::system_error const&) + { + threw = true; + } + 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() { io_context ioc(Backend); @@ -1236,6 +1302,12 @@ struct local_stream_socket_test testEndpointsConnected(); testShutdown(); testAssignAlreadyOpenThrows(); +#if BOOST_COROSIO_POSIX + testAssignRejectedFdStaysOpen(); +#endif +#if BOOST_COROSIO_HAS_EPOLL + testAssignDuplicateFdErrors(); +#endif testReleaseClosedThrows(); testAvailableClosedThrows(); testConnectToNonexistent(); From aeaf234421a8ac1d7a5727ef6088ce54701f6a99 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Mon, 17 Aug 2026 22:34:17 +0200 Subject: [PATCH 02/14] fix(assign): validate adopted sockets on io_uring and iocp io_uring's local assign accepted any fd without checks and could not fail; iocp's closed the held socket before association could fail and left the endpoint caches empty. Both now validate first and seed the endpoint caches, sharing the POSIX validation helper. --- .../native/detail/io_uring/io_uring_types.hpp | 17 ++++- .../detail/iocp/win_local_stream_service.hpp | 48 +++++++++--- .../detail/reactor/reactor_service_finals.hpp | 31 ++------ .../corosio/native/detail/validate_fd.hpp | 73 +++++++++++++++++++ test/unit/local_datagram_socket.cpp | 26 +++++-- test/unit/local_stream_socket.cpp | 26 +++++-- 6 files changed, 173 insertions(+), 48 deletions(-) create mode 100644 include/boost/corosio/native/detail/validate_fd.hpp 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..223f8a6bb 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 @@ -1244,12 +1245,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); @@ -2612,12 +2619,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_local_stream_service.hpp b/include/boost/corosio/native/detail/iocp/win_local_stream_service.hpp index 7be8cf549..f39c76d24 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 @@ -950,20 +950,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 {}; } 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 586f560e3..60e5d27d0 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 @@ -82,35 +83,15 @@ do_assign_fd( int expected_type, bool is_ip) noexcept { - if (fd < 0) - return make_err(EBADF); - - if (fd == socket_impl->native_handle()) + // 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. - 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); + 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 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/test/unit/local_datagram_socket.cpp b/test/unit/local_datagram_socket.cpp index c9ba4137c..2dbe07a27 100644 --- a/test/unit/local_datagram_socket.cpp +++ b/test/unit/local_datagram_socket.cpp @@ -692,16 +692,29 @@ struct local_datagram_socket_test BOOST_TEST(caught); } + // 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); + 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()); + } + // A rejected fd must remain owned and usable by the caller // (validation happens before any state is touched). void testAssignRejectedFdStaysOpen() { -#if BOOST_COROSIO_HAS_IO_URING - // io_uring's adopt path performs no validation yet. - if constexpr (std::is_same_v< - std::remove_const_t, io_uring_t>) - return; -#endif io_context ioc(Backend); local_datagram_socket sock(ioc); int fds[2]; @@ -842,6 +855,7 @@ struct local_datagram_socket_test testAvailableClosedThrows(); testAvailable(); testAssignAlreadyOpenThrows(); + testAssignBadFdThrows(); testAssignRejectedFdStaysOpen(); testRelease(); testSendOnClosedThrows(); diff --git a/test/unit/local_stream_socket.cpp b/test/unit/local_stream_socket.cpp index 14369e14f..f6326dae8 100644 --- a/test/unit/local_stream_socket.cpp +++ b/test/unit/local_stream_socket.cpp @@ -472,16 +472,29 @@ struct local_stream_socket_test } #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); + 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()); + } + // A rejected fd must remain owned and usable by the caller // (validation happens before any state is touched). void testAssignRejectedFdStaysOpen() { -#if BOOST_COROSIO_HAS_IO_URING - // io_uring's adopt path performs no validation yet. - if constexpr (std::is_same_v< - std::remove_const_t, io_uring_t>) - return; -#endif io_context ioc(Backend); local_stream_socket sock(ioc); int fds[2]; @@ -1303,6 +1316,7 @@ struct local_stream_socket_test testShutdown(); testAssignAlreadyOpenThrows(); #if BOOST_COROSIO_POSIX + testAssignBadFdThrows(); testAssignRejectedFdStaysOpen(); #endif #if BOOST_COROSIO_HAS_EPOLL From 8712a4f81b6cef812dde6ce493add6fb7607f71f Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Mon, 17 Aug 2026 22:52:05 +0200 Subject: [PATCH 03/14] fix(io_uring): cancel in-flight ops before releasing a socket fd release_socket() nulled the fd without cancel_and_flush, so pending SQEs could resolve against a recycled fd number after the caller closed the released descriptor. The acceptor already did this; the stream and datagram sockets now match. --- .../native/detail/io_uring/io_uring_types.hpp | 10 ++++ test/unit/local_stream_socket.cpp | 52 +++++++++++++++++++ 2 files changed, 62 insertions(+) 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 223f8a6bb..52c1e69eb 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 @@ -1131,6 +1131,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{}; @@ -2294,6 +2299,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{}; diff --git a/test/unit/local_stream_socket.cpp b/test/unit/local_stream_socket.cpp index f6326dae8..eaf98a4f7 100644 --- a/test/unit/local_stream_socket.cpp +++ b/test/unit/local_stream_socket.cpp @@ -1364,6 +1364,7 @@ struct local_stream_socket_test testEndpointStreamOutput(); testAvailable(); testRelease(); + testReleaseCancelsPendingRead(); } void testAvailable() @@ -1423,6 +1424,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 From 7bb13a64d2ab2888a1440a425ba541e1946097e6 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Mon, 17 Aug 2026 23:17:49 +0200 Subject: [PATCH 04/14] feat(inet): add assign() and release() to tcp_socket and udp_socket Adoption validates family and type before touching the held socket, never mutates the adopted descriptor, and allows assign-over-open by cancelling pending operations first. release() cancels pending operations, deregisters, and hands the open descriptor to the caller. Implemented on every backend. --- include/boost/corosio/detail/tcp_service.hpp | 16 + include/boost/corosio/detail/udp_service.hpp | 16 + .../native/detail/epoll/epoll_types.hpp | 11 + .../native/detail/io_uring/io_uring_types.hpp | 129 +++++ .../detail/iocp/win_tcp_acceptor_service.hpp | 69 +++ .../native/detail/iocp/win_tcp_service.hpp | 14 + .../native/detail/iocp/win_tcp_socket.hpp | 2 + .../native/detail/iocp/win_udp_service.hpp | 75 +++ .../native/detail/iocp/win_udp_socket.hpp | 2 + .../native/detail/kqueue/kqueue_types.hpp | 11 + .../detail/reactor/reactor_service_finals.hpp | 14 + .../native/detail/select/select_types.hpp | 11 + include/boost/corosio/tcp_socket.hpp | 53 ++ include/boost/corosio/udp_socket.hpp | 53 ++ src/corosio/src/tcp_socket.cpp | 25 + src/corosio/src/udp_socket.cpp | 18 + test/unit/tcp_socket.cpp | 461 ++++++++++++++++ test/unit/udp_socket.cpp | 504 ++++++++++++++++++ 18 files changed, 1484 insertions(+) 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/native/detail/epoll/epoll_types.hpp b/include/boost/corosio/native/detail/epoll/epoll_types.hpp index d94f502b1..c379a5e88 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 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 52c1e69eb..2acbd678d 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 @@ -380,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) @@ -508,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. @@ -1774,6 +1841,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) @@ -2066,6 +2147,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. 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..76cef538a 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 @@ -898,6 +898,21 @@ 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(); + 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 +1097,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) { 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..2a1cacfe4 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. 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..d0e938fb3 100644 --- a/include/boost/corosio/native/detail/iocp/win_tcp_socket.hpp +++ b/include/boost/corosio/native/detail/iocp/win_tcp_socket.hpp @@ -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..d7b9ee666 100644 --- a/include/boost/corosio/native/detail/iocp/win_udp_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_udp_service.hpp @@ -63,6 +63,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; @@ -869,6 +872,21 @@ 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(); + 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 +1060,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/kqueue/kqueue_types.hpp b/include/boost/corosio/native/detail/kqueue/kqueue_types.hpp index 3c4caf51e..c40125a19 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 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 60e5d27d0..3e3f69d11 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_service_finals.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_service_finals.hpp @@ -188,6 +188,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 { @@ -282,6 +289,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 { diff --git a/include/boost/corosio/native/detail/select/select_types.hpp b/include/boost/corosio/native/detail/select/select_types.hpp index 941b23969..1b4a7405f 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 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/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/unit/tcp_socket.cpp b/test/unit/tcp_socket.cpp index 63cb36f52..462e5d9cb 100644 --- a/test/unit/tcp_socket.cpp +++ b/test/unit/tcp_socket.cpp @@ -33,11 +33,18 @@ #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" @@ -46,6 +53,117 @@ namespace boost::corosio { namespace { +#if BOOST_COROSIO_HAS_IOCP +constexpr native_handle_type invalid_native_socket = + static_cast(~0ull); +#else +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. +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 +} + +void +close_native_socket(native_handle_type h) +{ +#if BOOST_COROSIO_HAS_IOCP + ::closesocket(static_cast(h)); +#else + ::close(static_cast(h)); +#endif +} + +// True while the descriptor is still open, i.e. a rejected assign +// left it with the caller. +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 +} + +// Adoption never touches descriptor flags, so the caller must hand in +// a socket that is already in the mode the backend needs. +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 +} + +// 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 +1817,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 +2162,340 @@ 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); + 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 + + 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/udp_socket.cpp b/test/unit/udp_socket.cpp index 06b6bea7f..ea4ba3b61 100644 --- a/test/unit/udp_socket.cpp +++ b/test/unit/udp_socket.cpp @@ -22,7 +22,20 @@ #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" @@ -30,6 +43,135 @@ namespace boost::corosio { namespace { +#if BOOST_COROSIO_HAS_IOCP +constexpr native_handle_type invalid_native_socket = + static_cast(~0ull); +#else +constexpr native_handle_type invalid_native_socket = + static_cast(-1); +#endif + +// Create a socket the way an adopting caller would: outside the +// library, already in the mode the backend needs, and owned by the +// caller until assign() succeeds. +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 + int fd = ::socket(family, type, 0); + if (fd >= 0) + { + int flags = ::fcntl(fd, F_GETFL); + ::fcntl(fd, F_SETFL, flags | O_NONBLOCK); + } + return static_cast(fd); +#endif +} + +void +close_native_socket(native_handle_type h) +{ +#if BOOST_COROSIO_HAS_IOCP + ::closesocket(static_cast(h)); +#else + ::close(static_cast(h)); +#endif +} + +// True while the descriptor is still open, i.e. a rejected assign +// left it with the caller. +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 +} + +// 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 +1345,359 @@ 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)); + + 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); + expect_throw(st); + 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)); + + 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; + } + + 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 +1743,15 @@ struct udp_socket_test testMulticastInterfaceV6(); testBufferSizeBoundary(); testWrongProtocolNoDelayOnUdp(); + + // Adoption and release + testAssignBoundSocket(); + testAssignRejections(); + testAssignFailureKeepsSocket(); + testAssignOverOpenCancelsPending(); + testRelease(); + testReleaseClosedThrows(); + testAssignV6(); } }; From 18f36374aca2775862791d2500093e30f6867301 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Mon, 17 Aug 2026 23:50:08 +0200 Subject: [PATCH 05/14] feat(acceptor): complete the adoption triad on acceptors tcp_acceptor gains assign(), release(), and native_handle(); local_stream_acceptor gains assign() and native_handle(). Adopting a listening descriptor seeds the local-endpoint cache so accept paths that derive the address family from it keep working, including for adopted IPv6 listeners. --- .../detail/local_stream_acceptor_service.hpp | 15 + .../corosio/detail/tcp_acceptor_service.hpp | 15 + .../boost/corosio/local_stream_acceptor.hpp | 45 ++ .../native/detail/epoll/epoll_types.hpp | 5 - .../detail/io_uring/io_uring_acceptor_ops.hpp | 16 + .../io_uring/io_uring_multishot_acceptor.hpp | 170 ++++++- .../native/detail/io_uring/io_uring_op.hpp | 18 + .../detail/io_uring/io_uring_scheduler.hpp | 137 +++++- .../native/detail/io_uring/io_uring_types.hpp | 119 +++-- .../detail/iocp/win_local_stream_acceptor.hpp | 1 + .../win_local_stream_acceptor_service.hpp | 21 + .../detail/iocp/win_local_stream_service.hpp | 48 ++ .../native/detail/iocp/win_tcp_acceptor.hpp | 3 + .../detail/iocp/win_tcp_acceptor_service.hpp | 81 ++++ .../native/detail/iocp/win_tcp_service.hpp | 14 + .../native/detail/kqueue/kqueue_types.hpp | 5 - .../detail/reactor/reactor_acceptor.hpp | 34 +- .../detail/reactor/reactor_service_finals.hpp | 46 ++ .../native/detail/select/select_types.hpp | 5 - include/boost/corosio/tcp_acceptor.hpp | 66 +++ src/corosio/src/local_stream_acceptor.cpp | 25 ++ src/corosio/src/tcp_acceptor.cpp | 36 ++ test/unit/local_stream_socket.cpp | 190 +++++++- test/unit/tcp_acceptor.cpp | 416 +++++++++++++++++- test/unit/tcp_socket.cpp | 66 +-- test/unit/test_utils.hpp | 92 ++++ test/unit/udp_socket.cpp | 62 +-- 27 files changed, 1580 insertions(+), 171 deletions(-) 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/local_stream_acceptor.hpp b/include/boost/corosio/local_stream_acceptor.hpp index 3a7c67a3e..184c39568 100644 --- a/include/boost/corosio/local_stream_acceptor.hpp +++ b/include/boost/corosio/local_stream_acceptor.hpp @@ -384,6 +384,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 +532,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/native/detail/epoll/epoll_types.hpp b/include/boost/corosio/native/detail/epoll/epoll_types.hpp index c379a5e88..39768dd51 100644 --- a/include/boost/corosio/native/detail/epoll/epoll_types.hpp +++ b/include/boost/corosio/native/detail/epoll/epoll_types.hpp @@ -173,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..4a8c6d077 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 @@ -84,6 +106,11 @@ class io_uring_multishot_acceptor_base intrusive_list waiters_; 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. + std::atomic arm_generation_{0}; private: // CRTP ctor private + Derived friended so the base cannot be @@ -146,6 +173,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(); @@ -211,6 +264,93 @@ 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`. + */ + void prepare_listen_arm() noexcept + { + retire_multishot(); + std::lock_guard lk(mutex_); + closing_ = false; + } + void start_multishot() { if (!multi_op_) @@ -225,9 +365,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(); @@ -513,17 +657,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 +689,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 2acbd678d..3863397bc 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 @@ -840,6 +840,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. @@ -881,6 +923,7 @@ 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.prepare_listen_arm(); acc.start_multishot(); return {}; } @@ -1374,11 +1417,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< @@ -1444,29 +1486,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 @@ -1590,6 +1609,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. @@ -1631,6 +1693,7 @@ 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.prepare_listen_arm(); acc.start_multishot(); return {}; } 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..c1a848529 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 @@ -55,6 +55,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, @@ -501,6 +505,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 { @@ -608,6 +620,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 f39c76d24..bd2429d13 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 @@ -88,6 +88,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); @@ -1121,6 +1125,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 76cef538a..9867d6a43 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 @@ -58,6 +58,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); @@ -1286,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) { @@ -1588,6 +1638,29 @@ 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(); + 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 @@ -1674,6 +1747,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 2a1cacfe4..e01455da5 100644 --- a/include/boost/corosio/native/detail/iocp/win_tcp_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_tcp_service.hpp @@ -146,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/kqueue/kqueue_types.hpp b/include/boost/corosio/native/detail/kqueue/kqueue_types.hpp index c40125a19..1cce4bedd 100644 --- a/include/boost/corosio/native/detail/kqueue/kqueue_types.hpp +++ b/include/boost/corosio/native/detail/kqueue/kqueue_types.hpp @@ -173,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/reactor/reactor_acceptor.hpp b/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp index 58488a604..b0b04f185 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 { 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 3e3f69d11..7180a7f87 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_service_finals.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_service_finals.hpp @@ -154,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 // ============================================================ @@ -395,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/select/select_types.hpp b/include/boost/corosio/native/detail/select/select_types.hpp index 1b4a7405f..4651dd86d 100644 --- a/include/boost/corosio/native/detail/select/select_types.hpp +++ b/include/boost/corosio/native/detail/select/select_types.hpp @@ -173,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/tcp_acceptor.hpp b/include/boost/corosio/tcp_acceptor.hpp index 2c419e2a3..3b22e7a0d 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 @@ -462,6 +463,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 +636,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/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/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/test/unit/local_stream_socket.cpp b/test/unit/local_stream_socket.cpp index eaf98a4f7..4fe01e79d 100644 --- a/test/unit/local_stream_socket.cpp +++ b/test/unit/local_stream_socket.cpp @@ -1208,15 +1208,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); @@ -1350,6 +1532,10 @@ struct local_stream_socket_test testAcceptorAcceptClosedThrows(); testAcceptorReleaseClosedThrows(); testAcceptorReleaseOpen(); + testAcceptorNativeHandle(); + testAcceptorAssignFromRelease(); + testAcceptorAssignOverListening(); + testAcceptorAssignAfterRelease(); testAcceptorLocalEndpoint(); testEndpointTooLongThrows(); testEndpointTooLongNoThrow(); diff --git a/test/unit/tcp_acceptor.cpp b/test/unit/tcp_acceptor.cpp index b0ad18671..d9d7fcd89 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,291 @@ 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)); + } + + // 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)); + } + + // 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 +1420,17 @@ struct tcp_acceptor_test testStopTokenAccept(); testAcceptPendingConnection(); testAcceptWithoutListen(); + + // Descriptor adoption + testNativeHandle(); + testAssignListeningSocket(); + testAssignOverListeningAcceptor(); + testAssignAfterRelease(); + testListenAfterRelease(); + testAssignRejections(); + testRelease(); + testReleaseClosedThrows(); + testAssignV6Listener(); } }; diff --git a/test/unit/tcp_socket.cpp b/test/unit/tcp_socket.cpp index 462e5d9cb..97ba84277 100644 --- a/test/unit/tcp_socket.cpp +++ b/test/unit/tcp_socket.cpp @@ -49,70 +49,16 @@ #include "context.hpp" #include "test_suite.hpp" +#include "test_utils.hpp" namespace boost::corosio { namespace { -#if BOOST_COROSIO_HAS_IOCP -constexpr native_handle_type invalid_native_socket = - static_cast(~0ull); -#else -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. -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 -} - -void -close_native_socket(native_handle_type h) -{ -#if BOOST_COROSIO_HAS_IOCP - ::closesocket(static_cast(h)); -#else - ::close(static_cast(h)); -#endif -} - -// True while the descriptor is still open, i.e. a rejected assign -// left it with the caller. -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 -} - -// Adoption never touches descriptor flags, so the caller must hand in -// a socket that is already in the mode the backend needs. -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 -} +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. 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 ea4ba3b61..6300d0afd 100644 --- a/test/unit/udp_socket.cpp +++ b/test/unit/udp_socket.cpp @@ -39,63 +39,16 @@ #include "context.hpp" #include "test_suite.hpp" +#include "test_utils.hpp" namespace boost::corosio { namespace { -#if BOOST_COROSIO_HAS_IOCP -constexpr native_handle_type invalid_native_socket = - static_cast(~0ull); -#else -constexpr native_handle_type invalid_native_socket = - static_cast(-1); -#endif - -// Create a socket the way an adopting caller would: outside the -// library, already in the mode the backend needs, and owned by the -// caller until assign() succeeds. -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 - int fd = ::socket(family, type, 0); - if (fd >= 0) - { - int flags = ::fcntl(fd, F_GETFL); - ::fcntl(fd, F_SETFL, flags | O_NONBLOCK); - } - return static_cast(fd); -#endif -} - -void -close_native_socket(native_handle_type h) -{ -#if BOOST_COROSIO_HAS_IOCP - ::closesocket(static_cast(h)); -#else - ::close(static_cast(h)); -#endif -} - -// True while the descriptor is still open, i.e. a rejected assign -// left it with the caller. -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 -} +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 @@ -1361,6 +1314,7 @@ struct udp_socket_test 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); @@ -1522,6 +1476,7 @@ struct udp_socket_test 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; @@ -1671,6 +1626,7 @@ struct udp_socket_test close_native_socket(nfd); return; } + make_native_adoptable(nfd); udp_socket adopted(ioc); adopted.assign(nfd); From 95f4d6f3228671b0946aceb222d97d13a400e42e Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Tue, 18 Aug 2026 01:20:23 +0200 Subject: [PATCH 06/14] feat(wait): report real write readiness on sockets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Socket wait(write) previously completed immediately and unconditionally, which busy-spins external flush loops ('retry when writable') exactly when the send buffer is full — intolerable once foreign sockets can be adopted. Write waits now probe with a zero-timeout poll and park until the reactor reports writability; select snapshots include parked write waits; iocp routes write waits through the poll reactor. Acceptor wait(write) keeps immediate completion and is documented as meaningless. --- doc/modules/ROOT/pages/4.guide/4r.wait.adoc | 44 +-- .../win_local_stream_acceptor_service.hpp | 3 + .../detail/iocp/win_local_stream_service.hpp | 11 +- .../detail/iocp/win_tcp_acceptor_service.hpp | 16 +- .../native/detail/iocp/win_tcp_socket.hpp | 6 +- .../native/detail/iocp/win_udp_service.hpp | 15 +- .../native/detail/iocp/win_wait_reactor.hpp | 7 +- .../detail/reactor/reactor_acceptor.hpp | 8 +- .../reactor/reactor_datagram_socket.hpp | 44 +-- .../detail/reactor/reactor_stream_socket.hpp | 48 +-- .../native/detail/select/select_scheduler.hpp | 13 +- include/boost/corosio/tcp_acceptor.hpp | 5 + .../native/native_local_stream_socket.cpp | 4 +- test/unit/native/native_tcp_socket.cpp | 6 +- test/unit/reactor_paths.cpp | 9 +- test/unit/wait.cpp | 292 +++++++++++++++++- 16 files changed, 399 insertions(+), 132 deletions(-) diff --git a/doc/modules/ROOT/pages/4.guide/4r.wait.adoc b/doc/modules/ROOT/pages/4.guide/4r.wait.adoc index 09842d4d0..7d8b31c08 100644 --- a/doc/modules/ROOT/pages/4.guide/4r.wait.adoc +++ b/doc/modules/ROOT/pages/4.guide/4r.wait.adoc @@ -107,32 +107,38 @@ 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." - -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. +`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. + +Acceptors are the exception: `wait(wait_type::write)` on an acceptor +completes immediately on the reactor and IOCP backends and never +completes on `io_uring`, because writability has no meaning for a +listening socket. Do not use it. == 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/native/detail/iocp/win_local_stream_acceptor_service.hpp b/include/boost/corosio/native/detail/iocp/win_local_stream_acceptor_service.hpp index c1a848529..92fbc9392 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 @@ -314,6 +314,9 @@ win_local_stream_acceptor_internal::wait( svc_.work_started(); + // Acceptors complete wait_type::write immediately: writability + // has no meaning for a listening socket, and parking would never + // wake. Documented in the wait guide. if (w == wait_type::write) { svc_.on_completion(&op, 0, 0); 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 bd2429d13..1567233a7 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 @@ -606,12 +606,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 @@ -640,7 +634,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(); } 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 9867d6a43..27a7af72b 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 @@ -713,14 +713,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 @@ -752,7 +744,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(); } @@ -1533,6 +1528,9 @@ win_tcp_acceptor_internal::wait( svc_.work_started(); + // Acceptors complete wait_type::write immediately: writability + // has no meaning for a listening socket, and parking would never + // wake. Documented in the wait guide. if (w == wait_type::write) { svc_.on_completion(&op, 0, 0); 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 d0e938fb3..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 { 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 d7b9ee666..35cf10308 100644 --- a/include/boost/corosio/native/detail/iocp/win_udp_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_udp_service.hpp @@ -713,17 +713,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(); } 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/reactor/reactor_acceptor.hpp b/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp index b0b04f185..5f1af44db 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp @@ -212,6 +212,10 @@ class reactor_acceptor `wait_type::read`, completion signals that an incoming connection is pending and a subsequent accept will succeed without blocking. + + `wait_type::write` completes immediately here: writability + carries no meaning for a listening socket, and the io_uring + backend never completes it at all. It is not a usable wait. */ std::coroutine_handle<> do_wait( std::coroutine_handle<>, @@ -523,7 +527,9 @@ reactor_acceptor 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_stream_socket.hpp b/include/boost/corosio/native/detail/reactor/reactor_stream_socket.hpp index 36637d4df..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<>, @@ -645,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; @@ -685,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_; @@ -737,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 31d9147a7..6390e5dc8 100644 --- a/include/boost/corosio/native/detail/select/select_scheduler.hpp +++ b/include/boost/corosio/native/detail/select/select_scheduler.hpp @@ -125,9 +125,9 @@ 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; @@ -338,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; @@ -356,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/tcp_acceptor.hpp b/include/boost/corosio/tcp_acceptor.hpp index 3b22e7a0d..4090145e8 100644 --- a/include/boost/corosio/tcp_acceptor.hpp +++ b/include/boost/corosio/tcp_acceptor.hpp @@ -441,6 +441,11 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object subsequent @ref accept will succeed without blocking. 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 completes immediately on the reactor and IOCP + backends and never completes on io_uring. + @param w The wait direction. @return An awaitable that completes with `io_result<>`. 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..57e0c82fe 100644 --- a/test/unit/reactor_paths.cpp +++ b/test/unit/reactor_paths.cpp @@ -481,7 +481,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); @@ -1391,7 +1392,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 +1533,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/wait.cpp b/test/unit/wait.cpp index 216ea6d60..3c4daf123 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,104 @@ 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); + } + // UDP wait_read fires when a datagram arrives. void testWaitOnUdp() { @@ -561,7 +845,9 @@ struct wait_test void run() { testWaitReadAndNoConsume(); - testWaitWriteImmediate(); + testWaitWriteReady(); + testWaitWriteParksUntilDrained(); + testWaitWriteCancel(); testAcceptorWait(); testWaitOnLocalStream(); testWaitOnUdp(); From e1660c1155c555ccbb92b59a651fa4e5f6a57fe0 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Tue, 18 Aug 2026 16:55:57 +0200 Subject: [PATCH 07/14] fix(iocp): dissociate released sockets from the completion port A handle stays bound to its completion port for its lifetime under the documented API, so a released socket could never be adopted into an io_context again: re-association failed with ERROR_INVALID_PARAMETER. Sever the association in release_socket() with NtSetInformationFile(FileReplaceCompletionInformation), the same mechanism the wider ecosystem relies on and the reason release requires Windows 8.1 or later. --- .../native/detail/iocp/win_dissociate.hpp | 68 +++++++++++++++++++ .../win_local_stream_acceptor_service.hpp | 2 + .../detail/iocp/win_local_stream_service.hpp | 5 ++ .../detail/iocp/win_tcp_acceptor_service.hpp | 6 ++ .../native/detail/iocp/win_udp_service.hpp | 2 + 5 files changed, 83 insertions(+) create mode 100644 include/boost/corosio/native/detail/iocp/win_dissociate.hpp 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_service.hpp b/include/boost/corosio/native/detail/iocp/win_local_stream_acceptor_service.hpp index 92fbc9392..edc74665d 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 @@ -525,6 +526,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{}; } 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 1567233a7..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 @@ -783,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{}; 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 27a7af72b..04305fff5 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 @@ -904,6 +905,10 @@ win_tcp_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_->family_ = AF_UNSPEC; internal_->local_endpoint_ = endpoint{}; @@ -1653,6 +1658,7 @@ win_tcp_acceptor::release_socket() noexcept if (s != INVALID_SOCKET) { internal_->cancel(); + dissociate_from_iocp(s); internal_->socket_ = INVALID_SOCKET; internal_->local_endpoint_ = endpoint{}; } 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 35cf10308..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 @@ -874,6 +875,7 @@ win_udp_socket::release_socket() noexcept if (s != INVALID_SOCKET) { internal_->cancel(); + dissociate_from_iocp(s); internal_->socket_ = INVALID_SOCKET; internal_->family_ = AF_UNSPEC; internal_->local_endpoint_ = endpoint{}; From 650455f612ccdd8065c23ad8c86ff76d6123ec89 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Tue, 18 Aug 2026 17:35:45 +0200 Subject: [PATCH 08/14] test(reactor_paths): discriminate reset overflow connections from false successes BSDs complete the handshake against a full accept queue and the listener then resets the child, so a success whose peer is already gone is a legal outcome there. The reset is recorded on the socket; the false-success bug under test reports success with nothing recorded, so consult SO_ERROR before counting the outcome as false. --- test/unit/reactor_paths.cpp | 38 ++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/test/unit/reactor_paths.cpp b/test/unit/reactor_paths.cpp index 57e0c82fe..ca6926819 100644 --- a/test/unit/reactor_paths.cpp +++ b/test/unit/reactor_paths.cpp @@ -46,6 +46,7 @@ #include #include +#include #include #include #include @@ -1165,12 +1166,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); From 8d23ce346c2d7f1e940d2f77524a7a03d8d52c52 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Tue, 18 Aug 2026 19:35:00 +0200 Subject: [PATCH 09/14] fix(acceptor): make acceptor wait semantics uniform across backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acceptor read and error waits now observe conditions that already hold when the wait begins: the reactor backends probe at initiation, and io_uring completes read waits from the multishot delivery queue — the accept machinery drains the kernel queue as connections arrive, so a poll on the listener could never report the readiness that matters. A connection queued before the wait, including one that predates adopting the listener, completes it everywhere. Acceptor write waits, which completed immediately on some backends and never on others, now fail uniformly with operation_not_supported: writability is meaningless for a listener, and a deterministic error beats a divergent lie. --- doc/modules/ROOT/pages/4.guide/4r.wait.adoc | 11 +- .../boost/corosio/local_stream_acceptor.hpp | 9 +- .../io_uring/io_uring_multishot_acceptor.hpp | 136 ++++++++++++- .../native/detail/io_uring/io_uring_types.hpp | 56 ++++-- .../win_local_stream_acceptor_service.hpp | 7 +- .../detail/iocp/win_tcp_acceptor_service.hpp | 7 +- .../boost/corosio/native/detail/make_err.hpp | 10 + .../detail/reactor/reactor_acceptor.hpp | 45 +++-- include/boost/corosio/tcp_acceptor.hpp | 9 +- test/unit/local_stream_socket.cpp | 14 +- test/unit/reactor_paths.cpp | 7 +- test/unit/tcp_acceptor.cpp | 179 ++++++++++++++++++ 12 files changed, 432 insertions(+), 58 deletions(-) diff --git a/doc/modules/ROOT/pages/4.guide/4r.wait.adoc b/doc/modules/ROOT/pages/4.guide/4r.wait.adoc index 7d8b31c08..f08f19ad6 100644 --- a/doc/modules/ROOT/pages/4.guide/4r.wait.adoc +++ b/doc/modules/ROOT/pages/4.guide/4r.wait.adoc @@ -85,6 +85,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 @@ -119,10 +123,9 @@ 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. -Acceptors are the exception: `wait(wait_type::write)` on an acceptor -completes immediately on the reactor and IOCP backends and never -completes on `io_uring`, because writability has no meaning for a -listening socket. Do not use it. +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 diff --git a/include/boost/corosio/local_stream_acceptor.hpp b/include/boost/corosio/local_stream_acceptor.hpp index 184c39568..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. 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 4a8c6d077..3dbd8bc4d 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 @@ -94,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; }; @@ -104,6 +107,11 @@ 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 @@ -221,6 +229,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()) @@ -239,6 +252,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 @@ -529,9 +626,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(); @@ -560,10 +666,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) @@ -610,6 +727,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(); 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 3863397bc..00952026d 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 @@ -686,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)) { @@ -1466,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)) { 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 edc74665d..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 @@ -315,12 +315,11 @@ win_local_stream_acceptor_internal::wait( svc_.work_started(); - // Acceptors complete wait_type::write immediately: writability - // has no meaning for a listening socket, and parking would never - // wake. Documented in the wait guide. + // 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(); } 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 04305fff5..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 @@ -1533,12 +1533,11 @@ win_tcp_acceptor_internal::wait( svc_.work_started(); - // Acceptors complete wait_type::write immediately: writability - // has no meaning for a listening socket, and parking would never - // wake. Documented in the wait guide. + // 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(); } diff --git a/include/boost/corosio/native/detail/make_err.hpp b/include/boost/corosio/native/detail/make_err.hpp index bc92055ae..9d00d9149 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,11 @@ make_err(unsigned long dwError) noexcept if (dwError == ERROR_HANDLE_EOF) return capy::error::eof; + // Part of the portable wait contract; system_category's condition + // mapping for WSA codes varies by toolchain. + if (dwError == WSAEOPNOTSUPP) + return std::make_error_code(std::errc::operation_not_supported); + 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 5f1af44db..995681e07 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp @@ -208,14 +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. - - `wait_type::write` completes immediately here: writability - carries no meaning for a listening socket, and the io_uring - backend never completes it at all. It is not a usable wait. + 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<>, @@ -527,9 +527,9 @@ 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(); } @@ -573,6 +573,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); @@ -581,6 +593,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/tcp_acceptor.hpp b/include/boost/corosio/tcp_acceptor.hpp index 4090145e8..7dde39951 100644 --- a/include/boost/corosio/tcp_acceptor.hpp +++ b/include/boost/corosio/tcp_acceptor.hpp @@ -438,13 +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 completes immediately on the reactor and IOCP - backends and never completes on io_uring. + the wait fails with `errc::operation_not_supported` on + every backend. @param w The wait direction. diff --git a/test/unit/local_stream_socket.cpp b/test/unit/local_stream_socket.cpp index 4fe01e79d..333d0d3ec 100644 --- a/test/unit/local_stream_socket.cpp +++ b/test/unit/local_stream_socket.cpp @@ -898,18 +898,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; @@ -934,7 +926,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. diff --git a/test/unit/reactor_paths.cpp b/test/unit/reactor_paths.cpp index ca6926819..ff2afa20a 100644 --- a/test/unit/reactor_paths.cpp +++ b/test/unit/reactor_paths.cpp @@ -415,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); @@ -443,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 diff --git a/test/unit/tcp_acceptor.cpp b/test/unit/tcp_acceptor.cpp index d9d7fcd89..19b84542c 100644 --- a/test/unit/tcp_acceptor.cpp +++ b/test/unit/tcp_acceptor.cpp @@ -1157,6 +1157,182 @@ struct tcp_acceptor_test 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. @@ -1424,6 +1600,9 @@ struct tcp_acceptor_test // Descriptor adoption testNativeHandle(); testAssignListeningSocket(); + testWaitReadPreexistingBacklog(); + testWaitReadAdoptedBacklog(); + testWaitWriteUnsupported(); testAssignOverListeningAcceptor(); testAssignAfterRelease(); testListenAfterRelease(); From 203eb21c0eeaa54306d500fe1f981b1cd5fc88e5 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Tue, 18 Aug 2026 20:23:32 +0200 Subject: [PATCH 10/14] fix(local): adopt over an open local socket like the internet family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local stream and datagram sockets refused assign() on an open socket while every other adoptable type cancels, closes, and adopts — including the local stream acceptor. The front-end guards were the only difference; the backends already handle replacement. One family, one contract: pending operations complete canceled, validation failures leave the socket untouched, and the caller keeps a rejected descriptor. --- .../boost/corosio/local_datagram_socket.hpp | 33 ++++-- include/boost/corosio/local_stream_socket.hpp | 36 ++++-- src/corosio/src/local_datagram_socket.cpp | 2 - src/corosio/src/local_stream_socket.cpp | 2 - test/unit/local_datagram_socket.cpp | 65 ++++++++--- test/unit/local_stream_socket.cpp | 109 ++++++++++++++++-- 6 files changed, 201 insertions(+), 46 deletions(-) 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_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/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_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/test/unit/local_datagram_socket.cpp b/test/unit/local_datagram_socket.cpp index 2dbe07a27..380b10344 100644 --- a/test/unit/local_datagram_socket.cpp +++ b/test/unit/local_datagram_socket.cpp @@ -674,22 +674,59 @@ 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); - local_datagram_socket sock(ioc); - sock.open(); + auto ex = ioc.get_executor(); + local_datagram_socket d1(ioc), d2(ioc); + connect_pair(d1, d2); - bool caught = false; - try - { - sock.assign(-1); - } - catch (std::logic_error const&) - { - caught = true; - } - BOOST_TEST(caught); + 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 @@ -854,7 +891,7 @@ struct local_datagram_socket_test testReleaseClosedThrows(); testAvailableClosedThrows(); testAvailable(); - testAssignAlreadyOpenThrows(); + testAssignOverOpenAdopts(); testAssignBadFdThrows(); testAssignRejectedFdStaysOpen(); testRelease(); diff --git a/test/unit/local_stream_socket.cpp b/test/unit/local_stream_socket.cpp index 333d0d3ec..5403fb50f 100644 --- a/test/unit/local_stream_socket.cpp +++ b/test/unit/local_stream_socket.cpp @@ -452,24 +452,112 @@ 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); - local_stream_socket sock(ioc); - sock.open(); - BOOST_TEST_EQ(sock.is_open(), true); + auto ex = ioc.get_executor(); + local_stream_socket s1(ioc), s2(ioc); + connect_pair(s1, s2); - bool caught = false; + 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 { - sock.assign(static_cast(-1)); + s1.assign(static_cast(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); // 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 @@ -1488,8 +1576,9 @@ struct local_stream_socket_test testSocketPair(); testEndpointsConnected(); testShutdown(); - testAssignAlreadyOpenThrows(); #if BOOST_COROSIO_POSIX + testAssignOverOpenAdopts(); + testAssignOverOpenRejectedKeepsSocket(); testAssignBadFdThrows(); testAssignRejectedFdStaysOpen(); #endif From fe4fec363e2bdc2a14205754ac104a5e3b04e87f Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Tue, 18 Aug 2026 20:49:45 +0200 Subject: [PATCH 11/14] docs(wait): show the dup-and-adopt pattern for foreign-library sockets A library that owns its socket needs readiness without ceding ownership; adopting a duplicate gives corosio a descriptor it may close while readiness still travels through the shared open file description. The old sketch predated assign() on tcp_socket; the example is now compiled. --- doc/modules/ROOT/pages/4.guide/4r.wait.adoc | 44 ++++++++++++--------- test/doc/snippets/4r_wait.cpp | 44 +++++++++++++++++++++ 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/doc/modules/ROOT/pages/4.guide/4r.wait.adoc b/doc/modules/ROOT/pages/4.guide/4r.wait.adoc index f08f19ad6..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 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, From be01ecb926dee3fc4e0066cd957705a84b88e8c3 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Wed, 19 Aug 2026 17:07:24 +0200 Subject: [PATCH 12/14] fix(reactor): don't latch cancels for waits outside their slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cancel reaching a wait after dispatch claimed it — or before it parked — latched a descriptor flag that outlived the op and canceled the next wait in the same direction under a fresh token. The latch was redundant: request_cancel() already marks the op, register_op consults that mark before parking, and delivery decodes it. --- .../detail/reactor/reactor_basic_socket.hpp | 11 ++- test/unit/wait.cpp | 78 +++++++++++++++++++ 2 files changed, 83 insertions(+), 6 deletions(-) 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 7d166f610..388dbd83d 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_basic_socket.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_basic_socket.hpp @@ -242,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/test/unit/wait.cpp b/test/unit/wait.cpp index 3c4daf123..2cc302151 100644 --- a/test/unit/wait.cpp +++ b/test/unit/wait.cpp @@ -392,6 +392,83 @@ struct wait_test 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() { @@ -848,6 +925,7 @@ struct wait_test testWaitWriteReady(); testWaitWriteParksUntilDrained(); testWaitWriteCancel(); + testWaitWriteCancelDoesNotLeak(); testAcceptorWait(); testWaitOnLocalStream(); testWaitOnUdp(); From 752de1f1437deea03aaf0818e091c95c7ede6884 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Wed, 19 Aug 2026 17:07:29 +0200 Subject: [PATCH 13/14] fix(acceptor): keep re-listen from forking or resurrecting accepts A second listen() re-registered the descriptor (failing on epoll) and on io_uring retired a live multishot arming without cancelling it, leaving two armings with the retired side closing its deliveries. Re-listen is now a backlog change only: registration and arming are skipped when already live. The listen path also purges deliveries a released descriptor left queued, as the adoption path always did. --- .../io_uring/io_uring_multishot_acceptor.hpp | 37 ++++- .../native/detail/io_uring/io_uring_types.hpp | 8 +- .../detail/reactor/reactor_acceptor.hpp | 5 + test/unit/tcp_acceptor.cpp | 136 ++++++++++++++++++ 4 files changed, 178 insertions(+), 8 deletions(-) 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 3dbd8bc4d..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 @@ -117,7 +117,10 @@ class io_uring_multishot_acceptor_base /// 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. + /// `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: @@ -441,11 +444,37 @@ class io_uring_multishot_acceptor_base arming still owed a terminal CQE, which a fresh submission would alias by `user_data`. */ - void prepare_listen_arm() noexcept + /** 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(); - std::lock_guard lk(mutex_); - closing_ = false; + 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() 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 00952026d..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 @@ -939,8 +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.prepare_listen_arm(); - acc.start_multishot(); + if (acc.prepare_listen_arm()) + acc.start_multishot(); return {}; } @@ -1725,8 +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.prepare_listen_arm(); - acc.start_multishot(); + if (acc.prepare_listen_arm()) + acc.start_multishot(); return {}; } diff --git a/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp b/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp index 995681e07..096fc8bee 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp @@ -506,6 +506,11 @@ reactor_acceptor 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() { @@ -1606,6 +1740,8 @@ struct tcp_acceptor_test testAssignOverListeningAcceptor(); testAssignAfterRelease(); testListenAfterRelease(); + testListenTwiceKeepsAccepting(); + testReleaseDropsPreacceptedConnections(); testAssignRejections(); testRelease(); testReleaseClosedThrows(); From 91c7848345a06b00ff8ad04fdec99257994df732 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Wed, 19 Aug 2026 17:07:30 +0200 Subject: [PATCH 14/14] fix(iocp): report adoption rejections with portable conditions The reactor backends reject with errno values every runtime maps to generic conditions; the Windows WSA equivalents map on some runtimes only. Normalize the adoption-contract codes the way the wait contract's already are, and pin the wrong-type rejection code in the tests. --- include/boost/corosio/native/detail/make_err.hpp | 12 ++++++++++-- test/unit/tcp_socket.cpp | 14 +++++++++++++- test/unit/udp_socket.cpp | 14 +++++++++++++- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/include/boost/corosio/native/detail/make_err.hpp b/include/boost/corosio/native/detail/make_err.hpp index 9d00d9149..68c45a952 100644 --- a/include/boost/corosio/native/detail/make_err.hpp +++ b/include/boost/corosio/native/detail/make_err.hpp @@ -82,10 +82,18 @@ make_err(unsigned long dwError) noexcept if (dwError == ERROR_HANDLE_EOF) return capy::error::eof; - // Part of the portable wait contract; system_category's condition - // mapping for WSA codes varies by toolchain. + // 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/test/unit/tcp_socket.cpp b/test/unit/tcp_socket.cpp index 97ba84277..1f87d3cb8 100644 --- a/test/unit/tcp_socket.cpp +++ b/test/unit/tcp_socket.cpp @@ -2190,7 +2190,19 @@ struct tcp_socket_test auto dg = make_native_socket(AF_INET, SOCK_DGRAM); BOOST_TEST(dg != invalid_native_socket); - expect_throw(dg); + { + // 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); diff --git a/test/unit/udp_socket.cpp b/test/unit/udp_socket.cpp index 6300d0afd..9adcd863d 100644 --- a/test/unit/udp_socket.cpp +++ b/test/unit/udp_socket.cpp @@ -1382,7 +1382,19 @@ struct udp_socket_test auto st = make_native_socket(AF_INET, SOCK_STREAM); BOOST_TEST(st != invalid_native_socket); - expect_throw(st); + { + // 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);