From 4369861dd2b8447eb510529b827b4e57e6733147 Mon Sep 17 00:00:00 2001 From: TheLingeringWill Date: Sat, 14 Nov 2026 02:40:20 +0100 Subject: [PATCH 1/6] perf: read accepted IOCP TCP endpoints from the AcceptEx buffer Each accept asked the kernel for both endpoints of the new connection, one getsockname and one getpeername, although AcceptEx had already written both addresses into the buffer it was given. The tcp service now looks up GetAcceptExSockaddrs next to AcceptEx and the accept parses the two addresses out of that buffer, which takes no syscall. A provider that does not hand out GetAcceptExSockaddrs leaves the accept asking the kernel as before. --- .../native/detail/iocp/win_tcp_acceptor.hpp | 3 + .../detail/iocp/win_tcp_acceptor_service.hpp | 61 ++++++++-- .../native/detail/iocp/win_tcp_service.hpp | 15 +++ test/unit/fault/iocp_faults.cpp | 111 +++++++++++++++++- 4 files changed, 175 insertions(+), 15 deletions(-) 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 46e70da69..133e639bb 100644 --- a/include/boost/corosio/native/detail/iocp/win_tcp_acceptor.hpp +++ b/include/boost/corosio/native/detail/iocp/win_tcp_acceptor.hpp @@ -46,6 +46,9 @@ struct accept_op : overlapped_op SOCKET listen_socket = INVALID_SOCKET; io_object::implementation** impl_out = nullptr; char addr_buf[2 * (sizeof(sockaddr_in6) + 16)]; + // The size AcceptEx was given for each address in addr_buf, which + // GetAcceptExSockaddrs needs to find them again. + DWORD addr_len = 0; static void do_complete( void* owner, 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 aff263575..f0a95c7d8 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 @@ -33,6 +33,7 @@ #include +#include #include namespace boost::corosio::detail { @@ -120,6 +121,18 @@ acceptor_wait_op::do_cancel_impl(overlapped_op* base) noexcept op->acceptor.socket_service().scheduler().cancel_wait(op); } +// An address GetAcceptExSockaddrs pointed into the AcceptEx buffer, +// as an endpoint; one it could not find stays the default endpoint. +inline endpoint +accept_ex_endpoint(sockaddr const* sa, int len) noexcept +{ + sockaddr_storage storage{}; + if (!sa || len <= 0 || len > static_cast(sizeof(storage))) + return {}; + std::memcpy(&storage, sa, static_cast(len)); + return from_sockaddr(storage); +} + // accept_op completion handler inline void @@ -172,20 +185,41 @@ accept_op::do_complete( op->peer_wrapper->set_socket(op->accepted_socket); - sockaddr_storage local_storage{}; - int local_len = sizeof(local_storage); - sockaddr_storage remote_storage{}; - int remote_len = sizeof(remote_storage); - endpoint local_ep, remote_ep; - if (::getsockname( - op->accepted_socket, - reinterpret_cast(&local_storage), &local_len) == 0) - local_ep = from_sockaddr(local_storage); - if (::getpeername( - op->accepted_socket, - reinterpret_cast(&remote_storage), &remote_len) == 0) - remote_ep = from_sockaddr(remote_storage); + if (auto parse = + op->acceptor.socket_service().get_accept_ex_sockaddrs()) + { + // AcceptEx already wrote both addresses into addr_buf, so + // reading them back costs no syscall, where getsockname and + // getpeername cost one each on every accept. + sockaddr* local_sa = nullptr; + sockaddr* remote_sa = nullptr; + int local_len = 0; + int remote_len = 0; + parse( + op->addr_buf, 0, op->addr_len, op->addr_len, &local_sa, + &local_len, &remote_sa, &remote_len); + local_ep = accept_ex_endpoint(local_sa, local_len); + remote_ep = accept_ex_endpoint(remote_sa, remote_len); + } + else + { + sockaddr_storage local_storage{}; + int local_len = sizeof(local_storage); + sockaddr_storage remote_storage{}; + int remote_len = sizeof(remote_storage); + + if (::getsockname( + op->accepted_socket, + reinterpret_cast(&local_storage), + &local_len) == 0) + local_ep = from_sockaddr(local_storage); + if (::getpeername( + op->accepted_socket, + reinterpret_cast(&remote_storage), + &remote_len) == 0) + remote_ep = from_sockaddr(remote_storage); + } op->peer_wrapper->set_endpoints(local_ep, remote_ep); op->accepted_socket = INVALID_SOCKET; @@ -369,6 +403,7 @@ win_tcp_acceptor::accept( // AcceptEx address buffer sizes must match the socket's address family DWORD addr_size = static_cast( (af == AF_INET6 ? sizeof(sockaddr_in6) : sizeof(sockaddr_in)) + 16); + op.addr_len = addr_size; DWORD bytes_received = 0; BOOL ok = accept_ex( 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 fb84613a2..6a2f26a58 100644 --- a/include/boost/corosio/native/detail/iocp/win_tcp_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_tcp_service.hpp @@ -181,6 +181,12 @@ class BOOST_COROSIO_DECL win_tcp_service final return accept_ex_; } + /** Return the GetAcceptExSockaddrs function pointer, or null. */ + LPFN_GETACCEPTEXSOCKADDRS get_accept_ex_sockaddrs() const noexcept + { + return get_accept_ex_sockaddrs_; + } + /** Post an overlapped operation for completion. */ void post(overlapped_op* op); @@ -209,6 +215,9 @@ class BOOST_COROSIO_DECL win_tcp_service final void* iocp_; LPFN_CONNECTEX connect_ex_ = nullptr; LPFN_ACCEPTEX accept_ex_ = nullptr; + // Optional: an accept parses its endpoints out of the AcceptEx + // buffer with it, and asks the kernel for them without it. + LPFN_GETACCEPTEXSOCKADDRS get_accept_ex_sockaddrs_ = nullptr; BOOST_COROSIO_MSVC_WARNING_PUSH BOOST_COROSIO_MSVC_WARNING_DISABLE(4251) // detail:: members, dll-interface object_pool pool_; @@ -1105,6 +1114,12 @@ win_tcp_service::load_extension_functions() sizeof(accept_ex_guid), &accept_ex_, sizeof(accept_ex_), &bytes, nullptr, nullptr); + GUID sockaddrs_guid = WSAID_GETACCEPTEXSOCKADDRS; + ::WSAIoctl( + sock, SIO_GET_EXTENSION_FUNCTION_POINTER, &sockaddrs_guid, + sizeof(sockaddrs_guid), &get_accept_ex_sockaddrs_, + sizeof(get_accept_ex_sockaddrs_), &bytes, nullptr, nullptr); + ::closesocket(sock); } diff --git a/test/unit/fault/iocp_faults.cpp b/test/unit/fault/iocp_faults.cpp index 95b2c51bc..1091e42c5 100644 --- a/test/unit/fault/iocp_faults.cpp +++ b/test/unit/fault/iocp_faults.cpp @@ -908,6 +908,111 @@ struct iocp_faults BOOST_TEST(compec == std::errc::connection_aborted); } + // An accept reads both endpoints out of the buffer AcceptEx filled, + // so it asks the kernel for neither. + void testAcceptReadsAcceptExAddresses() + { + if (!hook_is_live(sys::getsockname) || !hook_is_live(sys::getpeername)) + { + skip_dead_hook("getsockname/getpeername"); + return; + } + io_context ioc(iocp); + tcp_acceptor acc(ioc, loopback()); + auto const ep = + endpoint(ipv4_address::loopback(), acc.local_endpoint().port()); + tcp_socket client(ioc); + tcp_socket server(ioc); + std::error_code cec, aec; + unsigned names = 1, peers = 1; + endpoint client_local, local, remote; + bool expired = false; + auto body = [&]() -> capy::task<> { + // The connection waits in the backlog, so the counted window + // holds the accept alone. + auto [ec] = co_await client.connect(ep); + cec = ec; + client_local = client.local_endpoint(); + // Count this thread's calls; the ones they would fail are + // never reached. + fault_scope n(sys::getsockname, WSAEFAULT, 1u << 30); + fault_scope p(sys::getpeername, WSAEFAULT, 1u << 30); + auto [aec2] = co_await acc.accept(server); + aec = aec2; + names = n.count(); + peers = p.count(); + local = server.local_endpoint(); + remote = server.remote_endpoint(); + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + // An accept that never completes reads as a failure rather than + // as a job timeout. + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(!cec); + BOOST_TEST(!aec); + BOOST_TEST_EQ(names, 0u); + BOOST_TEST_EQ(peers, 0u); + BOOST_TEST(local == ep); + BOOST_TEST(remote == client_local); + BOOST_TEST(remote.port() != 0); + } + + // Without GetAcceptExSockaddrs an accept still learns both endpoints, + // by asking the kernel for them. + void testAcceptWithoutSockaddrsParser() + { + if (!hook_is_live(sys::getsockname) || !hook_is_live(sys::getpeername)) + { + skip_dead_hook("getsockname/getpeername"); + return; + } + // The tcp service looks up ConnectEx, AcceptEx and then + // GetAcceptExSockaddrs when the first tcp object is created, so + // the arm precedes the acceptor and fails the third lookup. + fault_scope f(sys::WSAIoctl, WSAEOPNOTSUPP, 3); + io_context ioc(iocp); + tcp_acceptor acc(ioc, loopback()); + BOOST_TEST(f.fired()); + auto const ep = + endpoint(ipv4_address::loopback(), acc.local_endpoint().port()); + tcp_socket client(ioc); + tcp_socket server(ioc); + std::error_code cec, aec; + unsigned names = 0, peers = 0; + endpoint client_local, local, remote; + bool expired = false; + auto body = [&]() -> capy::task<> { + auto [ec] = co_await client.connect(ep); + cec = ec; + client_local = client.local_endpoint(); + // Count this thread's calls, which show the fallback ran; + // the ones they would fail are never reached. + fault_scope n(sys::getsockname, WSAEFAULT, 1u << 30); + fault_scope p(sys::getpeername, WSAEFAULT, 1u << 30); + auto [aec2] = co_await acc.accept(server); + aec = aec2; + names = n.count(); + peers = p.count(); + local = server.local_endpoint(); + remote = server.remote_endpoint(); + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(!cec); + BOOST_TEST(!aec); + BOOST_TEST_EQ(names, 1u); + BOOST_TEST_EQ(peers, 1u); + BOOST_TEST(local == ep); + BOOST_TEST(remote == client_local); + BOOST_TEST(remote.port() != 0); + } + void testUdpSetupFails() { io_context ioc(iocp); @@ -1896,8 +2001,8 @@ struct iocp_faults /* A context whose extension-pointer bootstrap never ran. win_tcp_service::load_extension_functions creates one socket to - ask Winsock for ConnectEx and AcceptEx; failing that socket - leaves both pointers null for the whole + ask Winsock for ConnectEx, AcceptEx and GetAcceptExSockaddrs; + failing that socket leaves all three pointers null for the whole context. That is the one arm that reaches all three call sites which refuse an operation because the pointer is missing -- testTcpExtensionPointerMissing fails the query instead and @@ -2143,6 +2248,8 @@ struct iocp_faults testAcceptorOptionsFail(); testAcceptorAssignFails(); testAcceptFails(); + testAcceptReadsAcceptExAddresses(); + testAcceptWithoutSockaddrsParser(); testExtensionPointersMissing(); testUdpSetupFails(); testUdpIoFails(); From 1c4d22e1381b1705f07b3d054d529f6741b04f37 Mon Sep 17 00:00:00 2001 From: TheLingeringWill Date: Sat, 14 Nov 2026 02:41:36 +0100 Subject: [PATCH 2/6] perf: fetch the local endpoint on demand after an IOCP TCP connect A successful connect called getsockname to cache a local endpoint that most connections never read. It now marks the endpoint pending, and the first local_endpoint() call fetches and caches it, as the io_uring backend does since it stopped fetching it eagerly. One reader claims the fill and a reader racing it fetches its own copy, so concurrent calls never read the cache while it is being written. A fetch that fails caches nothing, and the next call asks again. ConnectEx needs a bound socket, and an endpoint left unfetched says the socket most likely is one. The mark can outlive the socket it described when a late completion races a close and reopen, so a connect over it tries the bind anyway and lets an "already bound" refusal through to ConnectEx. A second connect on a connected socket still fails as it did before, at ConnectEx. The local_endpoint() documentation now says the endpoint may be cached by the first call after a connect rather than at the connect. --- .../native/detail/iocp/win_tcp_service.hpp | 65 +++++++-- .../native/detail/iocp/win_tcp_socket.hpp | 18 ++- include/boost/corosio/tcp_socket.hpp | 12 +- test/unit/fault/iocp_faults.cpp | 136 ++++++++++++++++++ 4 files changed, 211 insertions(+), 20 deletions(-) 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 6a2f26a58..915383569 100644 --- a/include/boost/corosio/native/detail/iocp/win_tcp_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_tcp_service.hpp @@ -333,14 +333,12 @@ connect_op::do_complete( op->internal.socket_, SOL_SOCKET, SO_UPDATE_CONNECT_CONTEXT, nullptr, 0); - endpoint local_ep; - sockaddr_storage local_storage{}; - int local_len = sizeof(local_storage); - if (::getsockname( - op->internal.socket_, - reinterpret_cast(&local_storage), &local_len) == 0) - local_ep = from_sockaddr(local_storage); - op->internal.set_endpoints(local_ep, op->target_endpoint); + // Most connections never read their local endpoint, so it is + // left for local_endpoint() to fetch rather than costing every + // connect a getsockname. + op->internal.remote_endpoint_ = op->target_endpoint; + op->internal.local_state_.store( + win_tcp_socket::local_state::pending, std::memory_order_release); } auto prevent_premature_destruction = std::move(op->object_ref_); @@ -441,6 +439,7 @@ win_tcp_socket::reuse() noexcept // can still have written these. local_endpoint_ = endpoint{}; remote_endpoint_ = endpoint{}; + local_state_.store(local_state::cached, std::memory_order_relaxed); BOOST_COROSIO_ASSERT(!conn_.stop_cb); BOOST_COROSIO_ASSERT(!rd_.stop_cb); BOOST_COROSIO_ASSERT(!wr_.stop_cb); @@ -458,6 +457,7 @@ win_tcp_socket::set_endpoints(endpoint local, endpoint remote) noexcept { local_endpoint_ = local; remote_endpoint_ = remote; + local_state_.store(local_state::cached, std::memory_order_release); } inline std::coroutine_handle<> @@ -481,9 +481,15 @@ win_tcp_socket::connect( svc_.work_started(); - // ConnectEx requires the socket to be bound. Skip if already bound - // (e.g. the caller used tcp_socket::bind() before connect). - if (local_endpoint_ == endpoint{}) + // ConnectEx requires the socket to be bound. A cache that names an + // endpoint says it is (e.g. tcp_socket::bind() before connect). An + // endpoint an earlier connect left unfetched says it most likely + // is, but that mark can outlive the socket it described when a late + // completion races a close and reopen, so the bind is tried anyway + // and an "already bound" refusal leaves the verdict to ConnectEx. + bool const unfetched = + local_state_.load(std::memory_order_acquire) != local_state::cached; + if (unfetched || local_endpoint_ == endpoint{}) { sockaddr_storage bind_storage{}; socklen_t bind_len; @@ -510,8 +516,12 @@ win_tcp_socket::connect( socket_, reinterpret_cast(&bind_storage), bind_len) == SOCKET_ERROR) { - svc_.on_completion(&op, ::WSAGetLastError(), 0); - return std::noop_coroutine(); + int const err = ::WSAGetLastError(); + if (!unfetched || err != WSAEINVAL) + { + svc_.on_completion(&op, static_cast(err), 0); + return std::noop_coroutine(); + } } } @@ -816,6 +826,7 @@ win_tcp_socket::close_socket() noexcept // Clear cached endpoints local_endpoint_ = endpoint{}; remote_endpoint_ = endpoint{}; + local_state_.store(local_state::cached, std::memory_order_release); } inline std::error_code @@ -862,6 +873,7 @@ win_tcp_socket::release_socket() noexcept family_ = AF_UNSPEC; local_endpoint_ = endpoint{}; remote_endpoint_ = endpoint{}; + local_state_.store(local_state::cached, std::memory_order_release); } return static_cast(s); } @@ -892,7 +904,30 @@ win_tcp_socket::get_option( inline endpoint win_tcp_socket::local_endpoint() const noexcept { - return local_endpoint_; + auto state = local_state_.load(std::memory_order_acquire); + if (state == local_state::cached) + return local_endpoint_; + + // A connect left the endpoint for the first read to fetch. One + // reader claims the cache and fills it; a reader racing it asks the + // kernel for its own copy rather than read the cache mid-write. A + // fetch that fails caches nothing, so the next read asks again. + sockaddr_storage storage{}; + int len = sizeof(storage); + if (socket_ == INVALID_SOCKET || + ::getsockname(socket_, reinterpret_cast(&storage), &len) != + 0) + return endpoint{}; + endpoint const ep = from_sockaddr(storage); + if (state == local_state::pending && + local_state_.compare_exchange_strong( + state, local_state::fetching, std::memory_order_acquire, + std::memory_order_relaxed)) + { + local_endpoint_ = ep; + local_state_.store(local_state::cached, std::memory_order_release); + } + return ep; } inline endpoint @@ -1050,6 +1085,8 @@ win_tcp_service::bind_socket(win_tcp_socket& impl, endpoint ep) if (::getsockname( sock, reinterpret_cast(&local_storage), &local_len) == 0) impl.local_endpoint_ = detail::from_sockaddr(local_storage); + impl.local_state_.store( + win_tcp_socket::local_state::cached, std::memory_order_release); return {}; } 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 a97787da0..749af7a1e 100644 --- a/include/boost/corosio/native/detail/iocp/win_tcp_socket.hpp +++ b/include/boost/corosio/native/detail/iocp/win_tcp_socket.hpp @@ -27,6 +27,7 @@ #include #include +#include #include #include @@ -144,8 +145,23 @@ class win_tcp_socket final wait_op wt_; SOCKET socket_ = INVALID_SOCKET; int family_ = AF_UNSPEC; - endpoint local_endpoint_; + // What local_endpoint_ holds. `cached`: it is authoritative, and + // empty for a socket with no known endpoint. `pending`: a connect + // left the endpoint unfetched, and the first local_endpoint() asks + // the kernel for it. `fetching`: the reader that claimed the fetch + // is writing the cache, and other readers fetch their own copy + // meanwhile. Whatever else writes local_endpoint_ sets `cached`. + enum class local_state : unsigned char + { + cached, + pending, + fetching + }; + + // Mutable so local_endpoint() can fill it on first read. + mutable endpoint local_endpoint_; endpoint remote_endpoint_; + mutable std::atomic local_state_{local_state::cached}; public: explicit win_tcp_socket(win_tcp_service& svc) noexcept; diff --git a/include/boost/corosio/tcp_socket.hpp b/include/boost/corosio/tcp_socket.hpp index 05f5e01cd..9ad4ecce8 100644 --- a/include/boost/corosio/tcp_socket.hpp +++ b/include/boost/corosio/tcp_socket.hpp @@ -602,16 +602,18 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream Returns the local address and port to which the socket is bound. For a connected socket, this is the local side of the connection. - The endpoint is cached when the connection is established. + The endpoint is cached when the connection is established, or by + the first call after it. @return The local endpoint, or a default endpoint (0.0.0.0:0) if the socket is not connected. @par Thread Safety - The cached endpoint value is set during connect/accept completion - and cleared during close(). This function may be called concurrently - with I/O operations, but must not be called concurrently with - connect(), accept(), or close(). + The cached endpoint value is set during connect/accept completion, + or by the first call after it, and cleared during close(). + This function may be called concurrently with I/O operations, but + must not be called concurrently with connect(), accept(), or + close(). */ endpoint local_endpoint() const noexcept; diff --git a/test/unit/fault/iocp_faults.cpp b/test/unit/fault/iocp_faults.cpp index 1091e42c5..515fcac5b 100644 --- a/test/unit/fault/iocp_faults.cpp +++ b/test/unit/fault/iocp_faults.cpp @@ -28,6 +28,7 @@ #include "temp_path.hpp" #include #include +#include #include #include #include @@ -1013,6 +1014,138 @@ struct iocp_faults BOOST_TEST(remote.port() != 0); } + // A connect leaves the local endpoint for the first read of it, so + // a connection that never asks costs no getsockname. + void testConnectDefersLocalEndpoint() + { + if (!hook_is_live(sys::getsockname)) + { + skip_dead_hook("getsockname"); + return; + } + io_context ioc(iocp); + tcp_acceptor acc(ioc, loopback()); + auto const ep = + endpoint(ipv4_address::loopback(), acc.local_endpoint().port()); + tcp_socket c(ioc); + BOOST_TEST(!c.open(family::v4)); + std::error_code cec; + unsigned at_connect = 1, at_read = 0, at_reread = 0; + endpoint local; + auto body = [&]() -> capy::task<> { + // Counts this thread's calls; the one it would fail is never + // reached. + fault_scope f(sys::getsockname, WSAEFAULT, 1u << 30); + auto [ec] = co_await c.connect(ep); + cec = ec; + at_connect = f.count(); + local = c.local_endpoint(); + at_read = f.count(); + std::ignore = c.local_endpoint(); + at_reread = f.count(); + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(!cec); + BOOST_TEST_EQ(at_connect, 0u); + BOOST_TEST_EQ(at_read, 1u); + BOOST_TEST_EQ(at_reread, 1u); + BOOST_TEST(local.address() == ep.address()); + BOOST_TEST(local.port() != 0); + BOOST_TEST(c.remote_endpoint() == ep); + } + + // A second connect on a connected socket whose local endpoint was + // never read still tries the bind ConnectEx needs. Winsock answers + // that the socket is already bound, and the connect fails at + // ConnectEx, as it did when the endpoint was fetched eagerly. + void testSecondConnectFailsAtConnectEx() + { + if (!hook_is_live(sys::bind)) + { + skip_dead_hook("bind"); + return; + } + io_context ioc(iocp); + tcp_acceptor acc(ioc, loopback()); + auto const ep = + endpoint(ipv4_address::loopback(), acc.local_endpoint().port()); + tcp_socket c(ioc); + BOOST_TEST(!c.open(family::v4)); + std::error_code first, second; + unsigned binds = 0; + auto body = [&]() -> capy::task<> { + auto [ec1] = co_await c.connect(ep); + first = ec1; + // Counts this thread's calls; the one it would fail is never + // reached. + fault_scope b(sys::bind, WSAEFAULT, 1u << 30); + auto [ec2] = co_await c.connect(ep); + second = ec2; + binds = b.count(); + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(!first); + BOOST_TEST_EQ(binds, 1u); + BOOST_TEST(second == win_err(WSAEISCONN)); + BOOST_TEST(c.local_endpoint().port() != 0); + } + + // A local endpoint a connect left unfetched is read after the + // connection is gone as well as while it lasts: the first read, + // made after the peer reset it or after both sides closed it, + // returns the endpoint the peer saw the connection come from. + void testDeferredLocalEndpointOutlivesConnection() + { + auto check = [](bool abort) { + io_context ioc(iocp); + tcp_acceptor acc(ioc, loopback()); + auto const ep = + endpoint(ipv4_address::loopback(), acc.local_endpoint().port()); + tcp_socket c(ioc); + tcp_socket s(ioc); + BOOST_TEST(!c.open(family::v4)); + std::error_code cec, aec, rec; + endpoint seen_by_peer, local; + bool expired = false; + auto body = [&]() -> capy::task<> { + auto [ec1] = co_await c.connect(ep); + cec = ec1; + auto [ec2] = co_await acc.accept(s); + aec = ec2; + seen_by_peer = s.remote_endpoint(); + // A zero linger turns the close into a reset. + if (abort) + must_ok(s.set_option(socket_option::linger(true, 0))); + s.close(); + char buf[8] = {}; + auto [ec3, n] = co_await c.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rec = ec3; + if (!abort) + std::ignore = c.shutdown(tcp_socket::shutdown_send); + local = c.local_endpoint(); + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(!cec); + BOOST_TEST(!aec); + if (abort) + BOOST_TEST(rec == std::errc::connection_reset); + else + BOOST_TEST(rec == capy::cond::eof); + BOOST_TEST(seen_by_peer.port() != 0); + BOOST_TEST(local == seen_by_peer); + }; + check(true); + check(false); + } + void testUdpSetupFails() { io_context ioc(iocp); @@ -2250,6 +2383,9 @@ struct iocp_faults testAcceptFails(); testAcceptReadsAcceptExAddresses(); testAcceptWithoutSockaddrsParser(); + testConnectDefersLocalEndpoint(); + testSecondConnectFailsAtConnectEx(); + testDeferredLocalEndpointOutlivesConnection(); testExtensionPointersMissing(); testUdpSetupFails(); testUdpIoFails(); From 64c94dbf6d33f434f31e24229e2f34bfd8eff254 Mon Sep 17 00:00:00 2001 From: TheLingeringWill Date: Sat, 14 Nov 2026 02:44:06 +0100 Subject: [PATCH 3/6] perf: ask the IOCP wait reactor to cancel only waits it holds Every socket close and cancel asked the wait reactor to drop the socket's wait op, parked or not, and every open() closes first. Once the polling thread exists, each ask took the reactor lock, sent a byte down the wakeup channel, and made the thread wake, search its table and rebuild its poll set, for a socket that in nearly every case had nothing parked. Ops now carry a flag the reactor sets when a register queues them and clears before it completes them. A cancel reads it under the reactor lock and returns when it is clear: the op is not held, so there is nothing to drop. A cancel that comes before the register is still caught there by the op's cancelled flag, and one that races the reactor letting go of the op is dropped by the table search as before. Two fault tests used a cancel with nothing parked as a wake that always reaches the polling thread. testWakeSendFails now parks its wait again after every cancel, and testWaitReactorPollFails wakes the thread with a register. --- .../native/detail/iocp/win_overlapped_op.hpp | 7 ++ .../native/detail/iocp/win_wait_reactor.hpp | 35 ++++++-- test/unit/fault/iocp_faults.cpp | 87 ++++++++++++++++--- test/unit/iocp_paths.cpp | 11 ++- 4 files changed, 114 insertions(+), 26 deletions(-) diff --git a/include/boost/corosio/native/detail/iocp/win_overlapped_op.hpp b/include/boost/corosio/native/detail/iocp/win_overlapped_op.hpp index cb225a6a5..cedb1b696 100644 --- a/include/boost/corosio/native/detail/iocp/win_overlapped_op.hpp +++ b/include/boost/corosio/native/detail/iocp/win_overlapped_op.hpp @@ -171,6 +171,13 @@ struct overlapped_op DWORD bytes_transferred = 0; cancel_func_type cancel_func_ = nullptr; + /** Whether the wait reactor holds this op. Set under the reactor's + lock when a register queues the op, cleared by the reactor before + it completes the op, and read under that lock by a cancel, which + has nothing to ask the reactor for while it is clear. reset() + leaves it alone: only the reactor knows when it lets go. */ + std::atomic reactor_parked_{false}; + explicit overlapped_op(func_type func) noexcept : coro_op(func) { reset_overlapped(); 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 17f728763..daee9608f 100644 --- a/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp +++ b/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp @@ -66,9 +66,10 @@ namespace boost::corosio::detail { wait completes with the SO_ERROR value when it is set, or WSAECONNABORTED as a synthesized fallback when it is zero. 4. On socket cancel(), the user's thread calls cancel_wait(op), - which queues a cancel request. The reactor thread removes the - op from the table and posts a completion; invoke_handler sees - op.cancelled==true and yields capy::cond::canceled. + which queues a cancel request if the reactor holds the op. The + reactor thread removes the op from the table and posts a + completion; invoke_handler sees op.cancelled==true and yields + capy::cond::canceled. A poll the provider refuses on account of one descriptor answers that descriptor's op with the refusal and leaves the rest of the @@ -404,6 +405,7 @@ win_wait_reactor::queue_register(entry const& e) } pending_register_.push_back(e); + e.op->reactor_parked_.store(true, std::memory_order_relaxed); return 0; } @@ -424,6 +426,14 @@ win_wait_reactor::cancel_wait(overlapped_op* op) // close asks, and the queue would only grow. if (!thread_.joinable()) return; + // Nor is there anything to ask for an op the reactor does not + // hold, which is every close of a socket with no wait parked: + // waking the polling thread for it would cost a send, a poll + // and a search of the table. A register queued under this lock + // set the flag, and the reactor clears it only once it has let + // the op go. + if (!op->reactor_parked_.load(std::memory_order_acquire)) + return; pending_cancel_.push_back(op); } wake_self(); @@ -466,7 +476,9 @@ win_wait_reactor::drop_refused_entries() if (::WSAPoll(&pfd, 1, 0) != SOCKET_ERROR) continue; - sched_.on_completion(e.op, last_error(), 0); + DWORD const err = last_error(); + e.op->reactor_parked_.store(false, std::memory_order_release); + sched_.on_completion(e.op, err, 0); registered_.erase(registered_.begin() + (i - 1)); dropped = true; } @@ -507,11 +519,11 @@ win_wait_reactor::run() for (auto* op : to_cancel) { - // A socket's close() and cancel() ask the reactor to drop - // their wait op whether or not one is parked -- open() goes - // through close_socket() before it has a socket at all. - // Only a parked op pins its owner, so an op that is not in - // registered_ may belong to an object already destroyed: + // An ask can reach here for an op that is no longer parked: + // cancel_wait queues it while the flag is still set, and + // the op may fire or be cancelled before this pass reads + // it. Only a parked op pins its owner, so an op that is not + // in registered_ may belong to an object already destroyed: // look it up before touching it. auto it = std::find_if( registered_.begin(), registered_.end(), @@ -528,6 +540,7 @@ win_wait_reactor::run() { // The op's cancelled flag has already been set by // request_cancel; invoke_handler will translate it. + op->reactor_parked_.store(false, std::memory_order_release); sched_.on_completion(op, 0, 0); registered_.erase(it); } @@ -617,6 +630,7 @@ win_wait_reactor::run() } } + e.op->reactor_parked_.store(false, std::memory_order_release); sched_.on_completion(e.op, err, 0); registered_.erase(registered_.begin() + (i - 2)); } @@ -640,7 +654,10 @@ win_wait_reactor::run() pending_cancel_.clear(); } for (auto& e : registered_) + { + e.op->reactor_parked_.store(false, std::memory_order_release); sched_.on_completion(e.op, drain_err, 0); + } registered_.clear(); } diff --git a/test/unit/fault/iocp_faults.cpp b/test/unit/fault/iocp_faults.cpp index 515fcac5b..5abefd6b2 100644 --- a/test/unit/fault/iocp_faults.cpp +++ b/test/unit/fault/iocp_faults.cpp @@ -1436,6 +1436,53 @@ struct iocp_faults s2.close(); } + // Once the polling thread runs, a close or cancel with no wait + // parked leaves it asleep: nothing goes down its wakeup channel. + void testIdleReactorNotWokenByClose() + { + if (!hook_is_live(sys::send)) + { + skip_dead_hook("send"); + return; + } + io_context ioc(iocp); + auto [s1, s2] = must(make_socket_pair(ioc)); + std::error_code wec; + unsigned sends = 1; + bool done = false; + bool expired = false; + auto body = [&]() -> capy::task<> { + // A healthy pair is writable at once, so this wait starts the + // polling thread and leaves nothing parked once it returns. + auto [ec] = co_await s1.wait(wait_type::write); + wec = ec; + { + // Counts this thread's calls; the one it would fail is + // never reached. + fault_scope f(sys::send, WSAENOBUFS, 1u << 30); + for (int i = 0; i < 4; ++i) + { + tcp_socket t(ioc); + BOOST_TEST(!t.open(family::v4)); + t.close(); + } + s1.cancel(); + sends = f.count(); + } + done = true; + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(done); + BOOST_TEST(!wec); + BOOST_TEST_EQ(sends, 0u); + s1.close(); + s2.close(); + } + /* A wake that never left has to be retried, not swallowed. `wake_pending_` is set before the send and stands for a byte in @@ -1444,9 +1491,11 @@ struct iocp_faults coalesces into that phantom, and the poll they were meant to end is indefinite. - A parked wait starts the polling thread, so the cancels below - have a thread to wake. A cancel only sends once the byte already - in the channel is drained, so they repeat until two sends have + A parked wait starts the polling thread, and the waiter parks + again after every cancel, so each cancel below finds a wait the + reactor holds -- a cancel with nothing parked wakes nobody -- and + a thread to wake. A wake only sends once the byte already in the + channel is drained, so the cancels repeat until two sends have failed; a latch would coalesce every later wake into the phantom, and the final cancel, sent with nothing failing, would never reach the thread. @@ -1462,12 +1511,18 @@ struct iocp_faults io_context ioc(iocp); auto [s1, s2] = must(make_socket_pair(ioc)); std::error_code parked_ec; - bool parked_done = false; - bool expired = false; - bool fired = false; - auto parked = [&]() -> capy::task<> { - auto [ec] = co_await s1.wait(wait_type::error); - parked_ec = ec; + bool parked_done = false; + bool stop_parking = false; + bool expired = false; + bool fired = false; + auto parked = [&]() -> capy::task<> { + for (;;) + { + auto [ec] = co_await s1.wait(wait_type::error); + parked_ec = ec; + if (stop_parking) + break; + } parked_done = true; }; auto breaker = [&]() -> capy::task<> { @@ -1485,6 +1540,7 @@ struct iocp_faults } fired = first.fired() && second.fired(); } + stop_parking = true; s1.cancel(); for (int i = 0; i < 100 && !parked_done; ++i) std::ignore = @@ -1551,10 +1607,14 @@ struct iocp_faults // the way out covers registered_ and pending_register_ // alike. arm.emplace(sys::WSAPoll, WSAENOBUFS, 1u, any_thread); - // A cancel for an op the reactor never registered is a - // no-op that still pokes the self-pipe. - s2.cancel(); - co_return; + // A register wakes the polling thread, by its own byte or one + // already in the channel, where a cancel with nothing parked + // would not. A thread blocked in a poll begun before the arm + // polls again and fails holding this wait, which is drained + // with the rest; one whose poll failed first drains it on + // the way out or, once gone, refuses it with the same error. + // Either way the pair's being writable is never reported. + std::ignore = co_await s2.wait(wait_type::write); }; capy::run_async(ioc.get_executor())(parked()); capy::run_async(ioc.get_executor())(breaker()); @@ -2391,6 +2451,7 @@ struct iocp_faults testUdpIoFails(); testWaitReactorSetupFails(); testWaitReactorStartsOnFirstWait(); + testIdleReactorNotWokenByClose(); testWakeSendFails(); testWaitReactorPollFails(); testWaitReactorErrorProbe(); diff --git a/test/unit/iocp_paths.cpp b/test/unit/iocp_paths.cpp index 97002ece6..e31ebf7da 100644 --- a/test/unit/iocp_paths.cpp +++ b/test/unit/iocp_paths.cpp @@ -446,10 +446,13 @@ struct iocp_paths_test f.close(); } - // close() asks the wait reactor to drop the object's wait op even - // when none was ever parked. Objects destroyed before the reactor - // thread exists leave those asks queued; the first wait starts the - // thread, which must not touch the dead ops (ASan catches it). + // close() used to ask the wait reactor to drop the object's wait op + // even when none was ever parked, so objects destroyed before the + // reactor thread existed left those asks queued; the first wait + // started the thread, which must not touch the dead ops (ASan + // catches it). An ask now needs both a running thread and a parked + // op, so these objects ask nothing, and the scenario only shows + // that the wait after them still completes. void testStaleWaitCancelsFromDestroyedObjects() { io_context ioc(iocp); From 7f0044a448883d057c0ace930fd899aae1ed894a Mon Sep 17 00:00:00 2001 From: TheLingeringWill Date: Sat, 14 Nov 2026 02:44:38 +0100 Subject: [PATCH 4/6] perf: drop the O(n) erases and extra recv from the IOCP wait reactor Removing a fired, cancelled or refused wait erased it from the middle of the table, moving every entry after it down, so k completions out of n parked waits cost O(k*n) moves. The table has no order, so the last entry now fills the hole; the walks that remove as they go run from the back, so the entry moved was already examined and the slots below it keep their poll set entries. Draining the wakeup channel read until recv failed, which took a second recv on every wake just to hear WSAEWOULDBLOCK. Only the sender that turns wake_pending_ from false to true writes a byte, so the channel holds one at a time and a short read has emptied it. --- .../native/detail/iocp/win_wait_reactor.hpp | 34 +++++++--- test/unit/fault/iocp_faults.cpp | 62 +++++++++++++++++++ 2 files changed, 89 insertions(+), 7 deletions(-) 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 daee9608f..4f8922acf 100644 --- a/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp +++ b/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp @@ -138,6 +138,7 @@ class win_wait_reactor : private win_wsa_init void run(); bool drop_refused_entries(); + void remove_entry(std::size_t i) noexcept; DWORD queue_register(entry const& e); void wake_self() noexcept; DWORD make_wakeup_pair() noexcept; @@ -293,9 +294,10 @@ win_wait_reactor::make_wakeup_pair() noexcept } ::closesocket(listener); - // The drain loop in run() calls recv() until it returns <= 0. - // With a blocking socket that second recv() would block instead - // of returning WSAEWOULDBLOCK, deadlocking the reactor thread. + // The drain loop in run() reads again after a full buffer. With a + // blocking socket that recv() would block on an empty channel + // instead of returning WSAEWOULDBLOCK, deadlocking the reactor + // thread. u_long non_blocking = 1; if (::ioctlsocket(wakeup_read_, FIONBIO, &non_blocking) == SOCKET_ERROR) { @@ -479,12 +481,24 @@ win_wait_reactor::drop_refused_entries() DWORD const err = last_error(); e.op->reactor_parked_.store(false, std::memory_order_release); sched_.on_completion(e.op, err, 0); - registered_.erase(registered_.begin() + (i - 1)); + remove_entry(i - 1); dropped = true; } return dropped; } +inline void +win_wait_reactor::remove_entry(std::size_t i) noexcept +{ + // Order means nothing in the table, so the last entry fills the hole + // instead of everything after it moving down. The walks that remove + // as they go run from the back, so the entry moved into slot i was + // already examined, and the entries below i -- with their poll set + // slots -- stay put. + registered_[i] = registered_.back(); + registered_.pop_back(); +} + inline void win_wait_reactor::run() { @@ -542,7 +556,8 @@ win_wait_reactor::run() // request_cancel; invoke_handler will translate it. op->reactor_parked_.store(false, std::memory_order_release); sched_.on_completion(op, 0, 0); - registered_.erase(it); + remove_entry( + static_cast(it - registered_.begin())); } } @@ -581,11 +596,16 @@ win_wait_reactor::run() // Drain the wakeup socket so it stops reporting readable. if (pollfds[0].revents != 0) { + // wake_self sends only on the false-to-true turn of + // wake_pending_, which this drain turns back, so the channel + // holds a byte at a time: a short read has emptied it, and a + // further recv would only report WSAEWOULDBLOCK. A byte that + // lands after it makes the next poll return at once. char buf[64]; for (;;) { int r = ::recv(wakeup_read_, buf, sizeof(buf), 0); - if (r <= 0) + if (r < static_cast(sizeof(buf))) break; } wake_pending_.store(false, std::memory_order_release); @@ -632,7 +652,7 @@ win_wait_reactor::run() e.op->reactor_parked_.store(false, std::memory_order_release); sched_.on_completion(e.op, err, 0); - registered_.erase(registered_.begin() + (i - 2)); + remove_entry(i - 2); } } diff --git a/test/unit/fault/iocp_faults.cpp b/test/unit/fault/iocp_faults.cpp index 5abefd6b2..a33974d87 100644 --- a/test/unit/fault/iocp_faults.cpp +++ b/test/unit/fault/iocp_faults.cpp @@ -1483,6 +1483,67 @@ struct iocp_faults s2.close(); } + // Dropping a wait from the middle of the reactor's table moves the + // last entry into its slot. The moved wait stays parked, polled for + // what it asked, and still the reactor's to drop. + void testReactorRemovalKeepsMovedWait() + { + io_context ioc(iocp); + auto [a1, a2] = must(make_socket_pair(ioc)); + auto [b1, b2] = must(make_socket_pair(ioc)); + std::error_code aec, bec; + bool a_done = false; + bool b_done = false; + bool b_early = true; + bool expired = false; + // An error wait on a quiet socket never becomes ready, so only + // a cancel ends either of these. + auto wait_a = [&]() -> capy::task<> { + auto [ec] = co_await a1.wait(wait_type::error); + aec = ec; + a_done = true; + }; + auto wait_b = [&]() -> capy::task<> { + auto [ec] = co_await b1.wait(wait_type::error); + bec = ec; + b_done = true; + }; + auto driver = [&]() -> capy::task<> { + // a1's wait was registered first, so it is not the last + // entry, and dropping it moves b1's into its slot. + a1.cancel(); + for (int i = 0; i < 100 && !a_done; ++i) + std::ignore = + co_await corosio::delay(std::chrono::milliseconds(5)); + // A few more polls with the moved entry in place must not + // complete it. The pauses are not a timing assertion. + for (int i = 0; i < 4; ++i) + std::ignore = + co_await corosio::delay(std::chrono::milliseconds(5)); + b_early = b_done; + b1.cancel(); + for (int i = 0; i < 100 && !b_done; ++i) + std::ignore = + co_await corosio::delay(std::chrono::milliseconds(5)); + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(wait_a()); + capy::run_async(ioc.get_executor())(wait_b()); + capy::run_async(ioc.get_executor())(driver()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(a_done); + BOOST_TEST(aec == capy::error::canceled); + BOOST_TEST(!b_early); + BOOST_TEST(b_done); + BOOST_TEST(bec == capy::error::canceled); + a1.close(); + a2.close(); + b1.close(); + b2.close(); + } + /* A wake that never left has to be retried, not swallowed. `wake_pending_` is set before the send and stands for a byte in @@ -2452,6 +2513,7 @@ struct iocp_faults testWaitReactorSetupFails(); testWaitReactorStartsOnFirstWait(); testIdleReactorNotWokenByClose(); + testReactorRemovalKeepsMovedWait(); testWakeSendFails(); testWaitReactorPollFails(); testWaitReactorErrorProbe(); From 5526b4412f85befbaed8d54ac863a7236d2eeb6a Mon Sep 17 00:00:00 2001 From: TheLingeringWill Date: Sat, 14 Nov 2026 02:45:40 +0100 Subject: [PATCH 5/6] fix: arm the IOCP waitable timer under the dispatch lock update_timeout read the earliest expiry and set the waitable timer with no lock held. Two threads arming at once could read different expiries and set the timer in the opposite order, leaving the later deadline set, so the earlier timer fired late, at the later one's deadline. A thread inserting a new earliest timer races the run thread re-arming after a fire the same way. The read and the arm now happen under the dispatch lock, as asio arms its timer. A new earliest expiry is published before its notification takes the lock, so the last thread through always arms the earliest one. --- include/boost/corosio/native/detail/iocp/win_scheduler.hpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/include/boost/corosio/native/detail/iocp/win_scheduler.hpp b/include/boost/corosio/native/detail/iocp/win_scheduler.hpp index d79d61d9f..b7e781ebb 100644 --- a/include/boost/corosio/native/detail/iocp/win_scheduler.hpp +++ b/include/boost/corosio/native/detail/iocp/win_scheduler.hpp @@ -808,6 +808,13 @@ win_scheduler::update_timeout() return; if (timers_) { + // Read and arm under one lock. Two threads arming at once could + // otherwise read different earliest expiries and set the timer + // in the opposite order, leaving the later deadline set and the + // earlier timer firing late. A new earliest expiry is published + // before its notification takes this lock, so the last thread + // through arms the earliest one. + std::lock_guard lock(dispatch_mutex_); timers_->update_timeout(tsvc->nearest_expiry()); } else From c3e11482f0f4a584527fb7793ce7b01fcd4aea34 Mon Sep 17 00:00:00 2001 From: TheLingeringWill Date: Sat, 14 Nov 2026 02:48:16 +0100 Subject: [PATCH 6/6] perf: move the stop token into IOCP file operations The stream and random-access file wrappers passed their by-value stop token on by copy, so every read and write took and dropped a reference on the stop state. They now move it, as the stream handle already does. --- .../boost/corosio/native/detail/iocp/win_file_service.hpp | 5 +++-- .../native/detail/iocp/win_random_access_file_service.hpp | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/include/boost/corosio/native/detail/iocp/win_file_service.hpp b/include/boost/corosio/native/detail/iocp/win_file_service.hpp index bf31c2e44..55964220e 100644 --- a/include/boost/corosio/native/detail/iocp/win_file_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_file_service.hpp @@ -31,6 +31,7 @@ #include #include +#include namespace boost::corosio::detail { @@ -216,7 +217,7 @@ win_stream_file::read_some( std::error_code* ec, std::size_t* bytes) { - return internal_.read_some(cont, d, buf, token, ec, bytes); + return internal_.read_some(cont, d, buf, std::move(token), ec, bytes); } inline std::coroutine_handle<> @@ -228,7 +229,7 @@ win_stream_file::write_some( std::error_code* ec, std::size_t* bytes) { - return internal_.write_some(cont, d, buf, token, ec, bytes); + return internal_.write_some(cont, d, buf, std::move(token), ec, bytes); } inline native_handle_type diff --git a/include/boost/corosio/native/detail/iocp/win_random_access_file_service.hpp b/include/boost/corosio/native/detail/iocp/win_random_access_file_service.hpp index a8baea10e..95ea0710a 100644 --- a/include/boost/corosio/native/detail/iocp/win_random_access_file_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_random_access_file_service.hpp @@ -32,6 +32,7 @@ #include #include +#include namespace boost::corosio::detail { @@ -189,7 +190,8 @@ win_random_access_file::read_some_at( std::error_code* ec, std::size_t* bytes) { - return internal_.read_some_at(offset, cont, d, buf, token, ec, bytes); + return internal_.read_some_at( + offset, cont, d, buf, std::move(token), ec, bytes); } inline std::coroutine_handle<> @@ -202,7 +204,8 @@ win_random_access_file::write_some_at( std::error_code* ec, std::size_t* bytes) { - return internal_.write_some_at(offset, cont, d, buf, token, ec, bytes); + return internal_.write_some_at( + offset, cont, d, buf, std::move(token), ec, bytes); } inline native_handle_type